diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 38a52be..1c8b99b 100755 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -105,6 +105,9 @@ class Kernel extends ConsoleKernel 'parse_mode' => 'Markdown', ]); } + } elseif ($contacto->canal === 'whatsapp' && $contacto->canal_id) { + (new \App\Services\TelegramBotService('whatsapp')) + ->send($contacto->canal_id, $msgCanal); } elseif ($contacto->canal === 'web') { $conv = $contacto->activeConversation(); if ($conv) { diff --git a/app/Http/Controllers/WhatsappWebhookController.php b/app/Http/Controllers/WhatsappWebhookController.php index 6d0e952..638272d 100644 --- a/app/Http/Controllers/WhatsappWebhookController.php +++ b/app/Http/Controllers/WhatsappWebhookController.php @@ -6,7 +6,7 @@ use App\Models\WhatsappConversation; use App\Models\WhatsappSystemConfig; use App\Models\WhatsappUser; use App\Models\WhatsappWebhookLog; -use App\Services\WhatsappBotService; +use App\Services\TelegramBotService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; @@ -195,13 +195,39 @@ class WhatsappWebhookController extends Controller 'is_read' => false, ]); - $botService = new WhatsappBotService(); + // 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 - $botService->markRead($messageId); + $bot->waMarkRead($messageId); - // Procesar con el motor del bot - $botService->handle($user, $texto ?? '', $type); + 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); + } } // ────────────────────────────────────────────────────────── diff --git a/app/Http/Livewire/Chat/ShowConversaciones.php b/app/Http/Livewire/Chat/ShowConversaciones.php index 2d4ae78..88d8490 100755 --- a/app/Http/Livewire/Chat/ShowConversaciones.php +++ b/app/Http/Livewire/Chat/ShowConversaciones.php @@ -83,10 +83,13 @@ class ShowConversaciones extends Component $conv->update(['ultimo_mensaje_at' => now()]); - // Enviar via Telegram si el canal es telegram + // Entregar por el canal del cliente if ($conv->canal === 'telegram') { $engine = new ChatBotEngine(); $engine->enviarTelegram($conv->contact->canal_id, $this->replyText); + } elseif ($conv->canal === 'whatsapp') { + (new \App\Services\TelegramBotService('whatsapp')) + ->send($conv->contact->canal_id, $this->replyText); } $this->replyText = ''; diff --git a/app/Http/Livewire/Whatsapp/ShowConfiguracionBot.php b/app/Http/Livewire/Whatsapp/ShowConfiguracionBot.php index 1fbd19a..9c48c58 100644 --- a/app/Http/Livewire/Whatsapp/ShowConfiguracionBot.php +++ b/app/Http/Livewire/Whatsapp/ShowConfiguracionBot.php @@ -16,12 +16,6 @@ class ShowConfiguracionBot extends Component public string $webhook_verify_token = ''; public string $app_secret = ''; public string $bot_enabled = '1'; - public string $welcome_message = ''; - public string $default_no_match = ''; - public string $advisor_message = ''; - public string $business_hours_enabled = '0'; - public string $business_hours_start = ''; - public string $business_hours_end = ''; protected $rules = [ 'whatsapp_token' => 'required|string', @@ -29,18 +23,13 @@ class ShowConfiguracionBot extends Component 'whatsapp_api_url' => 'required|url', 'webhook_verify_token' => 'required|string', 'app_secret' => 'required|string', - 'welcome_message' => 'required|string|max:1000', - 'default_no_match' => 'required|string|max:500', - 'advisor_message' => 'required|string|max:500', ]; public function mount(): void { $keys = [ 'whatsapp_token', 'phone_number_id', 'whatsapp_api_url', - 'webhook_verify_token', 'app_secret', 'bot_enabled', 'welcome_message', - 'default_no_match', 'advisor_message', 'business_hours_enabled', - 'business_hours_start', 'business_hours_end', + 'webhook_verify_token', 'app_secret', 'bot_enabled', ]; foreach ($keys as $key) { @@ -54,9 +43,7 @@ class ShowConfiguracionBot extends Component $keys = [ 'whatsapp_token', 'phone_number_id', 'whatsapp_api_url', - 'webhook_verify_token', 'app_secret', 'bot_enabled', 'welcome_message', - 'default_no_match', 'advisor_message', 'business_hours_enabled', - 'business_hours_start', 'business_hours_end', + 'webhook_verify_token', 'app_secret', 'bot_enabled', ]; foreach ($keys as $key) { diff --git a/app/Jobs/ValidarPagoTelegramJob.php b/app/Jobs/ValidarPagoTelegramJob.php index ed61db6..676d71f 100644 --- a/app/Jobs/ValidarPagoTelegramJob.php +++ b/app/Jobs/ValidarPagoTelegramJob.php @@ -26,15 +26,19 @@ class ValidarPagoTelegramJob implements ShouldQueue public function __construct( private string $chatId, private array $datosPago, - private int $intento = 1 + private int $intento = 1, + private string $canal = 'telegram' ) {} public function handle(): void { - $cacheKey = "tgbot_state_{$this->chatId}"; + // Telegram conserva su prefijo histórico de caché; los demás canales usan el suyo. + $cacheKey = $this->canal === 'telegram' + ? "tgbot_state_{$this->chatId}" + : "{$this->canal}bot_state_{$this->chatId}"; $state = Cache::get($cacheKey, []); $usuarioId = $state['user_id'] ?? null; - $bot = new TelegramBotService(); + $bot = new TelegramBotService($this->canal); if (! $usuarioId) { return; @@ -49,7 +53,7 @@ class ValidarPagoTelegramJob implements ShouldQueue $nombreUsuario = $state['data']['nombre_remitente'] ?? null; $resultado = app(PagoValidadorService::class)->validar( - $this->datosPago, $usuarioId, $nombreUsuario, 'telegram' + $this->datosPago, $usuarioId, $nombreUsuario, $this->canal ); if ($resultado['estado'] === 'confirmado') { @@ -68,7 +72,7 @@ class ValidarPagoTelegramJob implements ShouldQueue // No encontrado — reintentar o agotar if ($this->intento < self::MAX_INTENTOS) { Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intento {$this->intento}/" . self::MAX_INTENTOS . " — reintentando en 20s"); - self::dispatch($this->chatId, $this->datosPago, $this->intento + 1) + self::dispatch($this->chatId, $this->datosPago, $this->intento + 1, $this->canal) ->delay(now()->addSeconds(20)); } else { Log::info("[ValidarPagoTelegramJob] chatId={$this->chatId}: intentos agotados, mostrando botón manual"); diff --git a/app/Models/ChatConversation.php b/app/Models/ChatConversation.php index 04b4020..fbe3e38 100644 --- a/app/Models/ChatConversation.php +++ b/app/Models/ChatConversation.php @@ -31,6 +31,7 @@ class ChatConversation extends Model { return match($this->canal) { 'telegram' => '✈️ Telegram', + 'whatsapp' => '🟢 WhatsApp', default => '💬 Web', }; } @@ -39,6 +40,7 @@ class ChatConversation extends Model { return match($this->canal) { 'telegram' => 'bg-blue-100 text-blue-700', + 'whatsapp' => 'bg-emerald-100 text-emerald-700', default => 'bg-green-100 text-green-700', }; } diff --git a/app/Services/TelegramBotService.php b/app/Services/TelegramBotService.php index 4cc6063..aea1d0a 100755 --- a/app/Services/TelegramBotService.php +++ b/app/Services/TelegramBotService.php @@ -40,6 +40,17 @@ class TelegramBotService private ?int $convId = null; + /** + * Canal de transporte: 'telegram' | 'whatsapp'. + * Los flujos, menús y configuración son los mismos; sólo cambia el envío. + */ + private string $canal; + + public function __construct(string $canal = 'telegram') + { + $this->canal = $canal; + } + // ─── Entry points ───────────────────────────────────────── public function handleText(string $chatId, string $text, string $nombre = ''): void @@ -97,8 +108,23 @@ class TelegramBotService $step = $state['step'] ?? 'await_email'; + // Pasos que esperan texto libre del usuario: ahí un número es un dato, no una opción. + $pasosDeTexto = ['await_email', 'await_otp', 'await_nombre', 'await_celular', + 'await_amount', 'await_remitente_nombre']; + + // WhatsApp responde con el número de la opción; se traduce al callback equivalente. + if ($this->canal === 'whatsapp' && ! in_array($step, $pasosDeTexto, true) + && preg_match('/^\d{1,2}$/', $lower)) { + $mapa = Cache::get($this->kbKey($chatId), []); + + if (isset($mapa[$lower])) { + $this->handleCallback($chatId, '', $mapa[$lower]); + return; + } + } + // Guardar último texto para que handleFreeText pueda pasarlo a Gemini - if (! in_array($step, ['await_email', 'await_otp', 'await_nombre', 'await_celular', 'await_amount', 'await_remitente_nombre'])) { + if (! in_array($step, $pasosDeTexto, true)) { $state['data']['last_text'] = $text; $this->setState($chatId, $state); } @@ -193,7 +219,7 @@ class TelegramBotService $base64 = base64_encode($imageData['content']); $mime = $imageData['mime']; - $datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime, $usuarioId, 'telegram'); + $datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime, $usuarioId, $this->canal); if (! $datosPago) { $this->send($chatId, "No pude leer el comprobante. Intenta con una foto más clara."); @@ -219,7 +245,7 @@ class TelegramBotService } $resultado = app(PagoValidadorService::class)->validar( - $datosPago, $usuarioId, $nombreUsuario, 'telegram' + $datosPago, $usuarioId, $nombreUsuario, $this->canal ); $motivo = $resultado['motivo'] ?? ''; @@ -242,7 +268,7 @@ class TelegramBotService ); // Lanzar job que reintenta cada 20s hasta 9 veces (≈3 min) - ValidarPagoTelegramJob::dispatch($chatId, $datosPago, 1) + ValidarPagoTelegramJob::dispatch($chatId, $datosPago, 1, $this->canal) ->delay(now()->addSeconds(20)); } else { $motivoTexto = match ($motivo) { @@ -283,7 +309,7 @@ class TelegramBotService $this->send($chatId, "⏳ Verificando de nuevo..."); $nombreUsuario = User::find($usuarioId)?->name ?? ''; - $resultado = app(PagoValidadorService::class)->validar($datosPago, $usuarioId, $nombreUsuario, 'telegram'); + $resultado = app(PagoValidadorService::class)->validar($datosPago, $usuarioId, $nombreUsuario, $this->canal); $motivo = $resultado['motivo'] ?? ''; if ($resultado['estado'] === 'confirmado') { @@ -436,7 +462,7 @@ class TelegramBotService AiUsageLog::registrar([ 'servicio' => 'whisper', - 'canal' => 'telegram', + 'canal' => $this->canal, 'usuario_id' => $usuarioId, 'audio_segundos' => $duracionSegundos ?: null, 'tiempo_ms' => $tiempoMs, @@ -461,7 +487,7 @@ class TelegramBotService Log::warning('[TelegramBot] Whisper exception: ' . $e->getMessage()); AiUsageLog::registrar([ 'servicio' => 'whisper', - 'canal' => 'telegram', + 'canal' => $this->canal, 'usuario_id' => $usuarioId, 'audio_segundos' => $duracionSegundos ?: null, 'tiempo_ms' => $tiempoMs, @@ -510,12 +536,12 @@ class TelegramBotService } try { - $contact = ChatContact::where('canal', 'telegram')->where('canal_id', (string) $chatId)->first(); + $contact = ChatContact::where('canal', $this->canal)->where('canal_id', (string) $chatId)->first(); if ($contact) { $contact->update(['user_id' => $user->id, 'nombre' => $user->name]); } else { $contact = ChatContact::create([ - 'canal' => 'telegram', + 'canal' => $this->canal, 'canal_id' => (string) $chatId, 'nombre' => $user->name, 'user_id' => $user->id, @@ -632,12 +658,12 @@ class TelegramBotService 'rol_id' => $rolCliente?->id, ]); - $contact = ChatContact::where('canal', 'telegram')->where('canal_id', $chatId)->first(); + $contact = ChatContact::where('canal', $this->canal)->where('canal_id', $chatId)->first(); if ($contact) { $contact->update(['user_id' => $user->id, 'nombre' => $user->name]); } else { $contact = ChatContact::create([ - 'canal' => 'telegram', + 'canal' => $this->canal, 'canal_id' => $chatId, 'nombre' => $user->name, 'user_id' => $user->id, @@ -655,7 +681,7 @@ class TelegramBotService if (! $conv) { $conv = ChatConversation::create([ 'contact_id' => $contact->id, - 'canal' => 'telegram', + 'canal' => $this->canal, 'estado' => 'bot', 'ultimo_mensaje_at' => now(), ]); @@ -1339,7 +1365,7 @@ class TelegramBotService if ($userId = ($this->getState($chatId)['user_id'] ?? null)) { $state = $this->getState($chatId); $nombre = $state['data']['nombre_remitente'] ?? null; - $solicitud = SolicitudRecarga::crear($userId, $monto, 'telegram', $nombre); + $solicitud = SolicitudRecarga::crear($userId, $monto, $this->canal, $nombre); $state['data']['solicitud_recarga_id'] = $solicitud->id; $this->setState($chatId, $state); } @@ -1522,7 +1548,7 @@ class TelegramBotService if ($texto) { $contexto = GeminiAgentService::buildContexto($state, $state['user_id'] ?? null); $resultado = app(GeminiAgentService::class)->responder( - $texto, $contexto, $state['user_id'] ?? null, 'telegram' + $texto, $contexto, $state['user_id'] ?? null, $this->canal ); if ($resultado['respuesta']) { @@ -1573,20 +1599,30 @@ class TelegramBotService // ─── State management ───────────────────────────────────── + private function stateKey(string $chatId): string + { + // Telegram conserva su prefijo histórico: cambiarlo cerraría todas las sesiones abiertas. + return $this->canal === 'telegram' ? "tgbot_state_{$chatId}" : "{$this->canal}bot_state_{$chatId}"; + } + private function getState(string $chatId): array { - return Cache::get("tgbot_state_{$chatId}", ['step' => 'await_email', 'data' => []]); + return Cache::get($this->stateKey($chatId), ['step' => 'await_email', 'data' => []]); } private function setState(string $chatId, array $state): void { - Cache::put("tgbot_state_{$chatId}", $state, now()->addSeconds(self::STATE_TTL)); + Cache::put($this->stateKey($chatId), $state, now()->addSeconds(self::STATE_TTL)); } // ─── Telegram API ───────────────────────────────────────── public function registrarComandos(): bool { + if ($this->canal !== 'telegram') { + return false; + } + return $this->apiCall('setMyCommands', [ 'commands' => [ ['command' => 'start', 'description' => 'Iniciar / Menú principal'], @@ -1608,6 +1644,10 @@ class TelegramBotService ]); } + if ($this->canal === 'whatsapp') { + return $this->waSend($chatId, $text); + } + return $this->apiCall('sendMessage', [ 'chat_id' => $chatId, 'text' => $text, @@ -1626,6 +1666,10 @@ class TelegramBotService ]); } + if ($this->canal === 'whatsapp') { + return $this->waSend($chatId, $this->numerarTeclado($chatId, $text, $inlineKeyboard)); + } + return $this->apiCall('sendMessage', [ 'chat_id' => $chatId, 'text' => $text ?: '', // zero-width space fallback @@ -1636,6 +1680,10 @@ class TelegramBotService private function answerCallback(string $callbackQueryId): void { + if ($this->canal !== 'telegram' || $callbackQueryId === '') { + return; + } + $this->apiCall('answerCallbackQuery', ['callback_query_id' => $callbackQueryId]); } @@ -1657,8 +1705,150 @@ class TelegramBotService } } + // ─── WhatsApp API ───────────────────────────────────────── + // + // WhatsApp no tiene teclados inline como Telegram: las mismas opciones se + // envían numeradas y la respuesta numérica del usuario se traduce de vuelta + // al callback_data equivalente, así los flujos son idénticos en ambos canales. + // ponytail: texto numerado en vez de mensajes interactivos; si se quieren + // botones nativos, el tope de Meta es 3 botones o 10 filas de lista. + + private function kbKey(string $chatId): string + { + return "wabot_kb_{$chatId}"; + } + + private function numerarTeclado(string $chatId, string $text, array $inlineKeyboard): string + { + $mapa = []; + $lineas = []; + $n = 1; + + foreach ($inlineKeyboard as $fila) { + foreach ($fila as $boton) { + $etiqueta = $boton['text'] ?? ''; + $accion = $boton['callback_data'] ?? null; + + if ($accion === null) { + continue; + } + + $mapa[(string) $n] = $accion; + $lineas[] = "{$n}. {$etiqueta}"; + $n++; + } + } + + if (! $lineas) { + return $text; + } + + Cache::put($this->kbKey($chatId), $mapa, now()->addHours(6)); + + $cuerpo = $text !== '' ? $text . "\n\n" : ''; + + return $cuerpo . implode("\n", $lineas) . "\n\n_Responde con el número de la opción._"; + } + + public function waMarkRead(string $messageId): bool + { + if ($this->canal !== 'whatsapp') { + return false; + } + + return $this->waApiCall('messages', [ + 'messaging_product' => 'whatsapp', + 'status' => 'read', + 'message_id' => $messageId, + ]); + } + + private function waSend(string $chatId, string $text): bool + { + return $this->waApiCall('messages', [ + 'messaging_product' => 'whatsapp', + 'recipient_type' => 'individual', + 'to' => $chatId, + 'type' => 'text', + 'text' => ['preview_url' => false, 'body' => $text], + ]); + } + + private function waApiCall(string $endpoint, array $payload): bool + { + [$token, $phoneId, $apiUrl] = $this->waCredenciales(); + + if (! $token || ! $phoneId) { + Log::warning('[WhatsAppBot] token o phone_number_id sin configurar.'); + return false; + } + + try { + $response = Http::withToken($token) + ->timeout(10) + ->post("{$apiUrl}{$phoneId}/{$endpoint}", $payload); + + if (! $response->successful()) { + Log::error('[WhatsAppBot] API error: ' . $response->body()); + } + + return $response->successful(); + } catch (\Throwable $e) { + Log::warning("[WhatsAppBot] waApiCall {$endpoint} failed: " . $e->getMessage()); + return false; + } + } + + /** @return array{0:string,1:string,2:string} token, phone_number_id, api_url */ + private function waCredenciales(): array + { + return [ + \App\Models\WhatsappSystemConfig::get('whatsapp_token'), + \App\Models\WhatsappSystemConfig::get('phone_number_id'), + \App\Models\WhatsappSystemConfig::get('whatsapp_api_url', 'https://graph.facebook.com/v22.0/'), + ]; + } + + private function downloadWhatsappMedia(string $mediaId): ?array + { + [$token, , $apiUrl] = $this->waCredenciales(); + + if (! $token) { + return null; + } + + try { + $info = Http::withToken($token)->timeout(10)->get("{$apiUrl}{$mediaId}"); + $url = $info->json('url'); + + if (! $url) { + Log::warning('[WhatsAppBot] media sin URL: ' . $info->body()); + return null; + } + + $archivo = Http::withToken($token)->timeout(30)->get($url); + $content = $archivo->body(); + + if (! $content) { + return null; + } + + return [ + 'content' => $content, + 'mime' => $info->json('mime_type') ?: 'image/jpeg', + ]; + } catch (\Throwable $e) { + Log::warning('[WhatsAppBot] downloadWhatsappMedia failed: ' . $e->getMessage()); + return null; + } + } + private function downloadTelegramFile(string $fileId): ?array { + if ($this->canal === 'whatsapp') { + return $this->downloadWhatsappMedia($fileId); + } + $token = ChatConfig::get('telegram_token'); if (! $token) { return null; diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 8811e4d..637dec6 100755 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -265,25 +265,9 @@
{{ $message }}
@enderror -{{ $message }}
@enderror -{{ $message }}
@enderror -+ Los mensajes, menús y flujos del bot se administran en + Chat / Bot → Configuración; + WhatsApp usa exactamente los mismos que Telegram y el chat web. +