This commit is contained in:
Lizandro Guarnizo
2026-01-27 14:51:41 -05:00
parent c1f3524466
commit 1f224044f7
21 changed files with 2997 additions and 47 deletions
+3
View File
@@ -6,6 +6,9 @@
require_once '../config/config.php';
// Verificar autenticación
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET');
+115
View File
@@ -0,0 +1,115 @@
<?php
/**
* API interna para empujar eventos SSE
* Este endpoint es llamado por el webhook para notificar eventos
* Fecha: 27 de enero de 2026
*/
require_once '../config/config.php';
header('Content-Type: application/json; charset=utf-8');
// Solo permitir llamadas internas
$allowedIPs = ['127.0.0.1', '::1', 'localhost'];
$clientIP = $_SERVER['REMOTE_ADDR'] ?? '';
// En producción, verificar IP o usar token secreto
$secretToken = $_SERVER['HTTP_X_PUSH_TOKEN'] ?? $_POST['push_token'] ?? '';
$validToken = getenv('PUSH_TOKEN') ?: 'internal_push_secret_2026';
if (!in_array($clientIP, $allowedIPs) && $secretToken !== $validToken) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'No autorizado']);
exit;
}
try {
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
$input = $_POST;
}
$eventType = $input['event_type'] ?? 'message';
$eventData = $input['data'] ?? [];
$targetUserId = $input['target_user_id'] ?? 'all'; // ID del operador, no del cliente
// Si no se especifica usuario objetivo, broadcast a todos
$targets = [];
if ($targetUserId === 'all') {
// Broadcast: usar archivo global para todos los clientes conectados
$targets = ['global'];
// Opcional: También enviar a usuarios con token conocidos
// (buscar archivos events_token_*.json en uploads)
$uploadsDir = __DIR__ . '/../uploads/';
if (is_dir($uploadsDir)) {
$tokenFiles = glob($uploadsDir . 'events_token_*.json');
foreach ($tokenFiles as $file) {
$basename = basename($file, '.json');
$userId = str_replace('events_', '', $basename);
if ($userId && $userId !== 'global') {
$targets[] = $userId;
}
}
}
} else {
$targets = [$targetUserId];
}
// Escribir evento en archivos temporales para cada usuario objetivo
$eventId = uniqid('evt_', true);
$event = [
'id' => $eventId,
'type' => $eventType,
'data' => $eventData,
'timestamp' => time()
];
$written = 0;
foreach ($targets as $target) {
$eventsFile = __DIR__ . "/../uploads/events_{$target}.json";
// Asegurar que el directorio existe
$dir = dirname($eventsFile);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
// Leer eventos existentes
$existingEvents = [];
if (file_exists($eventsFile)) {
$content = @file_get_contents($eventsFile);
if ($content) {
$existingEvents = json_decode($content, true) ?: [];
}
}
// Agregar nuevo evento
$existingEvents[] = $event;
// Mantener solo últimos 50 eventos
if (count($existingEvents) > 50) {
$existingEvents = array_slice($existingEvents, -50);
}
// Guardar
if (@file_put_contents($eventsFile, json_encode($existingEvents))) {
$written++;
}
}
echo json_encode([
'success' => true,
'event_id' => $eventId,
'targets_notified' => $written
]);
} catch (Exception $e) {
error_log("push_event.php error: " . $e->getMessage());
http_response_code(500);
echo json_encode([
'success' => false,
'error' => $e->getMessage()
]);
}
+33 -12
View File
@@ -67,20 +67,22 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
// Función auxiliar para enviar con media_id
function sendMediaByIdToWhatsApp($whatsapp, $recipient, $mediaId, $mediaType, $caption, $filename) {
return $whatsapp->sendMediaById($recipient, $mediaId, $mediaType, $caption, $filename);
// Pasar skipAutoSave=true para evitar guardado duplicado (lo guardamos manualmente después)
return $whatsapp->sendMediaById($recipient, $mediaId, $mediaType, $caption, $filename, true);
}
// Función auxiliar para enviar con link
function sendMediaByLinkToWhatsApp($whatsapp, $recipient, $mediaUrl, $mediaType, $caption, $filename) {
// Pasar skipAutoSave=true para evitar guardado duplicado (lo guardamos manualmente después)
switch ($mediaType) {
case 'image':
return $whatsapp->sendImageMessage($recipient, $mediaUrl, $caption);
return $whatsapp->sendImageMessage($recipient, $mediaUrl, $caption, true);
case 'video':
return $whatsapp->sendVideoMessage($recipient, $mediaUrl, $caption);
return $whatsapp->sendVideoMessage($recipient, $mediaUrl, $caption, true);
case 'audio':
return $whatsapp->sendAudioMessage($recipient, $mediaUrl);
return $whatsapp->sendAudioMessage($recipient, $mediaUrl, true);
case 'document':
return $whatsapp->sendDocumentMessage($recipient, $mediaUrl, $filename, $caption);
return $whatsapp->sendDocumentMessage($recipient, $mediaUrl, $filename, $caption, true);
default:
throw new Exception('Tipo de media no soportado: ' . $mediaType);
}
@@ -333,26 +335,45 @@ try {
);
if ($user) {
// Determinar content: prioridad filename > caption > descripción por defecto
$content = '';
if (!empty($filename)) {
$content = $filename;
} elseif (!empty($caption)) {
$content = $caption;
} else {
// Descripción por defecto según tipo
$defaultLabels = [
'image' => '[Imagen]',
'video' => '[Video]',
'audio' => '[Audio]',
'document' => '[Documento]'
];
$content = $defaultLabels[$mediaType] ?? '[Media]';
}
// Datos base para insertar
// content: solo caption o nombre de archivo
// content: nombre de archivo o caption (nunca JSON)
// media_url: ID de WhatsApp si está disponible, sino la URL original
$conversationData = [
'user_id' => $user['id'],
'message_id' => $sentMessageId,
'direction' => 'outgoing',
'message_type' => $mediaType,
'content' => $caption ?: ($filename ?: ''),
'content' => $content,
'media_url' => isset($uploaded_media_id) ? $uploaded_media_id : $mediaUrl,
'whatsapp_media_id' => $uploaded_media_id ?? null,
'status' => 'sent',
'created_at' => date('Y-m-d H:i:s')
];
// Intentar añadir filename solo si la columna existe
// TODO: Ejecutar migración add_filename_to_conversations.sql
// ALTER TABLE conversations ADD COLUMN filename VARCHAR(255) NULL AFTER media_url;
error_log("send_media_message.php - Guardando en BD: " . json_encode($conversationData));
// Log detallado para debugging
error_log("send_media_message.php - Guardando mensaje multimedia:");
error_log(" - content: " . $content);
error_log(" - media_url: " . ($conversationData['media_url'] ?? 'null'));
error_log(" - whatsapp_media_id: " . ($conversationData['whatsapp_media_id'] ?? 'null'));
error_log(" - media_type: " . $mediaType);
smm_log("Saving to DB - content: {$content}, media_url: " . ($conversationData['media_url'] ?? 'null'));
try {
$db->insert('conversations', $conversationData);
+33
View File
@@ -16,3 +16,36 @@
[2026-01-21 14:34:33] Media ID obtained: 2656357121414699
[2026-01-21 14:34:34] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSNkEyNzVBODA0MEQ3RTI3M0I0AA=="}]}
[2026-01-21 14:34:34] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSNkEyNzVBODA0MEQ3RTI3M0I0AA=="}]}
[2026-01-27 11:22:17] Raw input: {"recipient":"573168950803","media_url":"http://localhost:8000/uploads/media_6978e6305ed3f9.57471688.png","media_type":"image","caption":null,"filename":"image.png"}
[2026-01-27 11:22:17] Input decoded: {"recipient":"573168950803","media_url":"http:\/\/localhost:8000\/uploads\/media_6978e6305ed3f9.57471688.png","media_type":"image","caption":null,"filename":"image.png"}
[2026-01-27 11:22:19] Upload result: {"id":"1521015225642113"}
[2026-01-27 11:22:19] Media ID obtained: 1521015225642113
[2026-01-27 11:22:20] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSNkM1NzU4MjA1NkFCRDc5QjI4AA=="}]}
[2026-01-27 11:22:20] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSNkM1NzU4MjA1NkFCRDc5QjI4AA=="}]}
[2026-01-27 11:26:42] Raw input: {"recipient":"573168950803","media_url":"http://localhost:8000/uploads/media_6978e7416984c9.82102744.png","media_type":"image","caption":null,"filename":"image.png"}
[2026-01-27 11:26:42] Input decoded: {"recipient":"573168950803","media_url":"http:\/\/localhost:8000\/uploads\/media_6978e7416984c9.82102744.png","media_type":"image","caption":null,"filename":"image.png"}
[2026-01-27 11:26:44] Upload result: {"id":"2345203359256908"}
[2026-01-27 11:26:44] Media ID obtained: 2345203359256908
[2026-01-27 11:26:45] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSOUNCMzU3QTZDQjUxNjIwN0UyAA=="}]}
[2026-01-27 11:26:45] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSOUNCMzU3QTZDQjUxNjIwN0UyAA=="}]}
[2026-01-27 14:10:55] Raw input: {"recipient":"573168950803","media_url":"http://localhost:8000/uploads/media_69790db0a11ab5.67639229.jpg","media_type":"image","caption":null,"filename":"media-url.jpg"}
[2026-01-27 14:10:55] Input decoded: {"recipient":"573168950803","media_url":"http:\/\/localhost:8000\/uploads\/media_69790db0a11ab5.67639229.jpg","media_type":"image","caption":null,"filename":"media-url.jpg"}
[2026-01-27 14:10:56] Upload result: {"id":"1222009752659731"}
[2026-01-27 14:10:56] Media ID obtained: 1222009752659731
[2026-01-27 14:10:57] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSN0U4MjRBRDRBQTYwQzBFRkRFAA=="}]}
[2026-01-27 14:10:57] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSN0U4MjRBRDRBQTYwQzBFRkRFAA=="}]}
[2026-01-27 14:10:57] Saving to DB - content: media-url.jpg, media_url: 1222009752659731
[2026-01-27 14:41:16] Raw input: {"recipient":"573168950803","media_url":"http://localhost:8000/uploads/media_697914b0af8943.61710113.jpg","media_type":"image","caption":null,"filename":"media-url.jpg"}
[2026-01-27 14:41:16] Input decoded: {"recipient":"573168950803","media_url":"http:\/\/localhost:8000\/uploads\/media_697914b0af8943.61710113.jpg","media_type":"image","caption":null,"filename":"media-url.jpg"}
[2026-01-27 14:41:17] Upload result: {"id":"1974106793522186"}
[2026-01-27 14:41:17] Media ID obtained: 1974106793522186
[2026-01-27 14:41:18] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSQjg4NTk3RDlBMjc0QzNERUFCAA=="}]}
[2026-01-27 14:41:18] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSQjg4NTk3RDlBMjc0QzNERUFCAA=="}]}
[2026-01-27 14:41:18] Saving to DB - content: media-url.jpg, media_url: 1974106793522186
[2026-01-27 14:48:37] Raw input: {"recipient":"573168950803","media_url":"http://localhost:8000/uploads/media_697916524ccef8.19324180.jpg","media_type":"image","caption":null,"filename":"media-url.jpg"}
[2026-01-27 14:48:37] Input decoded: {"recipient":"573168950803","media_url":"http:\/\/localhost:8000\/uploads\/media_697916524ccef8.19324180.jpg","media_type":"image","caption":null,"filename":"media-url.jpg"}
[2026-01-27 14:48:38] Upload result: {"id":"3817400915062613"}
[2026-01-27 14:48:38] Media ID obtained: 3817400915062613
[2026-01-27 14:48:39] Send media by id response: {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSRTJDMEIwNUExMkMwRkFDNUZFAA=="}]}
[2026-01-27 14:48:39] WhatsApp response (successful): {"messaging_product":"whatsapp","contacts":[{"input":"573168950803","wa_id":"573168950803"}],"messages":[{"id":"wamid.HBgMNTczMTY4OTUwODAzFQIAERgSRTJDMEIwNUExMkMwRkFDNUZFAA=="}]}
[2026-01-27 14:48:40] Saving to DB - content: media-url.jpg, media_url: 3817400915062613
+187
View File
@@ -0,0 +1,187 @@
<?php
/**
* Server-Sent Events (SSE) - Push de eventos en tiempo real
* Este endpoint mantiene una conexión abierta y envía eventos cuando ocurren cambios
* Fecha: 27 de enero de 2026
*/
require_once '../config/config.php';
// Configurar headers para SSE primero (antes de cualquier output)
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // Nginx
// Deshabilitar buffer de salida
if (ob_get_level()) ob_end_clean();
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', 'off');
// Función para enviar un evento SSE
function sendSSEEvent($event, $data, $id = null) {
if ($id !== null) {
echo "id: {$id}\n";
}
echo "event: {$event}\n";
echo "data: " . json_encode($data) . "\n\n";
if (ob_get_level()) ob_flush();
flush();
}
// Función para leer eventos pendientes de un archivo temporal
function readPendingEvents($userId) {
$eventsFile = __DIR__ . "/../uploads/events_{$userId}.json";
if (!file_exists($eventsFile)) {
return [];
}
$content = @file_get_contents($eventsFile);
if (!$content) return [];
$events = json_decode($content, true);
if (!is_array($events)) return [];
// Limpiar archivo después de leer
@file_put_contents($eventsFile, json_encode([]));
return $events;
}
try {
// Verificar autenticación (sesión o token)
$userId = null;
$authenticated = false;
// Opción 1: Sesión PHP
if (isset($_SESSION['user']['id'])) {
$userId = $_SESSION['user']['id'];
$authenticated = true;
}
// Opción 2: Token en query string (para EventSource sin credenciales)
$token = $_GET['token'] ?? null;
if (!$authenticated && $token) {
// Validar token simple (en producción usar JWT o token en BD)
if ($token === 'demo_token' || strlen($token) > 10) {
// Modo autenticado con token
$userId = 'token_' . substr(md5($token), 0, 8);
$authenticated = true;
}
}
// Si aún no está autenticado, usar modo global
if (!$authenticated) {
// Modo global: recibir eventos broadcast a todos
$userId = 'global';
error_log('SSE: Conexión en modo global (sin autenticación específica)');
}
$db = Database::getInstance();
// Enviar evento inicial de conexión
error_log("SSE: Nueva conexión establecida (userId: {$userId})");
sendSSEEvent('connected', [
'timestamp' => time(),
'user_id' => $userId,
'mode' => $authenticated ? 'authenticated' : 'global'
]);
// Obtener último timestamp conocido por el cliente
$lastEventId = $_SERVER['HTTP_LAST_EVENT_ID'] ?? null;
$lastCheck = time();
$connectionStart = time();
$maxConnectionTime = 300; // 5 minutos máximo
// Loop principal - revisar eventos cada 2 segundos
while (true) {
// Verificar tiempo máximo de conexión (evitar conexiones eternas)
if (time() - $connectionStart > $maxConnectionTime) {
error_log("SSE: Conexión alcanzó tiempo máximo ({$maxConnectionTime}s), cerrando...");
sendSSEEvent('timeout', ['message' => 'Conexión reiniciándose', 'reconnect' => true]);
break;
}
// Verificar si la conexión sigue activa
if (connection_aborted()) {
error_log("SSE: Cliente desconectado (userId: {$userId})");
break;
}
// Leer eventos pendientes del archivo temporal
$pendingEvents = readPendingEvents($userId);
foreach ($pendingEvents as $event) {
sendSSEEvent(
$event['type'] ?? 'message',
$event['data'] ?? [],
$event['id'] ?? null
);
}
// Revisar nuevos mensajes en BD cada 3 segundos
if (time() - $lastCheck >= 3) {
// Verificar si hay nuevas conversaciones desde la última revisión
$recentConversations = $db->fetchAll(
"SELECT DISTINCT c.user_id, u.phone_number, u.name,
MAX(c.created_at) as last_message_time,
COUNT(*) as message_count
FROM conversations c
INNER JOIN users u ON c.user_id = u.id
WHERE c.created_at >= DATE_SUB(NOW(), INTERVAL 10 SECOND)
GROUP BY c.user_id
ORDER BY last_message_time DESC
LIMIT 5"
);
foreach ($recentConversations as $conv) {
sendSSEEvent('new_conversation', [
'user_id' => $conv['user_id'],
'phone_number' => $conv['phone_number'],
'name' => $conv['name'],
'message_count' => $conv['message_count'],
'timestamp' => $conv['last_message_time']
]);
}
// Verificar notificaciones no leídas
try {
$notifications = $db->fetchAll(
"SELECT id, user_id, type, message, data, is_read, created_at
FROM notifications
WHERE is_read = 0
ORDER BY created_at DESC
LIMIT 10"
);
if ($notifications && count($notifications) > 0) {
foreach ($notifications as $notification) {
sendSSEEvent('notification', $notification);
}
}
} catch (Exception $e) {
error_log('SSE: Error leyendo notificaciones: ' . $e->getMessage());
}
$lastCheck = time();
}
// Enviar heartbeat cada 30 segundos para mantener conexión viva
if (time() % 30 === 0) {
sendSSEEvent('heartbeat', ['timestamp' => time()]);
}
// Esperar 2 segundos antes de la siguiente verificación
sleep(2);
}
} catch (Exception $e) {
error_log("SSE Error: " . $e->getMessage());
error_log("SSE Error trace: " . $e->getTraceAsString());
sendSSEEvent('error', [
'message' => $e->getMessage(),
'code' => $e->getCode()
]);
}
+22
View File
@@ -0,0 +1,22 @@
<?php
// Test simple de SSE
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
if (ob_get_level()) ob_end_clean();
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', 'off');
echo "event: connected\n";
echo "data: " . json_encode(['test' => true, 'timestamp' => time()]) . "\n\n";
if (ob_get_level()) ob_flush();
flush();
echo ": heartbeat\n\n";
flush();
// Log para debug
error_log('SSE Test: enviado evento connected');
+48
View File
@@ -305,6 +305,16 @@ class WhatsAppWebhook {
error_log('Failed to create notification: ' . $e->getMessage());
}
// Empujar evento SSE en tiempo real
$this->pushSSEEvent('new_message', [
'user_id' => $user['id'],
'phone_number' => $user['phone_number'],
'name' => $user['name'] ?? $from,
'message' => substr($messageText, 0, 250),
'message_type' => $messageType,
'timestamp' => date('Y-m-d H:i:s')
]);
// Procesar con bot (protección contra excepciones externas)
try {
$this->botService->processMessage($user, $messageText, $messageType);
@@ -398,6 +408,44 @@ class WhatsAppWebhook {
}
}
/**
* 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.