'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'] ?? []; match ($field) { 'messages', 'conversations' => self::handleMessages($value), 'statuses' => self::handleStatuses($value), default => 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, ]; match ($type) { 'text' => self::handleText($msg, $context), 'image' => self::handleMedia($msg, $context, 'image'), 'audio' => self::handleMedia($msg, $context, 'audio'), 'video' => self::handleMedia($msg, $context, 'video'), 'document' => self::handleMedia($msg, $context, 'document'), 'sticker' => self::handleMedia($msg, $context, 'sticker'), 'location' => self::handleLocation($msg, $context), 'interactive' => self::handleInteractive($msg, $context), 'button' => self::handleButton($msg, $context), 'reaction' => self::handleReaction($msg, $context), default => self::log('INFO', "Tipo de mensaje no manejado: $type | from=$from"), }; } // 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'] ?? ''; $reply = match ($iType) { 'button_reply' => $msg['interactive']['button_reply'] ?? [], 'list_reply' => $msg['interactive']['list_reply'] ?? [], default => [], }; $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 (!str_starts_with($sigHeader, 'sha256=')) { 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): never { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode($body, JSON_UNESCAPED_UNICODE); exit; } }