Files
whatsapp/api/webhook.php
T
2026-01-21 11:04:41 -05:00

356 lines
14 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) {
// 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;
}
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);
if (!$user) {
$userId = $this->createUser($phoneNumber);
$user = $this->getUserById($userId);
}
// 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['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 URL directo si viene en el webhook (evita resolver media_id)
$mediaUrl = $message['image']['url'] ?? ($message['image']['id'] ?? null);
$mimeType = $message['image']['mime_type'] ?? null;
$filename = $message['image']['filename'] ?? null;
} elseif (isset($message['audio'])) {
$messageType = 'audio';
$mediaUrl = $message['audio']['url'] ?? ($message['audio']['id'] ?? 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']['url'] ?? ($message['video']['id'] ?? 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']['url'] ?? ($message['document']['id'] ?? null);
$mimeType = $message['document']['mime_type'] ?? null;
$filename = $message['document']['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'));
// Guardar mensaje en base de datos
$saveData = [
'user_id' => $user['id'],
'message_id' => $messageId,
'direction' => 'incoming',
'message_type' => $messageType,
'content' => $messageText,
'media_url' => $mediaUrl,
'filename' => isset($filename) ? $filename : null,
'mime_type' => isset($mimeType) ? $mimeType : null,
'status' => 'received',
'is_read' => 0
];
// 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'];
}
// Añadir campos extra (si se detectaron)
if (isset($extraFields) && is_array($extraFields)) {
$saveData = array_merge($saveData, $extraFields);
}
$this->saveMessage($saveData);
// Crear notificación para UI (nuevo mensaje entrante)
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());
}
// Procesar con bot (protección contra excepciones externas)
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) {
return $this->db->insert('conversations', $messageData);
}
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')
]);
}
}
/**
* 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()]);
}
}
?>