Files
bot_palmas/admin/v1/WpWebhook.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 6f799c3829 feat: media via AI, text via NormalBot only, ai_for_media toggle
- Text/interactive/button messages → NormalBot only, never escalates to AI
- Image/audio/video/document → AiBot.processMedia() if ai_for_media enabled,
  else falls back to category greeting menu
- AiBot.processMedia() uses permission_type context so AI acknowledges uploads
  for perm 3 and redirects others to their menu
- WpWebhook.handleMedia now calls BotRouter so the bot responds to media
- Admin UI: checkbox toggle "Procesar imágenes y archivos con IA" in AI tab
- categoryMenuFallback() extracted in BotRouter as shared helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 13:28:56 -05:00

593 lines
24 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);
// Procesar cola outbound inmediatamente para respuestas automáticas
try {
OutboundWorker::processQueue();
} catch (\Throwable $e) {
self::log('WARN', 'OutboundWorker falló: ' . $e->getMessage());
}
}
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);
}
}
// Resolución principal: busca el número remitente en company_phones
private static function resolveCompanyByNumber(string $from): bool
{
if ($from === '') {
self::$currentCompany = null;
return false;
}
try {
$stmt = db()->prepare("
SELECT cp.company_id, cp.permission_type
FROM company_phones cp
WHERE cp.wa_number = ? AND cp.is_active = 1
LIMIT 1
");
$stmt->execute([$from]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
self::log('ERROR', 'resolveCompanyByNumber DB: ' . $e->getMessage());
self::$currentCompany = null;
return false;
}
if (!$row) {
self::$currentCompany = null;
self::log('WARN', "Número {$from} no registrado en ninguna empresa");
return false;
}
$company = CompanyRepository::findById((int)$row['company_id']);
if ($company === null) {
self::$currentCompany = null;
return false;
}
// Adjuntar tipo de permiso al contexto de empresa
$company['_permission_type'] = (int)$row['permission_type'];
self::$currentCompany = $company;
self::log('INFO', "Número {$from} → empresa: {$company['name']} (permiso tipo {$row['permission_type']})");
return true;
}
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 por número remitente
$allowed = self::resolveCompanyByNumber($from);
if (!$allowed) {
self::saveWebhookLog('messages', $from, $name, $type, '[NÚMERO NO HABILITADO]');
self::sendRejectionMessage($from, $phoneNumberId);
continue;
}
$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);
self::saveConversation($ctx, $body);
self::forwardToCompany($ctx, $body);
if (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);
self::saveConversation($ctx, $preview, $mediaId);
self::forwardToCompany($ctx, $preview, $mediaId);
if (self::$currentCompany !== null) {
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);
self::saveConversation($ctx, $preview);
self::forwardToCompany($ctx, $preview);
if (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);
self::saveConversation($ctx, $preview);
self::forwardToCompany($ctx, $preview);
if (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): void
{
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);
}
} catch (\PDOException $e) {
self::log('ERROR', 'DB saveConversation: ' . $e->getMessage());
}
}
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;
}
}