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()]); } } ?>