query('hub_mode'); $token = $request->query('hub_verify_token'); $challenge = $request->query('hub_challenge'); $storedToken = WhatsappSystemConfig::get('webhook_verify_token'); if ($mode === 'subscribe' && $token === $storedToken) { return response((string) $challenge, 200) ->header('Content-Type', 'text/plain'); } Log::warning('[WhatsApp Webhook] Verificación fallida.', [ 'mode' => $mode, 'token' => $token, ]); return response('Forbidden', 403); } // ────────────────────────────────────────────────────────── // POST /webhooks — Meta envía mensajes y actualizaciones // ────────────────────────────────────────────────────────── public function handle(Request $request) { // Solo Meta puede publicar aquí: firma HMAC-SHA256 del cuerpo crudo con el App Secret. // Falla cerrado — sin secreto configurado no se procesa nada. if (! $this->signatureValid($request)) { return response()->json(['status' => 'forbidden'], 403); } $payload = $request->all(); // Loguear el payload entrante antes de procesar WhatsappWebhookLog::create([ 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE), 'response' => null, 'status_code' => 200, ]); // Meta requiere respuesta 200 inmediata; el procesamiento va dentro de try-catch try { $this->processPayload($payload); } catch (\Throwable $e) { Log::error('[WhatsApp Webhook] Error al procesar payload: ' . $e->getMessage(), [ 'trace' => $e->getTraceAsString(), ]); } return response()->json(['status' => 'ok']); } // ────────────────────────────────────────────────────────── // Verificación de la firma X-Hub-Signature-256 de Meta // ────────────────────────────────────────────────────────── private function signatureValid(Request $request): bool { $secret = WhatsappSystemConfig::get('app_secret'); if ($secret === '') { Log::error('[WhatsApp Webhook] app_secret sin configurar; payload rechazado.'); return false; } $header = $request->header('X-Hub-Signature-256', ''); if (! str_starts_with($header, 'sha256=')) { Log::warning('[WhatsApp Webhook] Falta cabecera X-Hub-Signature-256.'); return false; } $esperada = hash_hmac('sha256', $request->getContent(), $secret); if (! hash_equals($esperada, substr($header, 7))) { Log::warning('[WhatsApp Webhook] Firma inválida; payload rechazado.'); return false; } return true; } // ────────────────────────────────────────────────────────── // Extracción y enrutamiento del payload // ────────────────────────────────────────────────────────── private function processPayload(array $payload): void { foreach ($payload['entry'] ?? [] as $entry) { foreach ($entry['changes'] ?? [] as $change) { if (($change['field'] ?? '') !== 'messages') { continue; } $value = $change['value'] ?? []; // Actualizaciones de estado de mensajes enviados (delivered, read, failed) foreach ($value['statuses'] ?? [] as $status) { $this->handleStatusUpdate($status); } // Mensajes entrantes de usuarios $contacts = $value['contacts'] ?? []; $contact = $contacts[0] ?? []; foreach ($value['messages'] ?? [] as $message) { $this->handleMessage($message, $contact); } } } } // ────────────────────────────────────────────────────────── // Actualización de estado de un mensaje saliente // ────────────────────────────────────────────────────────── private function handleStatusUpdate(array $status): void { $messageId = $status['id'] ?? null; $newStatus = $status['status'] ?? null; // delivered | read | failed if ($messageId && $newStatus) { WhatsappConversation::where('message_id', $messageId) ->update(['status' => $newStatus]); } } // ────────────────────────────────────────────────────────── // Procesamiento de un mensaje entrante // ────────────────────────────────────────────────────────── private function handleMessage(array $message, array $contact): void { $messageId = $message['id'] ?? null; $from = $message['from'] ?? null; // número en formato internacional $type = $message['type'] ?? 'text'; $nombre = trim($contact['profile']['name'] ?? ''); if (! $from || ! $messageId) { return; } // Deduplicación: si ya procesamos este message_id, descartarlo if (WhatsappConversation::where('message_id', $messageId)->exists()) { return; } // Extraer contenido según el tipo de mensaje [$texto, $mediaId, $filename, $mimeType] = $this->extractContent($message, $type); // Obtener o crear el usuario $user = WhatsappUser::firstOrCreate( ['phone_number' => $from], ['name' => $nombre ?: $from, 'status' => 'active'] ); if ($nombre && $user->name !== $nombre) { $user->update(['name' => $nombre]); } // Guardar mensaje entrante en el historial WhatsappConversation::create([ 'user_id' => $user->id, 'message_id' => $messageId, 'reply_to_message_id' => $message['context']['id'] ?? null, 'direction' => 'incoming', 'message_type' => $type, 'content' => $texto, 'whatsapp_media_id' => $mediaId, 'filename' => $filename, 'mime_type' => $mimeType, 'status' => 'received', 'is_read' => false, ]); // Interruptor del módulo: con el bot apagado sólo se registra el mensaje. if (WhatsappSystemConfig::get('bot_enabled', '1') !== '1') { return; } // Mismo motor que Telegram y el chat web: idénticos menús, flujos y configuración. $bot = new TelegramBotService('whatsapp'); // Confirmar lectura a WhatsApp $bot->waMarkRead($messageId); switch ($type) { case 'image': case 'document': if ($mediaId) { $bot->handlePhoto($from, $mediaId); return; } $bot->handleText($from, $texto ?? '', $nombre); return; case 'audio': case 'voice': if ($mediaId) { $bot->handleVoice($from, $mediaId, $nombre); return; } $bot->handleText($from, $texto ?? '', $nombre); return; default: $bot->handleText($from, $texto ?? '', $nombre); } } // ────────────────────────────────────────────────────────── // Extracción de contenido por tipo de mensaje // ────────────────────────────────────────────────────────── private function extractContent(array $message, string $type): array { $texto = null; $mediaId = null; $filename = null; $mime = null; switch ($type) { case 'text': $texto = $message['text']['body'] ?? ''; break; case 'interactive': $interactive = $message['interactive'] ?? []; $subtype = $interactive['type'] ?? ''; if ($subtype === 'button_reply') { $titulo = $interactive['button_reply']['title'] ?? ''; $texto = $this->normalizeInteractiveTitle($titulo); } elseif ($subtype === 'list_reply') { $titulo = $interactive['list_reply']['title'] ?? ''; $texto = $this->normalizeInteractiveTitle($titulo); } break; case 'image': case 'video': case 'audio': case 'sticker': $block = $message[$type] ?? []; $mediaId = $block['id'] ?? null; $mime = $block['mime_type'] ?? null; $texto = $block['caption'] ?? null; break; case 'document': $block = $message['document'] ?? []; $mediaId = $block['id'] ?? null; $filename = $block['filename'] ?? null; $mime = $block['mime_type'] ?? null; $texto = $block['caption'] ?? null; break; case 'location': $loc = $message['location'] ?? []; $texto = 'Ubicación: ' . ($loc['latitude'] ?? '') . ',' . ($loc['longitude'] ?? ''); break; case 'reaction': $texto = $message['reaction']['emoji'] ?? ''; break; } return [$texto, $mediaId, $filename, $mime]; } /** * Normaliza respuestas interactivas al número de opción si el título empieza con dígito. * Ejemplo: "1. Ver catálogo" → "1" */ private function normalizeInteractiveTitle(string $titulo): string { if (preg_match('/^(\d+)[\.\)\s]/', $titulo, $m)) { return $m[1]; } return $titulo; } }