Files
Lizandro GuarnizoandClaude Sonnet 4.6 03f02d2788 fix(webhook): skip SSE notification and DB notification for canal=turnero messages
Turnero messages were triggering the SSE event that conversations.php listens to,
causing the contact to flash briefly at the top of the list before disappearing
on the next poll (since get_conversations.php filters them out by canal).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 08:43:20 -05:00

615 lines
27 KiB
PHP

<?php
/**
* Webhook para recibir mensajes de WhatsApp
* Fecha: 13 de noviembre de 2025
*/
require_once __DIR__ . '/../config/config.php';
// Headers para API
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST');
header('Access-Control-Allow-Headers: Content-Type');
class WhatsAppWebhook {
private $db;
private $whatsappService;
private $botService;
public function __construct() {
error_log('[webhook] __construct start');
try {
$this->db = Database::getInstance();
$this->whatsappService = new WhatsAppService();
$this->botService = new BotService();
} catch (Throwable $t) {
error_log('[webhook] constructor failed: ' . $t->getMessage());
if (function_exists('writeLog')) {
writeLog('ERROR', 'Webhook constructor failed: ' . $t->getMessage(), ['trace' => $t->getTraceAsString()]);
}
throw $t;
}
error_log('[webhook] __construct end');
}
public function handleRequest() {
error_log('[webhook] handleRequest start, method=' . ($_SERVER['REQUEST_METHOD'] ?? 'unknown'));
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$this->verifyWebhook();
} elseif ($method === 'POST') {
$this->processIncomingMessage();
} else {
http_response_code(405);
echo json_encode(['error' => 'Método no permitido']);
}
}
private function verifyWebhook() {
$verifyToken = $_GET['hub_verify_token'] ?? '';
$challenge = $_GET['hub_challenge'] ?? '';
$mode = $_GET['hub_mode'] ?? '';
if ($mode === 'subscribe' && $verifyToken === WEBHOOK_VERIFY_TOKEN) {
echo $challenge;
exit;
} else {
http_response_code(403);
echo json_encode(['error' => 'Token de verificación inválido']);
}
}
private function processIncomingMessage() {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// Registrar webhook en logs
$this->logWebhook($input, json_encode(['status' => 'received']), 200);
if (!$data || !isset($data['entry'])) {
http_response_code(400);
echo json_encode(['error' => 'Datos inválidos']);
return;
}
try {
foreach ($data['entry'] as $entry) {
if (isset($entry['changes'])) {
foreach ($entry['changes'] as $change) {
// Soportar payloads con field 'conversations' y 'messages' (WhatsApp puede variar)
if ($change['field'] === 'conversations' || $change['field'] === 'messages' || isset($change['value']['messages'])) {
$this->processconversations($change['value']);
}
}
}
}
echo json_encode(['status' => 'success']);
} catch (Exception $e) {
error_log("Error processing webhook: " . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'Error interno del servidor']);
}
}
private function processconversations($value) {
// Detectar si este mensaje llegó al número turnero
$receivingPhoneId = $value['metadata']['phone_number_id'] ?? null;
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
$isTurnero = $receivingPhoneId && $turneroPhoneId && ($receivingPhoneId === $turneroPhoneId);
// Aceptar tanto payloads con 'conversations' como con 'messages' (WhatsApp varía según la integración)
$items = [];
if (isset($value['conversations']) && is_array($value['conversations'])) {
$items = $value['conversations'];
} elseif (isset($value['messages']) && is_array($value['messages'])) {
$items = $value['messages'];
} else {
return;
}
// Extraer nombres de contactos del payload (contacts[].profile.name)
$contactNames = [];
if (isset($value['contacts']) && is_array($value['contacts'])) {
foreach ($value['contacts'] as $contact) {
$waId = $contact['wa_id'] ?? null;
$name = $contact['profile']['name'] ?? null;
if ($waId && $name) {
$contactNames[$waId] = $name;
}
}
}
foreach ($items as $message) {
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
$timestamp = $message['timestamp'] ?? null;
// Si falta lo crítico, saltar
if (empty($phoneNumber) || empty($messageId)) {
continue;
}
// Verificar si ya procesamos este mensaje
$existing = $this->db->fetch(
"SELECT id FROM conversations WHERE message_id = :message_id",
['message_id' => $messageId]
);
if ($existing) {
continue; // Ya procesamos este mensaje
}
// Obtener o crear usuario
$user = $this->getUserByPhone($phoneNumber);
$contactName = $contactNames[$phoneNumber] ?? null;
if (!$user) {
$userId = $this->createUser($phoneNumber);
$user = $this->getUserById($userId);
// Guardar nombre del contacto de WhatsApp al crear usuario
if ($contactName && $userId) {
try {
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ?)', [$userId, '']);
$user['name'] = $contactName;
} catch (Exception $e) {
error_log('[webhook] Error guardando nombre contacto: ' . $e->getMessage());
}
}
} else {
// Actualizar nombre si el usuario no tiene uno guardado
if ($contactName && (empty($user['name']))) {
try {
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ?)', [$user['id'], '']);
$user['name'] = $contactName;
} catch (Exception $e) {
error_log('[webhook] Error actualizando nombre contacto: ' . $e->getMessage());
}
}
}
// Procesar diferentes tipos de mensaje
$messageText = '';
$messageType = 'text';
$mediaUrl = null;
if (isset($message['reaction']) || (isset($message['type']) && $message['type'] === 'reaction')) {
// Reacción (emoji) a un mensaje anterior
$messageType = 'reaction';
$emoji = $message['reaction']['emoji'] ?? ($message['emoji'] ?? '');
$reactionTo = $message['reaction']['message_id'] ?? ($message['context']['id'] ?? '');
$messageText = json_encode(['emoji' => $emoji, 'message_id' => $reactionTo]);
// Guardar campos específicos para reacciones
$extraFields = [
'reaction_to_message_id' => $reactionTo ? intval($reactionTo) : null,
'reaction_emoji' => $emoji
];
} elseif (isset($message['interactive'])) {
// Interactive replies (list or button) - normalize to text so bot can process
$messageType = 'text';
if (isset($message['interactive']['list_reply']['title'])) {
$messageText = $message['interactive']['list_reply']['title'];
// If title starts with a number ("1. Option"), convert to the number for menu selection
if (preg_match('/^\s*(\d+)\b/', $messageText, $m)) {
$messageText = $m[1];
}
} elseif (isset($message['interactive']['button_reply']['title'])) {
$messageText = $message['interactive']['button_reply']['title'];
if (preg_match('/^\s*(\d+)\b/', $messageText, $m)) {
$messageText = $m[1];
}
} else {
$messageText = json_encode($message['interactive']);
}
error_log(sprintf('[webhook] interactive received title=%s mapped=%s', substr($message['interactive']['list_reply']['title'] ?? ($message['interactive']['button_reply']['title'] ?? json_encode($message['interactive'])),0,200), substr($messageText,0,200)));
} elseif (isset($message['text']) || ($message['type'] ?? '') === 'text') {
$messageText = isset($message['text']['body']) ? $message['text']['body'] : ($message['body'] ?? '');
$messageType = 'text';
} elseif (isset($message['image'])) {
$messageText = $message['image']['caption'] ?? '';
$messageType = 'image';
// Preferir media ID (estable) sobre URL (expira en minutos)
$mediaUrl = $message['image']['id'] ?? ($message['image']['url'] ?? null);
$mimeType = $message['image']['mime_type'] ?? null;
$filename = $message['image']['filename'] ?? null;
} elseif (isset($message['audio'])) {
$messageType = 'audio';
$mediaUrl = $message['audio']['id'] ?? ($message['audio']['url'] ?? null);
$mimeType = $message['audio']['mime_type'] ?? null;
$filename = $message['audio']['filename'] ?? null;
} elseif (isset($message['video'])) {
$messageText = $message['video']['caption'] ?? '';
$messageType = 'video';
$mediaUrl = $message['video']['id'] ?? ($message['video']['url'] ?? null);
$mimeType = $message['video']['mime_type'] ?? null;
$filename = $message['video']['filename'] ?? null;
} elseif (isset($message['document'])) {
$messageText = $message['document']['filename'] ?? '';
$messageType = 'document';
$mediaUrl = $message['document']['id'] ?? ($message['document']['url'] ?? null);
$mimeType = $message['document']['mime_type'] ?? null;
$filename = $message['document']['filename'] ?? null;
} elseif (isset($message['sticker'])) {
$messageType = 'sticker';
$mediaUrl = $message['sticker']['id'] ?? ($message['sticker']['url'] ?? null);
$mimeType = $message['sticker']['mime_type'] ?? null;
$filename = $message['sticker']['filename'] ?? null;
}
// Log debug (temporal) para verificar cómo llegan media_url/filename
error_log(sprintf('[webhook] messageId=%s type=%s mediaUrl=%s filename=%s mime=%s', $messageId, $messageType, $mediaUrl ?? 'NULL', $filename ?? 'NULL', $mimeType ?? 'NULL'));
// ── Descargar media localmente de inmediato ──
// Si hay un media ID (numérico) o URL de Graph, descargarlo y guardarlo en uploads/media/
$localFile = null;
$localThumb = null;
if (!empty($mediaUrl) && in_array($messageType, ['image', 'audio', 'video', 'document', 'sticker'])) {
try {
set_time_limit(180); // Tiempo extra para descargar media pesado
$mediaService = new MediaService();
$subdir = date('Y/m'); // Organizar por año/mes
// Determinar si es un media ID (numérico) o una URL directa
if (preg_match('/^\d+$/', $mediaUrl)) {
// Es un media ID de WhatsApp → resolver vía Graph API y descargar
$stored = $mediaService->fetchAndStoreFromGraph($mediaUrl, $subdir);
} else {
// Es una URL directa → descargar directamente
$stored = $mediaService->fetchAndStoreFromUrl($mediaUrl, $subdir);
}
if (!empty($stored['local_file'])) {
$localFile = $stored['local_file'];
error_log("[webhook] Media descargado localmente: {$localFile}");
}
if (!empty($stored['local_thumb'])) {
$localThumb = $stored['local_thumb'];
}
} catch (Exception $mediaEx) {
error_log("[webhook] Error descargando media (se encolará para retry): " . $mediaEx->getMessage());
// Encolar en media_queue para reintento posterior
try {
$this->db->insert('media_queue', [
'media_id' => preg_match('/^\d+$/', $mediaUrl) ? $mediaUrl : '',
'media_url' => $mediaUrl,
'subdir' => date('Y/m'),
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s')
]);
error_log("[webhook] Media encolado en media_queue para retry");
} catch (Exception $qEx) {
error_log("[webhook] Error encolando media: " . $qEx->getMessage());
}
}
}
// Guardar mensaje en base de datos
$saveData = [
'user_id' => $user['id'],
'message_id' => $messageId,
'direction' => 'incoming',
'message_type' => $messageType,
'content' => $messageText,
'media_url' => $mediaUrl,
'status' => 'received',
'is_read' => 0,
'canal' => $isTurnero ? 'turnero' : 'bot',
];
// Guardar whatsapp_media_id si el mediaUrl es un ID numérico de WhatsApp
if (!empty($mediaUrl) && preg_match('/^\d+$/', $mediaUrl)) {
$saveData['whatsapp_media_id'] = $mediaUrl;
}
// Añadir campos opcionales solo si existen para evitar errores en esquemas antiguos
if (isset($filename) && $filename !== null) {
$saveData['filename'] = $filename;
}
if (isset($mimeType) && $mimeType !== null) {
$saveData['mime_type'] = $mimeType;
}
// Si se descargó y almacenó localmente, añadir las rutas locales
if (isset($localFile) && $localFile) {
$saveData['local_file'] = $localFile;
}
if (isset($localThumb) && $localThumb) {
$saveData['local_thumb'] = $localThumb;
}
// Si el mensaje incluye contexto (es respuesta a otro mensaje)
if (isset($message['context']) && isset($message['context']['id'])) {
$saveData['reply_to_message_id'] = $message['context']['id'];
}
// Special handling for reaction messages: apply reaction to referenced message instead of creating separate record
if (isset($extraFields) && is_array($extraFields) && isset($messageType) && $messageType === 'reaction' && !empty($reactionTo)) {
try {
// Try to update the referenced message (by message_id or id)
$updated = $this->db->update(
'conversations',
['reaction_emoji' => $emoji, 'reaction_to_message_id' => $reactionTo],
'message_id = :mid OR id = :mid',
['mid' => $reactionTo]
);
// Optionally create a small notification about the reaction
try {
$this->db->insert('notifications', [
'user_id' => $user['id'],
'type' => 'reaction',
'message' => "Reacción recibida: {$emoji}",
'data' => json_encode(['message_id' => $reactionTo, 'emoji' => $emoji]),
'is_read' => 0,
'created_at' => date('Y-m-d H:i:s')
]);
} catch (Exception $ne) {
error_log('Failed to create reaction notification: ' . $ne->getMessage());
}
// We handled the reaction by updating the target message - skip creating a separate reaction record
continue;
} catch (Exception $e) {
// If update failed, fallback to saving the reaction as a message
error_log('Failed to apply reaction update: ' . $e->getMessage());
$saveData = array_merge($saveData, $extraFields);
}
} else {
// Añadir campos extra (si se detectaron)
if (isset($extraFields) && is_array($extraFields)) {
$saveData = array_merge($saveData, $extraFields);
}
}
// Filtrar campos según columnas existentes para evitar errores en esquemas antiguos
$saveData = $this->filterColumns('conversations', $saveData);
// Guardar mensaje con INSERT IGNORE para deduplicar webhooks duplicados.
// Si el message_id ya existe (segunda llamada paralela), rowCount=0 y saltamos el bot.
$conversationId = $this->saveMessage($saveData);
if (!$conversationId) {
// Mensaje ya procesado por una llamada concurrente, ignorar
error_log("[webhook] Duplicate webhook ignorado para message_id={$messageId}");
continue;
}
// Si el media se encoló (no se pudo descargar), actualizar media_queue con el conversation_id
if (!empty($mediaUrl) && empty($localFile) && in_array($messageType, ['image', 'audio', 'video', 'document', 'sticker'])) {
try {
$this->db->query(
"UPDATE media_queue SET conversation_id = :cid WHERE media_id = :mid AND conversation_id IS NULL ORDER BY id DESC LIMIT 1",
['cid' => $conversationId, 'mid' => (preg_match('/^\d+$/', $mediaUrl) ? $mediaUrl : '')]
);
} catch (Exception $e) {
error_log("[webhook] Error actualizando media_queue con conversation_id: " . $e->getMessage());
}
}
// Notificaciones y SSE solo para el canal principal (no turnero)
if (!$isTurnero) {
try {
$this->db->insert('notifications', [
'user_id' => $user['id'],
'type' => 'incoming_message',
'message' => substr($messageText, 0, 250),
'data' => json_encode(['message_id' => $messageId]),
'is_read' => 0,
'created_at' => date('Y-m-d H:i:s')
]);
} catch (Exception $e) {
error_log('Failed to create notification: ' . $e->getMessage());
}
$this->pushSSEEvent('new_message', [
'user_id' => $user['id'],
'phone_number' => $user['phone_number'],
'name' => $user['name'] ?? ($phoneNumber ?? ''),
'message' => substr($messageText, 0, 250),
'message_type' => $messageType,
'timestamp' => date('Y-m-d H:i:s')
]);
}
// Procesar con bot solo si el mensaje llegó al número principal
if (!$isTurnero) {
try {
$this->botService->processMessage($user, $messageText, $messageType);
} catch (Exception $e) {
error_log("Bot processing failed: " . $e->getMessage());
}
}
}
// Procesar estados de mensajes (entregado, leído, etc.)
if (isset($value['statuses'])) {
$this->processconversationstatuses($value['statuses']);
}
}
private function processconversationstatuses($statuses) {
foreach ($statuses as $status) {
$messageId = $status['id'];
$newStatus = $status['status']; // sent, delivered, read, failed
$this->db->update(
'conversations',
['status' => $newStatus],
'message_id = :message_id',
['message_id' => $messageId]
);
}
}
private function getUserByPhone($phoneNumber) {
return $this->db->fetch(
"SELECT * FROM users WHERE phone_number = :phone",
['phone' => $phoneNumber]
);
}
private function getUserById($userId) {
return $this->db->fetch(
"SELECT * FROM users WHERE id = :id",
['id' => $userId]
);
}
private function createUser($phoneNumber, $sendWelcome = true) {
$id = $this->db->insert('users', [
'phone_number' => $phoneNumber,
'status' => 'active',
'created_at' => date('Y-m-d H:i:s')
]);
if ($sendWelcome && $id) {
try {
// Enviar mensaje de bienvenida usando el BotService (que consulta system_config)
$this->botService->sendWelcomeMessage($phoneNumber);
} catch (Throwable $t) {
error_log('Failed to send welcome message: ' . $t->getMessage());
}
}
return $id;
}
private function saveMessage($messageData) {
// INSERT IGNORE: si message_id ya existe (webhook duplicado), no inserta y devuelve 0
$cols = implode(', ', array_map(fn($c) => "`{$c}`", array_keys($messageData)));
$placeholders = implode(', ', array_map(fn($c) => ":{$c}", array_keys($messageData)));
$sql = "INSERT IGNORE INTO `conversations` ({$cols}) VALUES ({$placeholders})";
$stmt = $this->db->getConnection()->prepare($sql);
$stmt->execute($messageData);
if ($stmt->rowCount() === 0) {
return 0; // duplicado
}
return $this->db->getConnection()->lastInsertId();
}
/**
* Filtrar los datos para dejar solo las columnas que existen en la tabla
* Evita errores cuando la BD está en un esquema más antiguo
*/
private function filterColumns($table, array $data) {
static $columnsCache = [];
if (!isset($columnsCache[$table])) {
$cols = $this->db->fetchAll("SHOW COLUMNS FROM {$table}");
$columnsCache[$table] = array_map(function($c){ return $c['Field']; }, $cols);
}
$allowed = $columnsCache[$table];
return array_filter($data, function($v, $k) use ($allowed) {
return in_array($k, $allowed, true);
}, ARRAY_FILTER_USE_BOTH);
}
private function logWebhook($requestBody, $responseBody, $statusCode) {
if (ENABLE_LOGGING) {
$this->db->insert('webhook_logs', [
'request_body' => $requestBody,
'response_body' => $responseBody,
'status_code' => $statusCode,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'created_at' => date('Y-m-d H:i:s')
]);
}
}
/**
* Empujar evento a través de SSE (Server-Sent Events)
* Notifica a todos los operadores conectados sobre nuevos eventos
*/
private function pushSSEEvent($eventType, $data) {
try {
// Llamar al endpoint push_event interno
$pushUrl = 'http://127.0.0.1' . dirname($_SERVER['SCRIPT_NAME']) . '/push_event.php';
$payload = json_encode([
'event_type' => $eventType,
'data' => $data,
'target_user_id' => 'all' // Notificar a todos los operadores
]);
// Hacer llamada asíncrona (fire and forget)
$ch = curl_init($pushUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT_MS => 500, // Timeout corto
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Push-Token: internal_push_secret_2026'
]
]);
// Ejecutar sin bloquear
curl_exec($ch);
curl_close($ch);
} catch (Exception $e) {
// No fallar si el push falla
error_log('pushSSEEvent failed: ' . $e->getMessage());
}
}
/**
* Procesar un payload (array) recibido manualmente (replay)
* Útil para reproducir entradas desde la UI o scripts.
*/
public function processPayload(array $data) {
error_log("processPayload START");
// Registrar como recibido (reproducción)
$this->logWebhook(json_encode($data), json_encode(['status' => 'replayed']), 200);
if (empty($data) || !isset($data['entry'])) {
error_log("processPayload: empty or missing entry");
return false;
}
foreach ($data['entry'] as $entry) {
if (isset($entry['changes'])) {
foreach ($entry['changes'] as $change) {
error_log("processPayload: processing change field=" . ($change['field'] ?? ''));
if ((($change['field'] ?? '') === 'conversations' || ($change['field'] ?? '') === 'messages' || isset($change['value']['messages'])) && isset($change['value'])) {
try {
$this->processconversations($change['value']);
} catch (Exception $e) {
error_log("processPayload: processconversations failed: " . $e->getMessage());
}
}
}
}
}
error_log("processPayload END");
return true;
}
}
// Procesar la solicitud (solo si no estamos en CLI)
if (php_sapi_name() !== 'cli') {
try {
$webhook = new WhatsAppWebhook();
$webhook->handleRequest();
} catch (Throwable $t) {
// Manejar excepciones fatales y errores (Throwable)
error_log("Fatal error in webhook: " . $t->getMessage());
error_log($t->getTraceAsString());
if (function_exists('writeLog')) {
writeLog('ERROR', 'Fatal error in webhook: ' . $t->getMessage(), ['trace' => $t->getTraceAsString()]);
}
http_response_code(500);
echo json_encode(['error' => 'Error fatal del servidor', 'detail' => $t->getMessage()]);
}
}
?>