diff --git a/app/Http/Controllers/WhatsappWebhookController.php b/app/Http/Controllers/WhatsappWebhookController.php new file mode 100644 index 0000000..3e5489e --- /dev/null +++ b/app/Http/Controllers/WhatsappWebhookController.php @@ -0,0 +1,243 @@ +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; + } +} diff --git a/app/Services/WhatsappBotService.php b/app/Services/WhatsappBotService.php new file mode 100644 index 0000000..62b8011 --- /dev/null +++ b/app/Services/WhatsappBotService.php @@ -0,0 +1,338 @@ +token = WhatsappSystemConfig::get('whatsapp_token'); + $this->phoneNumberId = WhatsappSystemConfig::get('phone_number_id'); + $this->apiUrl = WhatsappSystemConfig::get('whatsapp_api_url', 'https://graph.facebook.com/v22.0/'); + } + + // ────────────────────────────────────────────────────────── + // Punto de entrada principal + // ────────────────────────────────────────────────────────── + + public function handle(WhatsappUser $user, string $texto, string $tipo = 'text'): void + { + // Usuario bloqueado → ignorar + if ($user->status === 'blocked') { + return; + } + + // Atendido por asesor → solo almacenar, no responder + if ($user->in_service) { + return; + } + + // Bot deshabilitado para este usuario → ignorar + if (! $user->bot_enabled) { + return; + } + + // Bot deshabilitado globalmente → ignorar + if (WhatsappSystemConfig::get('bot_enabled', '1') !== '1') { + return; + } + + // Medios (imagen, video, audio, documento, etc.) → aviso y salir + if (! in_array($tipo, ['text', 'interactive'])) { + $msg = 'Recibimos tu ' . $tipo . '. Un asesor te contactará pronto.'; + $this->saveOutgoing($user->id, $msg); + $this->sendText($user->phone_number, $msg); + return; + } + + $texto = trim($texto); + + // ── Comandos especiales ────────────────────────────── + if ($this->isMenuCommand($texto)) { + $this->enviarMenuPrincipal($user); + return; + } + + if ($this->isBackCommand($texto)) { + $this->irAtras($user); + return; + } + + if ($this->isAdvisorCommand($texto)) { + $this->transferirAsesor($user); + return; + } + + // ── Primera visita → bienvenida ────────────────────── + if (! $user->welcome_sent_at) { + $this->enviarBienvenida($user); + return; + } + + // ── Navegación por menú ────────────────────────────── + $this->procesarSeleccion($user, $texto); + } + + // ────────────────────────────────────────────────────────── + // Comandos + // ────────────────────────────────────────────────────────── + + private function isMenuCommand(string $texto): bool + { + return in_array(mb_strtolower($texto), ['menu', 'menú', 'inicio', 'start', 'hola', 'hi']); + } + + private function isBackCommand(string $texto): bool + { + return in_array(mb_strtolower($texto), ['atras', 'atrás', 'volver', 'back', 'regresar']); + } + + private function isAdvisorCommand(string $texto): bool + { + return in_array(mb_strtolower($texto), ['asesor', 'agente', 'humano', 'ayuda']); + } + + // ────────────────────────────────────────────────────────── + // Flujos de respuesta + // ────────────────────────────────────────────────────────── + + private function enviarBienvenida(WhatsappUser $user): void + { + $msg = WhatsappSystemConfig::get( + 'welcome_message', + '¡Hola! Bienvenido. Escribe *menu* para ver las opciones disponibles.' + ); + + $user->update(['welcome_sent_at' => now(), 'current_menu_id' => null]); + $this->saveOutgoing($user->id, $msg); + $this->sendText($user->phone_number, $msg); + + // Mostrar menú principal después del saludo + $this->enviarMenuPrincipal($user); + } + + private function enviarMenuPrincipal(WhatsappUser $user): void + { + $menu = WhatsappMenu::where('is_main', 1)->where('is_active', 1)->first(); + + if (! $menu) { + $msg = WhatsappSystemConfig::get('default_no_match', 'No hay opciones disponibles en este momento.'); + $this->saveOutgoing($user->id, $msg); + $this->sendText($user->phone_number, $msg); + return; + } + + $user->update(['current_menu_id' => $menu->id]); + $texto = $this->formatMenu($menu); + $this->saveOutgoing($user->id, $texto); + $this->sendText($user->phone_number, $texto); + } + + private function irAtras(WhatsappUser $user): void + { + $menuActual = $user->current_menu_id + ? WhatsappMenu::find($user->current_menu_id) + : null; + + $menuPadre = ($menuActual && $menuActual->parent_id) + ? WhatsappMenu::find($menuActual->parent_id) + : null; + + if ($menuPadre && $menuPadre->is_active) { + $user->update(['current_menu_id' => $menuPadre->id]); + $texto = $this->formatMenu($menuPadre); + $this->saveOutgoing($user->id, $texto); + $this->sendText($user->phone_number, $texto); + } else { + $this->enviarMenuPrincipal($user); + } + } + + private function transferirAsesor(WhatsappUser $user): void + { + $msg = WhatsappSystemConfig::get( + 'advisor_message', + 'Un asesor se comunicará contigo en breve. Por favor espera.' + ); + + $user->update([ + 'advisor_requested' => true, + 'in_service' => false, + 'current_menu_id' => null, + ]); + + $this->saveOutgoing($user->id, $msg); + $this->sendText($user->phone_number, $msg); + } + + private function procesarSeleccion(WhatsappUser $user, string $texto): void + { + if (! $user->current_menu_id) { + $this->enviarMenuPrincipal($user); + return; + } + + $menu = WhatsappMenu::find($user->current_menu_id); + + if (! $menu || ! $menu->is_active) { + $this->enviarMenuPrincipal($user); + return; + } + + // Solo aceptar selección numérica + $numero = is_numeric($texto) ? (int) $texto : null; + $opcion = $numero + ? $menu->options()->where('option_number', $numero)->where('is_active', 1)->first() + : null; + + if (! $opcion) { + $noMatch = WhatsappSystemConfig::get('default_no_match', 'Opción no reconocida. Escribe el número de la opción deseada.'); + $fullMsg = $noMatch . "\n\n" . $this->formatMenu($menu); + $this->saveOutgoing($user->id, $fullMsg); + $this->sendText($user->phone_number, $fullMsg); + return; + } + + match ($opcion->action_type) { + 'submenu' => $this->irASubmenu($user, $opcion), + 'message' => $this->responderMensaje($user, $opcion), + 'advisor' => $this->transferirAsesor($user), + 'template' => $this->enviarPlantilla($user, $opcion), + default => $this->responderMensaje($user, $opcion), + }; + } + + private function irASubmenu(WhatsappUser $user, object $opcion): void + { + $subMenu = WhatsappMenu::find($opcion->action_value); + + if (! $subMenu || ! $subMenu->is_active) { + $msg = WhatsappSystemConfig::get('default_no_match', 'Opción no disponible en este momento.'); + $this->saveOutgoing($user->id, $msg); + $this->sendText($user->phone_number, $msg); + return; + } + + $user->update(['current_menu_id' => $subMenu->id]); + $texto = $this->formatMenu($subMenu); + $this->saveOutgoing($user->id, $texto); + $this->sendText($user->phone_number, $texto); + } + + private function responderMensaje(WhatsappUser $user, object $opcion): void + { + $texto = (string) ($opcion->action_value ?? ''); + $this->saveOutgoing($user->id, $texto); + $this->sendText($user->phone_number, $texto); + + // Volver al menú principal tras la respuesta + $menuPrincipal = WhatsappMenu::where('is_main', 1)->where('is_active', 1)->first(); + if ($menuPrincipal) { + $user->update(['current_menu_id' => $menuPrincipal->id]); + $textoMenu = $this->formatMenu($menuPrincipal); + $this->saveOutgoing($user->id, $textoMenu); + $this->sendText($user->phone_number, $textoMenu); + } + } + + private function enviarPlantilla(WhatsappUser $user, object $opcion): void + { + $templateName = (string) ($opcion->action_value ?? ''); + + $payload = [ + 'messaging_product' => 'whatsapp', + 'to' => $user->phone_number, + 'type' => 'template', + 'template' => [ + 'name' => $templateName, + 'language' => ['code' => 'es'], + ], + ]; + + $this->saveOutgoing($user->id, "[Plantilla: {$templateName}]"); + $this->callApi('messages', $payload); + } + + // ────────────────────────────────────────────────────────── + // Helpers de formato + // ────────────────────────────────────────────────────────── + + private function formatMenu(WhatsappMenu $menu): string + { + $lines = [$menu->message]; + + foreach ($menu->options()->where('is_active', 1)->get() as $op) { + $lines[] = "{$op->option_number}. {$op->title}"; + } + + return implode("\n", $lines); + } + + // ────────────────────────────────────────────────────────── + // Envío a la API de WhatsApp Cloud + // ────────────────────────────────────────────────────────── + + public function sendText(string $to, string $body): bool + { + return $this->callApi('messages', [ + 'messaging_product' => 'whatsapp', + 'to' => $to, + 'type' => 'text', + 'text' => ['body' => $body, 'preview_url' => false], + ]); + } + + public function markRead(string $messageId): void + { + $this->callApi('messages', [ + 'messaging_product' => 'whatsapp', + 'status' => 'read', + 'message_id' => $messageId, + ]); + } + + private function callApi(string $endpoint, array $payload): bool + { + if (! $this->token || ! $this->phoneNumberId) { + Log::warning('[WhatsApp] Token o phone_number_id no configurados.'); + return false; + } + + try { + $response = Http::withToken($this->token) + ->timeout(10) + ->post("{$this->apiUrl}{$this->phoneNumberId}/{$endpoint}", $payload); + + if (! $response->successful()) { + Log::error('[WhatsApp] API error: ' . $response->body()); + } + + return $response->successful(); + } catch (\Throwable $e) { + Log::error('[WhatsApp] Excepción al llamar API: ' . $e->getMessage()); + return false; + } + } + + private function saveOutgoing(int $userId, string $content): void + { + WhatsappConversation::create([ + 'user_id' => $userId, + 'direction' => 'outgoing', + 'message_type' => 'text', + 'content' => $content, + 'status' => 'sent', + ]); + } +} diff --git a/database/chat_tables.sql b/database/chat_tables.sql new file mode 100644 index 0000000..f5bd529 --- /dev/null +++ b/database/chat_tables.sql @@ -0,0 +1,78 @@ +-- ============================================= +-- Módulo Chat — Crear tablas en PostgreSQL +-- Ejecutar en el servidor con: +-- psql -U -d -f chat_tables.sql +-- ============================================= + +CREATE TABLE IF NOT EXISTS chat_config ( + id BIGSERIAL PRIMARY KEY, + config_key VARCHAR(255) NOT NULL UNIQUE, + config_value TEXT, + description VARCHAR(255), + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS chat_contacts ( + id BIGSERIAL PRIMARY KEY, + canal VARCHAR(20) NOT NULL, + canal_id VARCHAR(255) NOT NULL, + nombre VARCHAR(255), + telefono VARCHAR(255), + metadata JSONB, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE (canal, canal_id) +); + +CREATE TABLE IF NOT EXISTS chat_conversations ( + id BIGSERIAL PRIMARY KEY, + contact_id BIGINT NOT NULL REFERENCES chat_contacts(id) ON DELETE CASCADE, + canal VARCHAR(20) NOT NULL, + estado VARCHAR(20) NOT NULL DEFAULT 'bot', + menu_actual_id INTEGER, + ultimo_mensaje_at TIMESTAMP NULL, + mensajes_no_leidos INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE, + tipo VARCHAR(20) NOT NULL, + contenido TEXT NOT NULL, + leido BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS chat_menus ( + id BIGSERIAL PRIMARY KEY, + nombre VARCHAR(255) NOT NULL, + mensaje TEXT NOT NULL, + es_raiz BOOLEAN NOT NULL DEFAULT FALSE, + activo BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); + +CREATE TABLE IF NOT EXISTS chat_menu_options ( + id BIGSERIAL PRIMARY KEY, + menu_id BIGINT NOT NULL REFERENCES chat_menus(id) ON DELETE CASCADE, + clave VARCHAR(20) NOT NULL, + etiqueta VARCHAR(255) NOT NULL, + accion VARCHAR(30) NOT NULL, + menu_destino_id BIGINT, + respuesta_texto TEXT, + orden INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); + +-- Registrar la migración en la tabla de Laravel para que no se corra de nuevo +INSERT INTO migrations (migration, batch) +SELECT '2026_04_30_000001_create_chat_tables', COALESCE((SELECT MAX(batch) FROM migrations), 0) + 1 +WHERE NOT EXISTS ( + SELECT 1 FROM migrations WHERE migration = '2026_04_30_000001_create_chat_tables' +); diff --git a/routes/web.php b/routes/web.php index ed37c39..fa2583f 100755 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\ChatController; use App\Http\Controllers\MenuController; use App\Http\Controllers\TelegramWebhookController; use App\Http\Controllers\WhatsappController; +use App\Http\Controllers\WhatsappWebhookController; use Illuminate\Support\Facades\Route; Route::get('/', function () { @@ -25,7 +26,9 @@ Route::get('/wompi_pagos', [MenuController::class, 'wompi_pagos'])->name('wompi Route::any('/wompi_hook', [MenuController::class, 'wompi_hook'])->name('wompi_hook'); Route::get('/compras', [MenuController::class, 'compras'])->name('compras'); Route::get('/pay/{estado}', [MenuController::class, 'pay'])->name('pay'); -Route::post('/webhooks', [MenuController::class, 'showwebhooks']); +Route::get('/webhooks', [WhatsappWebhookController::class, 'verify']); +Route::post('/webhooks', [WhatsappWebhookController::class, 'handle']) + ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); Route::get('/credenciales', [MenuController::class, 'showcredenciales'])->name('credenciales'); Route::get('/oferta', [MenuController::class, 'showoferta'])->name('oferta'); Route::get('/single', [MenuController::class, 'showsingle'])->name('single');