Files
bot_palmas/admin/v1/WpWebhook.php
T

381 lines
16 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 = '';
// ─── 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
{
$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
{
// 1. Leer body raw
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
self::respond(400, ['error' => 'Body vacío']);
}
// 2. Guardar raw body para los handlers
self::$currentRaw = $rawBody;
// 3. Verificar firma HMAC-SHA256 de Meta
self::verifySignature($rawBody);
// 4. Decodificar JSON
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
self::respond(400, ['error' => 'JSON inválido']);
}
// 5. Procesar ANTES de responder
self::processEvent($payload, $rawBody);
// 6. Responder 200 a Meta (menos de 20 seg)
self::respond(200, ['status' => 'received']);
}
// ─── 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;
}
$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);
}
// ─── 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'] ?? ''; // Número del remitente
$msgId = $msg['id'] ?? '';
$type = $msg['type'] ?? 'unknown';
$ts = $msg['timestamp'] ?? time();
// Nombre del contacto (si existe)
$name = '';
foreach ($contacts as $c) {
if (($c['wa_id'] ?? '') === $from) {
$name = $c['profile']['name'] ?? '';
break;
}
}
$context = [
'from' => $from,
'name' => $name,
'message_id' => $msgId,
'type' => $type,
'timestamp' => $ts,
'phone_number_id' => $phoneNumberId,
'display_phone' => $displayPhone,
];
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 de mensaje no manejado: $type | from=$from"); break;
}
}
// Estados que pueden venir dentro del mismo field 'messages'
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);
// ─── Aquí conectas con tu lógica de bot ──────────────────────────────
// Ejemplo: BotHandler::process($ctx, $body);
}
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);
}
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);
}
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);
self::log('MSG', "[INTERACTIVE/$iType] {$ctx['from']} | reply=" . json_encode($reply));
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'interactive', $preview);
self::saveConversation($ctx, $preview);
}
private static function handleButton(array $msg, array $ctx): void
{
$text = $msg['button']['text'] ?? '';
$payload = $msg['button']['payload'] ?? '';
$preview = "$text | $payload";
self::log('MSG', "[BUTTON] {$ctx['from']} | text=$text payload=$payload");
self::saveWebhookLog('messages', $ctx['from'], $ctx['name'], 'button', $preview);
self::saveConversation($ctx, $preview);
}
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);
}
// ─── 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");
}
}
// ─── Firma HMAC-SHA256 de Meta ────────────────────────────────────────────
private static function verifySignature(string $rawBody): void
{
$appSecret = env('WHATSAPP_APP_SECRET', '');
if ($appSecret === '') {
// En desarrollo puedes omitir esta validación; en producción es obligatoria
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 {
$stmt = db()->prepare("
INSERT IGNORE INTO conversations
(message_id, phone_number, contact_name, direction, message_type, content, media_id, timestamp)
VALUES (?, ?, ?, 'inbound', ?, ?, ?, ?)
");
$stmt->execute([
$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 {
$stmt = db()->prepare("
INSERT INTO notifications (type, reference_id, phone_number, message)
VALUES ('new_message', ?, ?, ?)
");
$stmt->execute([$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 {
$stmt = db()->prepare("
INSERT INTO webhook_logs
(event_field, from_number, contact_name, message_type, message_preview, raw_payload)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([$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 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;
}
}