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) { $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']); } // ────────────────────────────────────────────────────────── // 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, ]); $botService = new WhatsappBotService(); // Confirmar lectura a WhatsApp $botService->markRead($messageId); // Procesar con el motor del bot $botService->handle($user, $texto ?? '', $type); } // ────────────────────────────────────────────────────────── // 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; } }