456 lines
16 KiB
PHP
456 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Webhook OPTIMIZADO para recibir mensajes de WhatsApp
|
|
*
|
|
* CAMBIOS CLAVE:
|
|
* - Responde inmediatamente (< 1 segundo) con 200 OK
|
|
* - Encola mensajes para procesamiento asíncrono
|
|
* - Usa Redis para evitar duplicados
|
|
* - Logging estructurado con Monolog
|
|
*
|
|
* Fecha: Enero 2026
|
|
*/
|
|
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
require_once __DIR__ . '/../config/config.php';
|
|
|
|
use WhatsApp\Queue\RedisQueue;
|
|
use Monolog\Logger;
|
|
use Monolog\Handler\StreamHandler;
|
|
use Monolog\Handler\RotatingFileHandler;
|
|
|
|
// Headers
|
|
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');
|
|
|
|
// Configurar logger
|
|
$logger = new Logger('webhook');
|
|
$logger->pushHandler(new RotatingFileHandler(__DIR__ . '/../logs/webhook.log', 7, Logger::INFO));
|
|
|
|
// Inicializar cola
|
|
$queue = new RedisQueue(null, $logger);
|
|
|
|
class OptimizedWebhook {
|
|
private $db;
|
|
private $logger;
|
|
private $queue;
|
|
private $redis;
|
|
|
|
public function __construct($db, $logger, $queue) {
|
|
$this->db = $db;
|
|
$this->logger = $logger;
|
|
$this->queue = $queue;
|
|
|
|
// Inicializar Redis directamente para verificaciones rápidas
|
|
$this->redis = new Predis\Client([
|
|
'scheme' => getenv('REDIS_SCHEME') ?: 'tcp',
|
|
'host' => getenv('REDIS_HOST') ?: '127.0.0.1',
|
|
'port' => getenv('REDIS_PORT') ?: 6379,
|
|
]);
|
|
}
|
|
|
|
public function handleRequest() {
|
|
$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) {
|
|
$this->logger->info("Webhook verified successfully");
|
|
echo $challenge;
|
|
exit;
|
|
}
|
|
|
|
$this->logger->warning("Invalid verification attempt", [
|
|
'mode' => $mode,
|
|
'token_match' => $verifyToken === WEBHOOK_VERIFY_TOKEN
|
|
]);
|
|
|
|
http_response_code(403);
|
|
echo json_encode(['error' => 'Token de verificación inválido']);
|
|
}
|
|
|
|
private function processIncomingMessage() {
|
|
$startTime = microtime(true);
|
|
|
|
// Leer payload
|
|
$input = file_get_contents('php://input');
|
|
$data = json_decode($input, true);
|
|
|
|
// Responder inmediatamente con 200 OK (CRÍTICO: < 5 segundos)
|
|
http_response_code(200);
|
|
echo json_encode(['status' => 'received']);
|
|
|
|
// Forzar envío de respuesta al cliente
|
|
if (function_exists('fastcgi_finish_request')) {
|
|
fastcgi_finish_request();
|
|
} else {
|
|
// Fallback para otros SAPIs
|
|
ob_end_flush();
|
|
flush();
|
|
}
|
|
|
|
// Ahora procesamos el webhook sin presión de tiempo
|
|
$processingStart = microtime(true);
|
|
|
|
try {
|
|
// Log webhook (async, no bloquea)
|
|
$this->logWebhookAsync($input);
|
|
|
|
if (!$data || !isset($data['entry'])) {
|
|
$this->logger->warning("Invalid webhook payload received");
|
|
return;
|
|
}
|
|
|
|
$this->logger->info("Webhook received", [
|
|
'entries' => count($data['entry']),
|
|
'response_time_ms' => round((microtime(true) - $startTime) * 1000, 2)
|
|
]);
|
|
|
|
// Procesar cada entrada
|
|
foreach ($data['entry'] as $entry) {
|
|
if (!isset($entry['changes'])) continue;
|
|
|
|
foreach ($entry['changes'] as $change) {
|
|
if (isset($change['value']['messages'])) {
|
|
$contacts = $change['value']['contacts'] ?? [];
|
|
$this->queueMessages($change['value']['messages'], $contacts);
|
|
}
|
|
|
|
if (isset($change['value']['statuses'])) {
|
|
$this->updateMessageStatuses($change['value']['statuses']);
|
|
}
|
|
}
|
|
}
|
|
|
|
$totalTime = round((microtime(true) - $startTime) * 1000, 2);
|
|
$processingTime = round((microtime(true) - $processingStart) * 1000, 2);
|
|
|
|
$this->logger->info("Webhook processed", [
|
|
'total_time_ms' => $totalTime,
|
|
'processing_time_ms' => $processingTime,
|
|
'response_sent_in_ms' => round(($processingStart - $startTime) * 1000, 2)
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->logger->error("Error processing webhook", [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString()
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Encolar mensajes para procesamiento asíncrono
|
|
*/
|
|
private function queueMessages($messages, $contacts = []) {
|
|
// Extraer nombres de contactos del payload
|
|
$contactNames = [];
|
|
foreach ($contacts as $contact) {
|
|
$waId = $contact['wa_id'] ?? null;
|
|
$name = $contact['profile']['name'] ?? null;
|
|
if ($waId && $name) {
|
|
$contactNames[$waId] = $name;
|
|
}
|
|
}
|
|
|
|
foreach ($messages as $message) {
|
|
try {
|
|
$messageId = $message['id'] ?? null;
|
|
$phoneNumber = $message['from'] ?? null;
|
|
|
|
if (!$messageId || !$phoneNumber) {
|
|
continue;
|
|
}
|
|
|
|
// Verificación rápida de duplicados en Redis (más rápido que DB)
|
|
$duplicateKey = "whatsapp:processed:" . $messageId;
|
|
if ($this->redis->exists($duplicateKey)) {
|
|
$this->logger->debug("Duplicate message skipped", ['message_id' => $messageId]);
|
|
continue;
|
|
}
|
|
|
|
// Marcar como procesado en Redis (expira en 24h)
|
|
$this->redis->setex($duplicateKey, 86400, time());
|
|
|
|
// Obtener o crear usuario (rápido, solo DB lookup)
|
|
$user = $this->getUserByPhone($phoneNumber);
|
|
$contactName = $contactNames[$phoneNumber] ?? null;
|
|
if (!$user) {
|
|
$userId = $this->createUser($phoneNumber);
|
|
$user = $this->getUserById($userId);
|
|
// Guardar nombre del contacto de WhatsApp
|
|
if ($contactName && $userId) {
|
|
try {
|
|
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ? OR name = ?)', [$userId, '', $phoneNumber]);
|
|
$user['name'] = $contactName;
|
|
} catch (Exception $e) {
|
|
$this->logger->warning('Error guardando nombre contacto: ' . $e->getMessage());
|
|
}
|
|
}
|
|
} else {
|
|
// Actualizar nombre si no tiene uno real (solo tiene el teléfono o vacío)
|
|
if ($contactName && (empty($user['name']) || $user['name'] === $phoneNumber)) {
|
|
try {
|
|
$this->db->update('users', ['name' => $contactName], 'id = ? AND (name IS NULL OR name = ? OR name = ?)', [$user['id'], '', $phoneNumber]);
|
|
$user['name'] = $contactName;
|
|
} catch (Exception $e) {
|
|
$this->logger->warning('Error actualizando nombre contacto: ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extraer tipo y contenido del mensaje
|
|
$messageData = $this->extractMessageData($message);
|
|
|
|
// Guardar mensaje en BD (no esperar resultado del bot)
|
|
$this->saveIncomingMessage(
|
|
$user['id'],
|
|
$messageId,
|
|
$messageData['text'],
|
|
$messageData['type'],
|
|
$messageData['media_url'],
|
|
$messageData['timestamp']
|
|
);
|
|
|
|
// Encolar para procesamiento por worker
|
|
$this->queue->push('messages', [
|
|
'user' => $user,
|
|
'messageText' => $messageData['text'],
|
|
'messageType' => $messageData['type'],
|
|
'messageId' => $messageId,
|
|
'mediaUrl' => $messageData['media_url']
|
|
], 0); // Prioridad alta
|
|
|
|
$this->logger->info("Message queued", [
|
|
'message_id' => $messageId,
|
|
'user_id' => $user['id'],
|
|
'type' => $messageData['type']
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->logger->error("Failed to queue message", [
|
|
'message_id' => $messageId ?? 'unknown',
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extraer datos del mensaje según tipo
|
|
*/
|
|
private function extractMessageData($message) {
|
|
$type = $message['type'] ?? 'text';
|
|
$text = '';
|
|
$mediaUrl = null;
|
|
$timestamp = $message['timestamp'] ?? null;
|
|
|
|
switch ($type) {
|
|
case 'text':
|
|
$text = $message['text']['body'] ?? '';
|
|
break;
|
|
|
|
case 'interactive':
|
|
// Normalizar respuestas de botones/listas
|
|
if (isset($message['interactive']['list_reply']['title'])) {
|
|
$text = $message['interactive']['list_reply']['title'];
|
|
} elseif (isset($message['interactive']['button_reply']['title'])) {
|
|
$text = $message['interactive']['button_reply']['title'];
|
|
}
|
|
// Extraer número si viene como "1. Opción"
|
|
if (preg_match('/^\s*(\d+)\b/', $text, $m)) {
|
|
$text = $m[1];
|
|
}
|
|
break;
|
|
|
|
case 'image':
|
|
$text = $message['image']['caption'] ?? '';
|
|
// Preferir media ID (estable) sobre URL (expira en minutos)
|
|
$mediaUrl = $message['image']['id'] ?? $message['image']['url'] ?? null;
|
|
|
|
// Encolar descarga de media
|
|
if ($mediaUrl) {
|
|
$this->queueMediaDownload($mediaUrl, $message['id'], 'image');
|
|
}
|
|
break;
|
|
|
|
case 'video':
|
|
$text = $message['video']['caption'] ?? '';
|
|
$mediaUrl = $message['video']['id'] ?? $message['video']['url'] ?? null;
|
|
|
|
if ($mediaUrl) {
|
|
$this->queueMediaDownload($mediaUrl, $message['id'], 'video');
|
|
}
|
|
break;
|
|
|
|
case 'audio':
|
|
$mediaUrl = $message['audio']['id'] ?? $message['audio']['url'] ?? null;
|
|
|
|
if ($mediaUrl) {
|
|
$this->queueMediaDownload($mediaUrl, $message['id'], 'audio');
|
|
}
|
|
break;
|
|
|
|
case 'document':
|
|
$text = $message['document']['filename'] ?? '';
|
|
$mediaUrl = $message['document']['id'] ?? $message['document']['url'] ?? null;
|
|
|
|
if ($mediaUrl) {
|
|
$this->queueMediaDownload($mediaUrl, $message['id'], 'document');
|
|
}
|
|
break;
|
|
|
|
case 'reaction':
|
|
$emoji = $message['reaction']['emoji'] ?? '';
|
|
$reactionTo = $message['reaction']['message_id'] ?? '';
|
|
$text = json_encode(['emoji' => $emoji, 'message_id' => $reactionTo]);
|
|
break;
|
|
}
|
|
|
|
return [
|
|
'text' => $text,
|
|
'type' => $type,
|
|
'media_url' => $mediaUrl,
|
|
'timestamp' => $timestamp
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Encolar descarga de media
|
|
*/
|
|
private function queueMediaDownload($mediaUrl, $messageId, $type) {
|
|
$this->queue->push('media', [
|
|
'media_url' => strpos($mediaUrl, 'http') === 0 ? $mediaUrl : null,
|
|
'media_id' => strpos($mediaUrl, 'http') !== 0 ? $mediaUrl : null,
|
|
'message_id' => $messageId,
|
|
'type' => $type,
|
|
'subdir' => date('Y/m')
|
|
], 1); // Prioridad normal
|
|
}
|
|
|
|
/**
|
|
* Actualizar estados de mensajes (entregado, leído, etc.)
|
|
*/
|
|
private function updateMessageStatuses($statuses) {
|
|
foreach ($statuses as $status) {
|
|
try {
|
|
$messageId = $status['id'];
|
|
$newStatus = $status['status'];
|
|
|
|
$this->db->update(
|
|
'conversations',
|
|
['status' => $newStatus],
|
|
'message_id = :message_id',
|
|
['message_id' => $messageId]
|
|
);
|
|
|
|
} catch (Exception $e) {
|
|
$this->logger->error("Failed to update message status", [
|
|
'message_id' => $messageId ?? 'unknown',
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Guardar mensaje entrante en BD
|
|
*/
|
|
private function saveIncomingMessage($userId, $messageId, $text, $type, $mediaUrl, $timestamp) {
|
|
try {
|
|
$this->db->insert('conversations', [
|
|
'user_id' => $userId,
|
|
'message_id' => $messageId,
|
|
'direction' => 'incoming',
|
|
'content' => $text,
|
|
'message_type' => $type,
|
|
'media_url' => $mediaUrl,
|
|
'status' => 'received',
|
|
'created_at' => $timestamp ? date('Y-m-d H:i:s', $timestamp) : date('Y-m-d H:i:s')
|
|
]);
|
|
|
|
} catch (Exception $e) {
|
|
$this->logger->error("Failed to save message", [
|
|
'user_id' => $userId,
|
|
'message_id' => $messageId,
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log webhook de forma asíncrona (no bloqueante)
|
|
*/
|
|
private function logWebhookAsync($payload) {
|
|
try {
|
|
// Guardar solo últimos 1000 webhooks para no llenar BD
|
|
$this->db->query("DELETE FROM webhook_logs WHERE id < (SELECT id FROM (SELECT id FROM webhook_logs ORDER BY id DESC LIMIT 1 OFFSET 1000) as t)");
|
|
|
|
$this->db->insert('webhook_logs', [
|
|
'request_body' => $payload,
|
|
'response_body' => json_encode(['status' => 'queued']),
|
|
'status_code' => 200,
|
|
'created_at' => date('Y-m-d H:i:s')
|
|
]);
|
|
} catch (Exception $e) {
|
|
$this->logger->warning("Failed to log webhook", ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
private function getUserByPhone($phoneNumber) {
|
|
return $this->db->fetch(
|
|
"SELECT * FROM users WHERE phone_number = :phone",
|
|
['phone' => $phoneNumber]
|
|
);
|
|
}
|
|
|
|
private function createUser($phoneNumber) {
|
|
$this->db->insert('users', [
|
|
'phone_number' => $phoneNumber,
|
|
'name' => $phoneNumber,
|
|
'bot_enabled' => 1,
|
|
'created_at' => date('Y-m-d H:i:s')
|
|
]);
|
|
|
|
return $this->db->lastInsertId();
|
|
}
|
|
|
|
private function getUserById($userId) {
|
|
return $this->db->fetch(
|
|
"SELECT * FROM users WHERE id = :id",
|
|
['id' => $userId]
|
|
);
|
|
}
|
|
}
|
|
|
|
// Ejecutar webhook
|
|
try {
|
|
$db = Database::getInstance();
|
|
$webhook = new OptimizedWebhook($db, $logger, $queue);
|
|
$webhook->handleRequest();
|
|
|
|
} catch (Throwable $e) {
|
|
$logger->critical("Webhook crashed", [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString()
|
|
]);
|
|
|
|
http_response_code(500);
|
|
echo json_encode(['error' => 'Internal server error']);
|
|
}
|