Files
Lizandro GuarnizoandClaude Sonnet 4.6 8825f4e821 fix: el selector de empresas se comia los comandos de navegacion
isResetKeyword aceptaba menu, salir, atras, volver, inicio y regresar, o sea el
vocabulario completo de navegacion del bot. Ese chequeo corre en el webhook,
antes de NormalBot, asi que a un usuario que pertenece a varias empresas
cualquiera de esas palabras le borraba la sesion y lo devolvia al selector en
vez de navegar.

Ahora solo responde a palabras que signifiquen cambiar de empresa.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-01 11:01:30 -05:00

735 lines
32 KiB
PHP

<?php
declare(strict_types=1);
/**
* Controlador del Webhook de WhatsApp Business API (Meta Cloud API v18+)
*
* GET /admin/v1/wp-webhook → Verificación del webhook por Meta
* POST /admin/v1/wp-webhook → Recepción de eventos (mensajes, estados, etc.)
*/
class WpWebhook
{
/** Payload crudo del webhook actual, compartido entre métodos. */
private static string $currentRaw = '';
/** Empresa identificada para el webhook actual. */
private static ?array $currentCompany = null;
// ─── GET: Verificación Meta ───────────────────────────────────────────────
/**
* Meta envía:
* ?hub.mode=subscribe
* &hub.verify_token=TU_TOKEN
* &hub.challenge=CADENA_ALEATORIA
*
* Responde con hub.challenge si el token coincide.
*/
public static function verify(): void
{
self::debugLog('GET', $_SERVER['QUERY_STRING'] ?? '');
$mode = $_GET['hub_mode'] ?? $_GET['hub.mode'] ?? '';
$verifyToken = $_GET['hub_verify_token'] ?? $_GET['hub.verify_token'] ?? '';
$challenge = $_GET['hub_challenge'] ?? $_GET['hub.challenge'] ?? '';
$expectedToken = env('WHATSAPP_VERIFY_TOKEN', '');
if ($mode === 'subscribe' && hash_equals($expectedToken, $verifyToken)) {
http_response_code(200);
header('Content-Type: text/plain');
echo $challenge;
self::log('INFO', 'Webhook verificado por Meta.');
exit;
}
self::log('WARN', "Verificación fallida. mode=$mode token=$verifyToken");
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Verificación fallida'], JSON_UNESCAPED_UNICODE);
exit;
}
// ─── POST: Recepción de eventos ───────────────────────────────────────────
public static function receive(): void
{
$rawBody = file_get_contents('php://input');
$headers = getallheaders();
self::debugLog('POST', json_encode(['headers' => $headers, 'body' => $rawBody], JSON_UNESCAPED_UNICODE));
// Guardar raw body para los handlers
self::$currentRaw = $rawBody ?: '';
// Guardar SIEMPRE una entrada raw en webhook_logs antes de cualquier validación
self::saveRawIncoming($rawBody ?: '', $headers);
if ($rawBody === false || $rawBody === '') {
self::respond(400, ['error' => 'Body vacío']);
}
self::verifySignature($rawBody);
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
self::respond(400, ['error' => 'JSON inválido']);
}
self::processEvent($payload, $rawBody);
self::respond(200, ['status' => 'received']);
}
private static function saveRawIncoming(string $rawBody, array $headers): void
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$preview = mb_substr($rawBody, 0, 300);
$sigOk = 'pendiente';
$appSecret = env('WHATSAPP_APP_SECRET', '');
$hmacEnabled = env('verify_hmac_signature', '1');
if ($hmacEnabled !== '0' && $appSecret !== '') {
$sigHeader = $headers['X-Hub-Signature-256'] ?? $headers['x-hub-signature-256'] ?? '';
if (strpos($sigHeader, 'sha256=') === 0) {
$received = substr($sigHeader, 7);
$expected = hash_hmac('sha256', $rawBody, $appSecret);
$sigOk = hash_equals($expected, $received) ? 'ok' : 'invalida';
} else {
$sigOk = 'sin-firma';
}
} else {
$sigOk = 'desactivada';
}
try {
$stmt = db()->prepare("
INSERT INTO webhook_logs
(company_id, event_field, from_number, contact_name, message_type, message_preview, raw_payload)
VALUES (NULL, 'raw', ?, ?, 'raw', ?, ?)
");
$stmt->execute([$ip, "HMAC:{$sigOk}", mb_substr($preview, 0, 500), $rawBody]);
} catch (\PDOException $e) {
self::log('ERROR', 'saveRawIncoming DB: ' . $e->getMessage());
}
}
// ─── Procesamiento de eventos ─────────────────────────────────────────────
private static function processEvent(array $payload, string $raw): void
{
$object = $payload['object'] ?? '';
if ($object !== 'whatsapp_business_account') {
self::log('WARN', "Objeto desconocido: $object");
return;
}
self::resolveCompany($payload);
$entries = $payload['entry'] ?? [];
foreach ($entries as $entry) {
$changes = $entry['changes'] ?? [];
foreach ($changes as $change) {
$field = $change['field'] ?? '';
$value = $change['value'] ?? [];
if ($field === 'messages' || $field === 'conversations') {
self::handleMessages($value);
} elseif ($field === 'statuses') {
self::handleStatuses($value);
} else {
self::log('INFO', "Campo no manejado: $field");
}
}
}
// Persistir evento crudo para auditoría
self::saveRawEvent($raw);
// Note: BotRouter already sends synchronously in enqueueResponse().
// Running processQueue() here would re-send ALL previously-failed messages
// on every incoming webhook, causing duplicate/repeated responses.
}
private static function resolveCompany(array $payload): void
{
// Fallback: resolver por phone_number_id (usado para statuses y auditoría)
$phoneNumberId = '';
foreach ($payload['entry'] ?? [] as $entry) {
foreach ($entry['changes'] ?? [] as $change) {
$metadata = $change['value']['metadata'] ?? [];
if (!empty($metadata['phone_number_id'])) {
$phoneNumberId = $metadata['phone_number_id'];
break 2;
}
}
}
if ($phoneNumberId !== '') {
self::$currentCompany = CompanyRepository::findByPhoneNumberId($phoneNumberId);
}
}
// Devuelve todas las empresas a las que pertenece el número
private static function findCompaniesByNumber(string $from): array
{
if ($from === '') return [];
try {
$stmt = db()->prepare("
SELECT cp.company_id, cp.permission_type
FROM company_phones cp
WHERE cp.wa_number = ? AND cp.is_active = 1
ORDER BY cp.company_id ASC
");
$stmt->execute([$from]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
self::log('ERROR', 'findCompaniesByNumber DB: ' . $e->getMessage());
return [];
}
}
// Pre-menú multi-empresa: retorna true si la empresa quedó resuelta, false si hay que esperar al usuario
private static function handleMultiCompany(string $from, string $name, array $companies, string $rawText, string $phoneNumberId, string $msgType = 'text'): bool
{
// 1. El usuario tocó un botón de selección de empresa
if (str_starts_with($rawText, '__co_')) {
$selectedId = (int)substr($rawText, 5);
$company = CompanyRepository::findById($selectedId);
if (!$company) {
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false;
}
try {
db()->prepare("
INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at)
VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 8 HOUR))
ON DUPLICATE KEY UPDATE selected_id = VALUES(selected_id), expires_at = VALUES(expires_at)
")->execute([$from, json_encode($companies), $selectedId]);
} catch (\PDOException $e) {
self::log('ERROR', 'handleMultiCompany save session: ' . $e->getMessage());
}
$company['_permission_type'] = self::getPermissionType($companies, $selectedId);
self::$currentCompany = $company;
$displayName = $company['display_name'] ?: $company['name'];
// Mensaje 1: confirmación
WhatsAppSender::sendText($from, "✅ Conectado con *{$displayName}*.", $phoneNumberId);
// Mensaje 2: greeting del bot — reset contexto y disparar con mensaje vacío
$botCtx = ConversationContext::getOrCreate((int)$selectedId, $from, 'normal');
ConversationContext::reset((int)$botCtx['id']);
$synthCtx = [
'from' => $from,
'name' => $name,
'message_id' => 'mc_' . uniqid(),
'type' => 'text',
'timestamp' => time(),
'phone_number_id' => $phoneNumberId,
'display_phone' => '',
'permission_type' => self::getPermissionType($companies, $selectedId),
];
BotRouter::route($company, $synthCtx, '', 'text');
self::log('INFO', "Multi-empresa: {$from} seleccionó empresa #{$selectedId} ({$displayName})");
return false;
}
// 2. Keyword de reinicio → borrar sesión y mostrar pre-menú
// Solo aplica para texto libre; las respuestas interactivas no son keywords
if ($msgType === 'text' && self::isResetKeyword($rawText)) {
try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false;
}
// 3. Sesión activa → usar empresa guardada
try {
$stmt = db()->prepare("SELECT selected_id FROM multi_company_sessions WHERE wa_number = ? AND selected_id IS NOT NULL AND expires_at > NOW() LIMIT 1");
$stmt->execute([$from]);
$session = $stmt->fetch(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
self::log('ERROR', 'handleMultiCompany read session: ' . $e->getMessage());
$session = null;
}
if ($session) {
$selectedId = (int)$session['selected_id'];
try { db()->prepare("UPDATE multi_company_sessions SET expires_at = DATE_ADD(NOW(), INTERVAL 8 HOUR) WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
$company = CompanyRepository::findById($selectedId);
if (!$company) {
try { db()->prepare("DELETE FROM multi_company_sessions WHERE wa_number = ?")->execute([$from]); } catch (\PDOException $e) {}
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false;
}
$company['_permission_type'] = self::getPermissionType($companies, $selectedId);
self::$currentCompany = $company;
return true;
}
// 4. Sin sesión → mostrar pre-menú
try {
db()->prepare("
INSERT INTO multi_company_sessions (wa_number, companies_json, selected_id, expires_at)
VALUES (?, ?, NULL, DATE_ADD(NOW(), INTERVAL 10 MINUTE))
ON DUPLICATE KEY UPDATE companies_json = VALUES(companies_json), selected_id = NULL, expires_at = VALUES(expires_at)
")->execute([$from, json_encode($companies)]);
} catch (\PDOException $e) {
self::log('ERROR', 'handleMultiCompany insert pending: ' . $e->getMessage());
}
self::sendCompanyPreMenu($from, $phoneNumberId, $name, $companies);
return false;
}
private static function sendCompanyPreMenu(string $to, string $phoneNumberId, string $name, array $companies): void
{
$items = [];
foreach ($companies as $cp) {
$co = CompanyRepository::findById((int)$cp['company_id']);
if ($co) $items[] = ['id' => (int)$cp['company_id'], 'name' => $co['display_name'] ?: $co['name']];
}
if (empty($items)) return;
$greeting = $name ? "Hola *{$name}* 👋\n" : "Hola 👋\n";
$body = $greeting . "¿Con cuál empresa deseas comunicarte?";
if (count($items) <= 3) {
$buttons = [];
foreach ($items as $item) {
$buttons[] = ['type' => 'reply', 'reply' => ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 20)]];
}
$interactive = ['type' => 'button', 'body' => ['text' => $body], 'action' => ['buttons' => $buttons]];
} else {
$rows = [];
foreach ($items as $item) {
$rows[] = ['id' => '__co_' . $item['id'], 'title' => mb_substr($item['name'], 0, 24)];
}
$interactive = ['type' => 'list', 'body' => ['text' => $body], 'action' => ['button' => 'Ver empresas', 'sections' => [['title' => 'Empresas', 'rows' => $rows]]]];
}
WhatsAppSender::sendInteractive($to, $interactive, $phoneNumberId);
self::log('INFO', "Pre-menú de empresa enviado a {$to} (" . count($items) . " opciones)");
}
/**
* Solo palabras que signifiquen "cambiar de empresa". Antes incluía menu,
* salir, atras, volver, inicio y regresar — el vocabulario de navegación del
* bot— así que a un usuario multi-empresa se le comían todos esos comandos
* acá arriba y nunca llegaban a NormalBot.
*/
private static function isResetKeyword(string $text): bool
{
$n = mb_strtolower(trim($text));
$n = str_replace(['á','é','í','ó','ú','ü','ñ'], ['a','e','i','o','u','u','n'], $n);
return in_array($n, ['cambiar empresa', 'cambiar de empresa', 'empresas', 'reiniciar', 'reset'], true);
}
private static function getPermissionType(array $companies, int $companyId): int
{
foreach ($companies as $cp) {
if ((int)$cp['company_id'] === $companyId) return (int)$cp['permission_type'];
}
return 3;
}
private static function sendRejectionMessage(string $to, string $phoneNumberId): void
{
$token = env('WHATSAPP_ACCESS_TOKEN', '');
if ($token === '' || $to === '' || $phoneNumberId === '') return;
$body = json_encode([
'messaging_product' => 'whatsapp',
'to' => $to,
'type' => 'text',
'text' => ['body' => 'Comuníquese con el administrador, su número no se encuentra habilitado.'],
]);
$ch = curl_init("https://graph.facebook.com/v17.0/{$phoneNumberId}/messages");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $token],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
self::log('INFO', "Rechazo enviado a {$to} HTTP {$code}");
}
// ─── Reenvío a empresa ────────────────────────────────────────────────────
private static function forwardToCompany(array $context, string $content, ?string $mediaId = null): void
{
if (self::$currentCompany === null) {
return;
}
$messageData = array_merge($context, [
'content' => $content,
'media_id' => $mediaId,
'raw_payload' => self::$currentRaw,
]);
$result = CompanyApiClient::forwardMessage(self::$currentCompany, $messageData);
if ($result['success']) {
self::log('INFO', "Mensaje reenviado a empresa: " . (self::$currentCompany['name'] ?? '?'));
} else {
self::log('ERROR', "Fallo reenvío a empresa: " . ($result['error'] ?? json_encode($result)));
}
}
// ─── Mensajes entrantes ───────────────────────────────────────────────────
private static function handleMessages(array $value): void
{
$messages = $value['messages'] ?? [];
$contacts = $value['contacts'] ?? [];
$metadata = $value['metadata'] ?? [];
$phoneNumberId = $metadata['phone_number_id'] ?? '';
$displayPhone = $metadata['display_phone_number'] ?? '';
foreach ($messages as $msg) {
$from = $msg['from'] ?? '';
$msgId = $msg['id'] ?? '';
$type = $msg['type'] ?? 'unknown';
$ts = $msg['timestamp'] ?? time();
$name = '';
foreach ($contacts as $c) {
if (($c['wa_id'] ?? '') === $from) {
$name = $c['profile']['name'] ?? '';
break;
}
}
// Resolver empresa(s) por número remitente
$matchedCompanies = self::findCompaniesByNumber($from);
if (empty($matchedCompanies)) {
self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]');
self::sendRejectionMessage($from, $phoneNumberId);
continue;
}
if (count($matchedCompanies) === 1) {
$co = CompanyRepository::findById((int)$matchedCompanies[0]['company_id']);
if (!$co) continue;
$co['_permission_type'] = (int)$matchedCompanies[0]['permission_type'];
self::$currentCompany = $co;
self::log('INFO', "Número {$from} → empresa: {$co['name']} (permiso {$matchedCompanies[0]['permission_type']})");
} else {
$rawText = match ($type) {
'text' => $msg['text']['body'] ?? '',
'interactive' => $msg['interactive']['button_reply']['id'] ?? $msg['interactive']['list_reply']['id'] ?? '',
'button' => $msg['button']['payload'] ?? $msg['button']['text'] ?? '',
default => '',
};
if (!self::handleMultiCompany($from, $name, $matchedCompanies, $rawText, $phoneNumberId, $type)) {
continue;
}
self::log('INFO', "Número {$from} → empresa (multi-sel): " . (self::$currentCompany['name'] ?? '?'));
}
$context = [
'from' => $from,
'name' => $name,
'message_id' => $msgId,
'type' => $type,
'timestamp' => $ts,
'phone_number_id' => $phoneNumberId,
'display_phone' => $displayPhone,
'permission_type' => self::$currentCompany['_permission_type'] ?? 3,
];
switch ($type) {
case 'text': self::handleText($msg, $context); break;
case 'image': self::handleMedia($msg, $context, 'image'); break;
case 'audio': self::handleMedia($msg, $context, 'audio'); break;
case 'video': self::handleMedia($msg, $context, 'video'); break;
case 'document': self::handleMedia($msg, $context, 'document'); break;
case 'sticker': self::handleMedia($msg, $context, 'sticker'); break;
case 'location': self::handleLocation($msg, $context); break;
case 'interactive': self::handleInteractive($msg, $context); break;
case 'button': self::handleButton($msg, $context); break;
case 'reaction': self::handleReaction($msg, $context); break;
default: self::log('INFO', "Tipo no manejado: $type | from=$from"); break;
}
}
if (!empty($value['statuses'])) {
self::handleStatuses($value);
}
}
private static function handleText(array $msg, array $ctx): void
{
$body = $msg['text']['body'] ?? '';
self::log('MSG', "[TEXT] {$ctx['from']} ({$ctx['name']}): $body");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'text', $body);
$isNew = self::saveConversation($ctx, $body);
self::forwardToCompany($ctx, $body);
if ($isNew && self::$currentCompany !== null) {
BotRouter::route(self::$currentCompany, $ctx, $body, 'text');
}
}
private static function handleMedia(array $msg, array $ctx, string $type): void
{
$data = $msg[$type] ?? [];
$mediaId = $data['id'] ?? '';
$mime = $data['mime_type'] ?? '';
$caption = $data['caption'] ?? '';
$preview = $caption ?: "[$type id:$mediaId]";
self::log('MSG', "[" . strtoupper($type) . "] {$ctx['from']} | id=$mediaId mime=$mime caption=$caption");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], $type, $preview);
$isNew = self::saveConversation($ctx, $preview, $mediaId);
self::forwardToCompany($ctx, $preview, $mediaId);
if ($isNew && self::$currentCompany !== null) {
// media_id in context so BotRouter/MediaTranscriber can download the file
$ctx['media_id'] = $mediaId;
BotRouter::route(self::$currentCompany, $ctx, $caption, $type);
}
}
private static function handleLocation(array $msg, array $ctx): void
{
$lat = $msg['location']['latitude'] ?? '';
$lng = $msg['location']['longitude'] ?? '';
$name = $msg['location']['name'] ?? '';
$preview = "lat:$lat lng:$lng" . ($name ? " ($name)" : '');
self::log('MSG', "[LOCATION] {$ctx['from']} | lat=$lat lng=$lng name=$name");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'location', $preview);
self::saveConversation($ctx, $preview);
self::forwardToCompany($ctx, $preview);
}
private static function handleInteractive(array $msg, array $ctx): void
{
$iType = $msg['interactive']['type'] ?? '';
if ($iType === 'button_reply') {
$reply = $msg['interactive']['button_reply'] ?? [];
} elseif ($iType === 'list_reply') {
$reply = $msg['interactive']['list_reply'] ?? [];
} else {
$reply = [];
}
$preview = $iType . ': ' . json_encode($reply);
$replyId = $reply['id'] ?? '';
self::log('MSG', "[INTERACTIVE/$iType] {$ctx['from']} | reply=" . json_encode($reply));
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'interactive', $preview);
$isNew = self::saveConversation($ctx, $preview);
self::forwardToCompany($ctx, $preview);
if ($isNew && self::$currentCompany !== null && $replyId !== '') {
BotRouter::route(self::$currentCompany, $ctx, $replyId, 'interactive');
}
}
private static function handleButton(array $msg, array $ctx): void
{
$text = $msg['button']['text'] ?? '';
$payload = $msg['button']['payload'] ?? '';
$preview = "$text | $payload";
$replyId = $payload ?: $text;
self::log('MSG', "[BUTTON] {$ctx['from']} | text=$text payload=$payload");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'button', $preview);
$isNew = self::saveConversation($ctx, $preview);
self::forwardToCompany($ctx, $preview);
if ($isNew && self::$currentCompany !== null && $replyId !== '') {
BotRouter::route(self::$currentCompany, $ctx, $replyId, 'button');
}
}
private static function handleReaction(array $msg, array $ctx): void
{
$emoji = $msg['reaction']['emoji'] ?? '';
$reactTo = $msg['reaction']['message_id'] ?? '';
$preview = "emoji:$emoji replyTo:$reactTo";
self::log('MSG', "[REACTION] {$ctx['from']} ({$ctx['name']}) | emoji=$emoji msg=$reactTo");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'reaction', $preview);
self::saveConversation($ctx, $preview);
self::forwardToCompany($ctx, $preview);
}
// ─── Estados de mensajes enviados ────────────────────────────────────────
private static function handleStatuses(array $value): void
{
$statuses = $value['statuses'] ?? [];
foreach ($statuses as $s) {
$id = $s['id'] ?? '';
$status = $s['status'] ?? ''; // sent | delivered | read | failed
$recipient = $s['recipient_id'] ?? '';
$ts = $s['timestamp'] ?? '';
if ($status === 'failed') {
$errors = $s['errors'] ?? [];
self::log('ERROR', "[STATUS:failed] msg=$id recipient=$recipient errors=" . json_encode($errors));
} else {
self::log('INFO', "[STATUS:$status] msg=$id recipient=$recipient ts=$ts");
}
self::saveWebhookLog('statuses', $recipient, '', $status, "msg_id:$id");
if (self::$currentCompany !== null) {
$statusData = [
'message_id' => $id,
'status' => $status,
'recipient' => $recipient,
'timestamp' => $ts,
'errors' => $s['errors'] ?? null,
];
CompanyApiClient::forwardStatus(self::$currentCompany, $statusData);
}
}
}
// ─── Firma HMAC-SHA256 de Meta ────────────────────────────────────────────
private static function verifySignature(string $rawBody): void
{
$appSecret = env('WHATSAPP_APP_SECRET', '');
// Si el checkbox "Validar firma HMAC" está desactivado (0), saltar validación
$enabled = env('verify_hmac_signature', '1');
if ($enabled === '0') {
self::log('INFO', 'Validación HMAC desactivada por configuración.');
return;
}
if ($appSecret === '') {
self::log('WARN', 'WHATSAPP_APP_SECRET no configurado. Saltando verificación de firma.');
return;
}
$sigHeader = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
if (strpos($sigHeader, 'sha256=') !== 0) {
self::respond(401, ['error' => 'Firma ausente']);
}
$received = substr($sigHeader, 7);
$expected = hash_hmac('sha256', $rawBody, $appSecret);
if (!hash_equals($expected, $received)) {
self::log('WARN', 'Firma HMAC inválida. Posible payload adulterado.');
self::respond(401, ['error' => 'Firma inválida']);
}
}
// ─── Utilidades ───────────────────────────────────────────────────────────
// ─── Guardar en base de datos ─────────────────────────────────────────────
private static function saveConversation(array $ctx, string $content, ?string $mediaId = null): bool
{
try {
$companyId = self::$currentCompany['id'] ?? null;
$stmt = db()->prepare("
INSERT IGNORE INTO conversations
(company_id, message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp)
VALUES (?, ?, ?, ?, 'inbound', ?, ?, ?, ?)
");
$stmt->execute([
$companyId,
$ctx['message_id'],
$ctx['from'],
$ctx['name'],
$ctx['type'],
mb_substr($content, 0, 1000),
$mediaId,
$ctx['timestamp'],
]);
if ($stmt->rowCount() > 0) {
$convId = (int) db()->lastInsertId();
self::saveNotification($convId, $ctx['from'], $content);
return true;
}
// Mensaje duplicado (webhook retry de Meta) — no procesar de nuevo
self::log('INFO', "Mensaje duplicado ignorado: {$ctx['message_id']} from={$ctx['from']}");
return false;
} catch (\PDOException $e) {
self::log('ERROR', 'DB saveConversation: ' . $e->getMessage());
return true; // En caso de error DB, dejar pasar para no silenciar mensajes reales
}
}
private static function saveNotification(int $referenceId, string $phone, string $preview): void
{
try {
$companyId = self::$currentCompany['id'] ?? null;
$stmt = db()->prepare("
INSERT INTO notifications (company_id, type, reference_id, phone_number, message)
VALUES (?, 'new_message', ?, ?, ?)
");
$stmt->execute([$companyId, $referenceId, $phone, mb_substr($preview, 0, 255)]);
} catch (\PDOException $e) {
self::log('ERROR', 'DB saveNotification: ' . $e->getMessage());
}
}
private static function saveWebhookLog(
string $field,
string $from,
string $name,
string $type,
string $preview
): void {
try {
$companyId = self::$currentCompany['id'] ?? null;
$stmt = db()->prepare("
INSERT INTO webhook_logs
(company_id, event_field, from_number, contact_name, message_type, message_preview, raw_payload)
VALUES (?, ?, ?, ?, ?, ?, ?)
");
$stmt->execute([$companyId, $field, $from, $name, $type, mb_substr($preview, 0, 500), self::$currentRaw]);
} catch (\PDOException $e) {
self::log('ERROR', 'DB saveWebhookLog: ' . $e->getMessage());
}
}
/**
* Guarda el evento crudo en storage/events/ para auditoría.
*/
private static function saveRawEvent(string $raw): void
{
$dir = dirname(__DIR__, 2) . '/storage/events';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$file = $dir . '/' . date('Y-m-d') . '.log';
$line = '[' . date('Y-m-d H:i:s') . '] ' . $raw . PHP_EOL;
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
private static function log(string $level, string $message): void
{
$dir = dirname(__DIR__, 2) . '/storage/logs';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$file = $dir . '/webhook-' . date('Y-m-d') . '.log';
$line = '[' . date('Y-m-d H:i:s') . "] [$level] $message" . PHP_EOL;
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
// También a stderr en desarrollo
if (env('APP_ENV', 'production') === 'local') {
error_log($line);
}
}
private static function debugLog(string $method, string $data): void
{
$log = '[' . date('Y-m-d H:i:s') . "] {$method}\n{$data}\n" . str_repeat('-', 60) . "\n";
@file_put_contents('/tmp/wp-debug.log', $log, FILE_APPEND);
}
private static function respond(int $code, array $body): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($body, JSON_UNESCAPED_UNICODE);
exit;
}
}