Meta no permite averiguar el teléfono a partir del BSUID: no hay endpoint de consulta inversa, cada empresa debe llevar su propia equivalencia. Pero el BSUID llega en TODOS los webhooks de mensaje, también en los que aún traen teléfono, así que la equivalencia se puede ir guardando sola mientras la persona todavía muestra su número. - users.bsuid guarda esa equivalencia, y el webhook la anota en cada mensaje. Cuando alguien oculte su número, se le seguirá reconociendo y respondiendo a su teléfono de siempre. - Va en columna aparte y no en phone_number porque el teléfono además cruza con el paciente y el turnero; mezclarlos rompería esos cruces. - Para quien nunca escribió mostrando el número queda pedirle el contacto: pedirContacto() manda el botón request_contact_info, y el webhook atiende el mensaje `contacts` que llega si acepta. - Al vincular puede aparecer un segundo registro de la misma persona. No se fusionan: una fusión mal hecha mezcla dos historias clínicas. Gana el que tiene el teléfono, el otro queda anotado en el log para revisarlo a mano. - scripts/backfill_bsuid.php carga las equivalencias del histórico (6.423), con --simular para verlas sin escribir. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
721 lines
33 KiB
PHP
721 lines
33 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).
|
|
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
|
|
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
|
|
$contactNames = [];
|
|
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
|
foreach ($value['contacts'] as $contact) {
|
|
$name = $contact['profile']['name'] ?? null;
|
|
if (!$name) continue;
|
|
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
|
if ($clave) $contactNames[$clave] = $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;
|
|
|
|
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
|
|
$bsuid = $message['from_user_id'] ?? null;
|
|
|
|
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
|
|
// se busca por BSUID: si ya escribió antes mostrando su número, se le
|
|
// reconoce y se le sigue respondiendo a ese teléfono.
|
|
if (empty($phoneNumber) && $bsuid) {
|
|
$conocido = $this->db->fetch(
|
|
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
|
|
);
|
|
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
|
|
// aunque no permita cruzarlo con el paciente ni con el turnero.
|
|
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
|
|
}
|
|
|
|
// 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] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : 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());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra
|
|
// su teléfono. El día que lo oculte, ese registro es lo único que permitirá
|
|
// reconocerla, así que se anota en cada mensaje y no solo la primera vez.
|
|
if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) {
|
|
try {
|
|
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]);
|
|
$user['bsuid'] = $bsuid;
|
|
} catch (Exception $e) {
|
|
// Choca si ese BSUID ya está en otro usuario (la persona cambió de
|
|
// número). No es motivo para perder el mensaje: se sigue adelante.
|
|
error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $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 (($message['type'] ?? '') === 'contacts') {
|
|
// La persona compartió su contacto, sea por el botón que se le pidió
|
|
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
|
|
$messageType = 'contacts';
|
|
$messageText = json_encode($message['contacts'] ?? []);
|
|
|
|
$telefonoCompartido = null;
|
|
foreach ($message['contacts'] ?? [] as $c) {
|
|
foreach ($c['phones'] ?? [] as $t) {
|
|
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
|
|
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
|
|
if ($candidato) {
|
|
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($telefonoCompartido && !empty($user['id'])) {
|
|
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
|
|
}
|
|
|
|
} 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]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vincula el teléfono que la persona acaba de compartir con el usuario que
|
|
* hasta ahora solo se conocía por su BSUID.
|
|
*
|
|
* Puede haber dos registros de la misma persona: el viejo, de cuando escribía
|
|
* mostrando el número, y el nuevo creado con el BSUID de identificador. No se
|
|
* fusionan aquí (implicaría mover conversaciones, estados y aceptación de
|
|
* términos, y una fusión mal hecha mezcla historias clínicas de dos personas):
|
|
* se deja el registro con el teléfono como el bueno y se marca el otro, para
|
|
* que alguien lo revise.
|
|
*
|
|
* @return bool si el teléfono quedó vinculado
|
|
*/
|
|
private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) {
|
|
// Ya lo teníamos: nada que hacer
|
|
if (($user['phone_number'] ?? null) === $telefono) {
|
|
return true;
|
|
}
|
|
|
|
$existente = $this->db->fetch(
|
|
"SELECT id FROM users WHERE phone_number = :t AND id <> :id",
|
|
['t' => $telefono, 'id' => $user['id']]
|
|
);
|
|
|
|
try {
|
|
if ($existente) {
|
|
// El registro bueno es el que tiene el teléfono. Se le pasa el BSUID
|
|
// para que a partir de ahora se le reconozca por ahí.
|
|
if ($bsuid) {
|
|
$this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]);
|
|
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]);
|
|
}
|
|
error_log(sprintf(
|
|
'[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' .
|
|
'El usuario %d queda duplicado y hay que revisarlo a mano.',
|
|
$bsuid, $telefono, $existente['id'], $user['id']
|
|
));
|
|
$user = $this->getUserById($existente['id']) ?: $user;
|
|
return true;
|
|
}
|
|
|
|
// No había otro registro: el placeholder pasa a tener el teléfono real
|
|
$this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]);
|
|
$user['phone_number'] = $telefono;
|
|
error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono));
|
|
return true;
|
|
|
|
} catch (Exception $e) {
|
|
error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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()]);
|
|
}
|
|
}
|
|
?>
|