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>
615 lines
27 KiB
PHP
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()]);
|
|
}
|
|
}
|
|
?>
|