469 lines
19 KiB
PHP
469 lines
19 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
|
|
{
|
|
$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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
private static function resolveCompany(array $payload): void
|
|
{
|
|
$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::log('WARN', 'No se pudo identificar phone_number_id en el payload');
|
|
return;
|
|
}
|
|
|
|
self::$currentCompany = CompanyRepository::findByPhoneNumberId($phoneNumberId);
|
|
|
|
if (self::$currentCompany === null) {
|
|
self::log('WARN', "No hay empresa configurada para phone_number_id: {$phoneNumberId}");
|
|
} else {
|
|
self::log('INFO', "Mensaje enrutado a empresa: " . (self::$currentCompany['name'] ?? '?'));
|
|
}
|
|
}
|
|
|
|
// ─── 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'] ?? ''; // 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);
|
|
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);
|
|
}
|
|
|
|
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', '');
|
|
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 {
|
|
$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 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;
|
|
}
|
|
}
|