getState($chatId); $this->convId = $state['conv_id'] ?? null; // Save user message if we have a conversation if ($this->convId && $text !== '') { $conv = ChatConversation::find($this->convId); if ($conv) { ChatMessage::create([ 'conversation_id' => $this->convId, 'tipo' => 'usuario', 'contenido' => $text, 'leido' => false, ]); $conv->update(['ultimo_mensaje_at' => now()]); } } $lower = strtolower(trim($text)); // "salir" siempre pide confirmación de cierre de sesión if (in_array($lower, ['salir', '/salir'])) { if ($state['user_id'] ?? null) { $this->showLogoutConfirmation($chatId); } else { $this->askEmail($chatId); } return; } // Palabras que siempre regresan al menú, desde cualquier punto del flujo $menuTriggers = [ '/start', '/menu', '/inicio', '/cancelar', 'menu', 'menú', 'inicio', 'hola', 'volver', 'regresar', 'cancelar', 'empezar', 'comenzar', 'start', ]; if (in_array($lower, $menuTriggers)) { if ($state['user_id'] ?? null) { // Limpiar cualquier flujo pendiente $cleanState = $this->getState($chatId); unset($cleanState['step']); $cleanState['step'] = 'idle'; $this->setState($chatId, $cleanState); $this->showMainMenu($chatId); } else { $this->askEmail($chatId); } return; } $step = $state['step'] ?? 'await_email'; // 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'])) { $state['data']['last_text'] = $text; $this->setState($chatId, $state); } match ($step) { 'await_email' => $this->processEmail($chatId, $text, $nombre), 'await_otp' => $this->processOtp($chatId, $text), 'await_nombre' => $this->processNombre($chatId, $text), 'await_celular' => $this->processCelular($chatId, $text), 'await_amount' => $this->processCustomAmount($chatId, $text), 'await_remitente_nombre' => $this->processRemitenteNombre($chatId, $text), default => $this->handleFreeText($chatId, $state), }; } public function handleCallback(string $chatId, string $callbackId, string $data): void { $this->answerCallback($callbackId); $state = $this->getState($chatId); $this->convId = $state['conv_id'] ?? null; if (! ($state['user_id'] ?? null)) { $this->askEmail($chatId); return; } $parts = explode('|', $data, 3); $action = $parts[0]; match ($action) { 'menu' => $this->showMainMenu($chatId), 'logout_ask' => $this->showLogoutConfirmation($chatId), 'logout_confirm' => $this->confirmLogout($chatId), 'svc_list' => $this->listServices($chatId), 'svc_view' => $this->viewService($chatId, (int) ($parts[1] ?? 0)), 'buy_start' => $this->startPurchase($chatId, (int) ($parts[1] ?? 0)), 'buy_saldo' => $this->payWithSaldo($chatId), 'breb_tarifa' => $this->startDirectBrebPayment($chatId, (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0)), 'promo_list' => $this->listPromos($chatId), 'promo_view' => $this->viewPromo($chatId, (int) ($parts[1] ?? 0)), 'promo_saldo' => $this->buyPromoSaldo($chatId, (int) ($parts[1] ?? 0)), 'breb_promo' => $this->startDirectBrebPromo($chatId, (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0)), 'rec_init' => $this->showRechargeAmounts($chatId), 'rec_custom' => $this->askCustomAmount($chatId), 'rec_amount' => $this->chooseRechargeMethod($chatId, (int) ($parts[1] ?? 0)), 'rec_breb' => $this->askRemitenteNombre($chatId, (int) ($parts[1] ?? 0)), 'breb_nom' => $this->seleccionarNombreRemitente($chatId, (string) ($parts[1] ?? 'nuevo')), 'creds' => $this->showCredentials($chatId), 'history' => $this->showHistory($chatId), 'profile' => $this->showProfile($chatId), 'agent' => $this->showMainMenu($chatId), 'pay_retry' => $this->retryPayment($chatId), default => $this->showMainMenu($chatId), }; } public function handlePhoto(string $chatId, string $fileId): void { $state = $this->getState($chatId); $this->convId = $state['conv_id'] ?? null; if (! ($state['user_id'] ?? null)) { $this->send($chatId, "Para enviar un comprobante primero debes identificarte. Escribe tu correo."); return; } if (ChatConfig::get('validacion_foto_habilitada', '0') !== '1') { $this->send($chatId, "El análisis de comprobantes no está habilitado."); return; } $usuarioId = $state['user_id'] ?? null; $nombreUsuario = $state['data']['nombre_remitente'] ?? ($usuarioId ? (User::find($usuarioId)?->name ?? '') : ''); // Verificar solicitud de recarga activa ANTES de cualquier llamada de API $solicitud = $usuarioId ? SolicitudRecarga::pendiente($usuarioId) : null; if (! $solicitud) { $this->send($chatId, "⚠️ No tienes ninguna solicitud de recarga activa. Primero inicia el proceso de recarga desde el menú."); $this->showMainMenu($chatId); return; } $this->send($chatId, "⏳ Analizando tu comprobante..."); try { $imageData = $this->downloadTelegramFile($fileId); if (! $imageData) { $this->send($chatId, "No pude descargar la imagen. Intenta de nuevo."); return; } $base64 = base64_encode($imageData['content']); $mime = $imageData['mime']; $datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime, $usuarioId, 'telegram'); if (! $datosPago) { $this->send($chatId, "No pude leer el comprobante. Intenta con una foto más clara."); return; } if (isset($datosPago['__error'])) { $this->send($chatId, "⚠️ Error de API:\n`" . $datosPago['__error'] . "`"); return; } if (isset($datosPago['__parse_error'])) { $this->send($chatId, "⚠️ Gemini respondió pero no es JSON válido:\n`" . $datosPago['__parse_error'] . "`"); return; } // Verificar que el monto del comprobante coincida con la solicitud $valorIA = (int) ($datosPago['valor'] ?? 0); if ($valorIA !== $solicitud->monto) { $this->send($chatId, "⚠️ El monto del comprobante (*\$" . number_format($valorIA) . "*) no coincide con tu solicitud de recarga (*\$" . number_format($solicitud->monto) . "*)."); $this->showMainMenu($chatId); return; } $resultado = app(PagoValidadorService::class)->validar( $datosPago, $usuarioId, $nombreUsuario, 'telegram' ); $motivo = $resultado['motivo'] ?? ''; if ($resultado['estado'] === 'confirmado') { $this->autoAplicarRecarga($chatId, $state, $solicitud, $valorIA); } elseif ($resultado['estado'] === 'ya_usado') { $this->sendWithKeyboard($chatId, "⛔ *Comprobante ya utilizado.*\nEste pago ya fue registrado anteriormente.", [ [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ]); } elseif ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') { // Guardar para reintento manual (por si el job agota sus intentos) $state['data']['pago_pendiente'] = $datosPago; $this->setState($chatId, $state); $this->send($chatId, "⏳ *Estamos validando tu pago*, esto puede demorar unos minutos.\n" . "Cuando terminemos te confirmaremos por este medio. Espera un momento." ); // Lanzar job que reintenta cada 20s hasta 9 veces (≈3 min) ValidarPagoTelegramJob::dispatch($chatId, $datosPago, 1) ->delay(now()->addSeconds(20)); } else { $motivoTexto = match ($motivo) { 'correo_deshabilitado' => '📧 Verificación por correo no configurada', 'error_imap' => '📧 Error al leer correos IMAP', 'error_sistema' => '⚙️ Error interno, intenta de nuevo en unos minutos', default => '📧 No se pudo verificar automáticamente', }; $this->sendWithKeyboard($chatId, "⚠️ *No confirmado*\n_{$motivoTexto}_", [ [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ]); } } catch (\Throwable $e) { Log::warning('[TelegramBot] Error procesando foto: ' . $e->getMessage()); $this->send($chatId, "Error analizando el comprobante. Por favor intenta de nuevo."); } } private function retryPayment(string $chatId): void { $state = $this->getState($chatId); $this->convId = $state['conv_id'] ?? null; $datosPago = $state['data']['pago_pendiente'] ?? null; $usuarioId = $state['user_id'] ?? null; if (! $datosPago || ! $usuarioId) { $this->send($chatId, "No hay ningún comprobante pendiente. Envía la foto de tu comprobante."); return; } $solicitud = SolicitudRecarga::pendiente($usuarioId); if (! $solicitud) { $this->send($chatId, "⚠️ Tu solicitud de recarga expiró. Inicia una nueva recarga desde el menú."); $this->showMainMenu($chatId); return; } $this->send($chatId, "⏳ Verificando de nuevo..."); $nombreUsuario = User::find($usuarioId)?->name ?? ''; $resultado = app(PagoValidadorService::class)->validar($datosPago, $usuarioId, $nombreUsuario, 'telegram'); $motivo = $resultado['motivo'] ?? ''; if ($resultado['estado'] === 'confirmado') { $this->autoAplicarRecarga($chatId, $state, $solicitud, $solicitud->monto); } elseif ($resultado['estado'] === 'ya_usado') { unset($state['data']['pago_pendiente']); $this->setState($chatId, $state); $this->sendWithKeyboard($chatId, "⛔ *Comprobante ya utilizado.*\nEste pago ya fue registrado anteriormente.", [ [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ]); } else { $this->sendWithKeyboard($chatId, "⏳ *Aún no encontramos tu pago.*\nPuede demorar entre 1 y 2 minutos en llegar. Espera un momento y presiona Reintentar.", [ [['text' => '🔄 Reintentar', 'callback_data' => 'pay_retry']], [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ] ); } } public function autoAplicarRecarga(string $chatId, array $state, SolicitudRecarga $solicitud, int $monto): void { $usuarioId = $state['user_id']; $user = User::with('saldo')->find($usuarioId); if (! $user) { Log::warning("[TelegramBot] autoAplicarRecarga: usuario {$usuarioId} no encontrado."); $this->send($chatId, "⚠️ Error al aplicar la recarga. Por favor contacta a un asesor."); return; } $saldo = $user->saldo ?? Saldo::create(['usuario_id' => $usuarioId, 'valor' => 0]); $nuevoSaldo = $saldo->valor + $monto; DB::transaction(function () use ($saldo, $nuevoSaldo, $usuarioId, $monto, $solicitud) { $saldo->update(['valor' => $nuevoSaldo]); \App\Models\recarga::create([ 'usuario_id' => $usuarioId, 'saldo_id' => $saldo->id, 'monto' => $monto, 'valor_recarga' => $monto, 'status' => 'Confirmado', 'reference' => 'breb-bot-' . $solicitud->id, ]); $solicitud->confirmar(); }); $pp = $state['data']['pending_purchase'] ?? null; unset($state['data']['pago_pendiente'], $state['data']['solicitud_recarga_id'], $state['data']['pending_purchase']); $this->setState($chatId, $state); $this->send($chatId, "✅ *¡Tu recarga de \$" . number_format($monto) . " fue aplicada exitosamente!*\n" . "Tu nuevo saldo es *\$" . number_format($nuevoSaldo) . "*" ); if ($pp && ($pp['type'] ?? '') === 'tarifa') { // Configurar estado para la compra y ejecutarla directamente $state = $this->getState($chatId); $state['data'] = [ 'flow' => 'compra', 'tarifa_id' => $pp['tarifa_id'], 'valor' => $pp['valor'], 'servicio' => $pp['servicio'], 'servicio_id' => $pp['servicio_id'], ]; $this->setState($chatId, $state); $this->payWithSaldo($chatId); } elseif ($pp && ($pp['type'] ?? '') === 'promo') { $this->buyPromoSaldo($chatId, $pp['promo_id']); } else { $this->showMainMenu($chatId); } } public function handleVoice(string $chatId, string $fileId, string $nombre = '', int $duracionSegundos = 0): void { $state = $this->getState($chatId); $this->convId = $state['conv_id'] ?? null; $usuarioId = $state['user_id'] ?? null; if (ChatConfig::get('whisper_habilitado', '0') !== '1') { $this->send($chatId, "🎤 Recibí tu audio, pero la transcripción de voz no está habilitada. Por favor escribe tu mensaje."); return; } $this->send($chatId, "🎤 Transcribiendo tu audio..."); try { $audioData = $this->downloadTelegramFile($fileId); if (! $audioData) { $this->send($chatId, "No pude descargar el audio. Intenta de nuevo o escribe tu mensaje."); return; } $resultado = $this->transcribeAudio($audioData['content'], $duracionSegundos, $usuarioId); if (! $resultado['texto']) { $this->send($chatId, "No pude entender el audio. Intenta de nuevo o escribe tu mensaje."); return; } $this->send($chatId, "🎙️ Escuché: _\"{$resultado['texto']}\"_\nProcesando..."); $this->handleText($chatId, $resultado['texto'], $nombre); } catch (\Throwable $e) { Log::warning('[TelegramBot] handleVoice error: ' . $e->getMessage()); $this->send($chatId, "Error procesando el audio. Escribe tu mensaje."); } } private function transcribeAudio(string $audioContent, int $duracionSegundos = 0, ?int $usuarioId = null): array { $whisperUrl = ChatConfig::get('whisper_url'); $whisperToken = ChatConfig::get('whisper_token'); $inicio = microtime(true); if (! $whisperUrl) { return ['texto' => null]; } try { // response_format y language como query params $sep = str_contains($whisperUrl, '?') ? '&' : '?'; $url = rtrim($whisperUrl, '?') . $sep . 'response_format=json&language=es'; $response = Http::timeout(60) ->withBasicAuth('whisper', $whisperToken) ->attach('audio_file', $audioContent, 'audio.ogg') ->post($url); $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); // Intentar JSON primero, luego texto plano como fallback $json = $response->json(); $texto = trim($json['text'] ?? $json['transcription'] ?? ''); if ($texto === '' && $response->successful()) { // El servidor devolvió texto plano $raw = trim($response->body()); if ($raw !== '' && ! str_starts_with($raw, '{') && ! str_starts_with($raw, '[')) { $texto = $raw; } } $ok = $response->successful() && $texto !== ''; AiUsageLog::registrar([ 'servicio' => 'whisper', 'canal' => 'telegram', 'usuario_id' => $usuarioId, 'audio_segundos' => $duracionSegundos ?: null, 'tiempo_ms' => $tiempoMs, 'costo' => $duracionSegundos, // $1 por segundo 'resultado' => $ok ? 'ok' : 'error', 'detalle' => [ 'texto' => $ok ? substr($texto, 0, 200) : null, 'error' => $ok ? null : substr($response->body(), 0, 300), 'segundos' => $duracionSegundos, ], ]); if (! $ok) { Log::warning('[TelegramBot] Whisper error: ' . $response->body()); return ['texto' => null]; } return ['texto' => $texto]; } catch (\Throwable $e) { $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); Log::warning('[TelegramBot] Whisper exception: ' . $e->getMessage()); AiUsageLog::registrar([ 'servicio' => 'whisper', 'canal' => 'telegram', 'usuario_id' => $usuarioId, 'audio_segundos' => $duracionSegundos ?: null, 'tiempo_ms' => $tiempoMs, 'resultado' => 'error', 'detalle' => ['error' => $e->getMessage()], ]); return ['texto' => null]; } } // ─── Auth flow ──────────────────────────────────────────── private function askEmail(string $chatId): void { $this->setState($chatId, ['step' => 'await_email', 'data' => []]); $this->send($chatId, "👋 Hola! Para continuar escribe tu correo electrónico registrado:"); } private function processEmail(string $chatId, string $email, string $nombre): void { $email = strtolower(trim($email)); if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { $this->send($chatId, "❌ Eso no parece un correo válido. Escríbelo de nuevo:"); return; } try { $user = User::whereRaw('LOWER(email) = ?', [$email])->first(); } catch (\Throwable $e) { Log::error('[TelegramBot] processEmail DB error: ' . $e->getMessage()); $this->send($chatId, "⚠️ Error de conexión. Escribe tu correo de nuevo:"); return; } if (! $user) { $this->setState($chatId, [ 'step' => 'await_nombre', 'data' => ['email' => $email, 'nombre_tg' => $nombre], ]); $this->send($chatId, "No encontré una cuenta con ese correo.\n\n" . "¿Deseas crear una cuenta? Escribe tu *nombre completo* o escribe /cancelar para intentar con otro correo." ); return; } try { $contact = ChatContact::where('canal', 'telegram')->where('canal_id', (string) $chatId)->first(); if ($contact) { $contact->update(['user_id' => $user->id, 'nombre' => $user->name]); } else { $contact = ChatContact::create([ 'canal' => 'telegram', 'canal_id' => (string) $chatId, 'nombre' => $user->name, 'user_id' => $user->id, ]); } } catch (\Throwable $e) { Log::error('[TelegramBot] processEmail contact error: ' . $e->getMessage()); $this->send($chatId, "⚠️ Error interno. Escribe tu correo de nuevo:"); return; } $this->sendOtp($chatId, $contact, $user); } private function sendOtp(string $chatId, ChatContact $contact, User $user): void { $codigo = (string) random_int(100000, 999999); Cache::put("chat_otp_{$contact->id}", $codigo, now()->addMinutes(10)); $mailError = null; try { Mail::to($user->email)->send(new ChatOtpMail($codigo, $user->name)); } catch (\Throwable $e) { $mailError = $e->getMessage(); Log::warning('[TelegramOTP] Error enviando correo: ' . $mailError); } $this->setState($chatId, [ 'step' => 'await_otp', 'contact_id' => $contact->id, 'data' => [], ]); $masked = $this->maskEmail($user->email); if (! $mailError) { $this->send($chatId, "✉️ Te enviamos un código a `{$masked}`\n\nEscribe el código de 6 dígitos:"); } else { $this->send($chatId, "⚠️ Error enviando correo a `{$masked}`\n\n`{$mailError}`\n\nContacta al administrador." ); } } private function processOtp(string $chatId, string $text): void { $state = $this->getState($chatId); $contactId = $state['contact_id'] ?? null; if (! $contactId || ! preg_match('/^\d{6}$/', $text)) { $this->send($chatId, "El código debe tener 6 dígitos. Intenta de nuevo:"); return; } $saved = Cache::get("chat_otp_{$contactId}"); if (! $saved) { $this->send($chatId, "El código expiró. Escribe tu correo de nuevo:"); $this->setState($chatId, ['step' => 'await_email', 'data' => []]); return; } if ($text !== $saved) { $this->send($chatId, "❌ Código incorrecto. Intenta de nuevo:"); return; } Cache::forget("chat_otp_{$contactId}"); $contact = ChatContact::find($contactId); $user = User::with('saldo')->find($contact->user_id); $this->openSession($chatId, $contact, $user); } // ─── Registration flow ──────────────────────────────────── private function processNombre(string $chatId, string $text): void { if (strtolower($text) === '/cancelar') { $this->setState($chatId, ['step' => 'await_email', 'data' => []]); $this->send($chatId, "De acuerdo. Escribe tu correo electrónico:"); return; } $state = $this->getState($chatId); $state['data']['nombre'] = $text; $state['step'] = 'await_celular'; $this->setState($chatId, $state); $this->send($chatId, "Tu número de celular (o escribe /omitir para saltarlo):"); } private function processCelular(string $chatId, string $text): void { $state = $this->getState($chatId); $state['data']['celular'] = strtolower($text) === '/omitir' ? null : $text; $email = $state['data']['email'] ?? null; $nombre = $state['data']['nombre'] ?? null; if (! $email || ! $nombre) { $this->send($chatId, "Ocurrió un error. Escribe /start para comenzar de nuevo."); $this->setState($chatId, ['step' => 'await_email', 'data' => []]); return; } $rolCliente = Role::where('nombre', 'cliente')->first(); $user = User::create([ 'name' => $nombre, 'email' => $email, 'password' => Hash::make(Str::random(16)), 'cedula' => $state['data']['cedula'] ?? null, 'celular' => $state['data']['celular'] ?? null, 'estado' => 'activo', 'rol_id' => $rolCliente?->id, ]); $contact = ChatContact::where('canal', 'telegram')->where('canal_id', $chatId)->first(); if ($contact) { $contact->update(['user_id' => $user->id, 'nombre' => $user->name]); } else { $contact = ChatContact::create([ 'canal' => 'telegram', 'canal_id' => $chatId, 'nombre' => $user->name, 'user_id' => $user->id, ]); } $this->sendOtp($chatId, $contact, $user); } // ─── Open session ───────────────────────────────────────── private function openSession(string $chatId, ChatContact $contact, User $user): void { $conv = $contact->activeConversation(); if (! $conv) { $conv = ChatConversation::create([ 'contact_id' => $contact->id, 'canal' => 'telegram', 'estado' => 'bot', 'ultimo_mensaje_at' => now(), ]); } $this->convId = $conv->id; $this->setState($chatId, [ 'step' => 'menu', 'user_id' => $user->id, 'contact_id' => $contact->id, 'conv_id' => $conv->id, 'data' => [], ]); $saludo = "¡Bienvenido, *{$user->name}*! 🎉\nSaldo disponible: *\$" . number_format($user->saldo?->valor ?? 0) . "*"; $this->send($chatId, $saludo); $this->showMainMenu($chatId); } // ─── Main menu ──────────────────────────────────────────── private function showLogoutConfirmation(string $chatId): void { $this->sendWithKeyboard( $chatId, "⚠️ ¿Deseas cerrar sesión?\nDeberás volver a ingresar tu correo y código para continuar.", [ [ ['text' => '✅ Sí, cerrar sesión', 'callback_data' => 'logout_confirm'], ['text' => '❌ No, quedarme', 'callback_data' => 'menu'], ], ] ); } private function confirmLogout(string $chatId): void { $state = $this->getState($chatId); unset($state['user_id'], $state['conv_id'], $state['step'], $state['data']); $state['step'] = 'await_email'; $this->setState($chatId, $state); $this->send($chatId, "👋 Sesión cerrada. Hasta pronto."); $this->askEmail($chatId); } private function showMainMenu(string $chatId): void { $state = $this->getState($chatId); $state['step'] = 'menu'; $state['data'] = []; $this->setState($chatId, $state); $this->sendWithKeyboard($chatId, "¿En qué te puedo ayudar?", [ [ ['text' => '📺 Ver Servicios', 'callback_data' => 'svc_list'], ['text' => '🎁 Promociones', 'callback_data' => 'promo_list'], ], [ ['text' => '💰 Recargar Saldo', 'callback_data' => 'rec_init'], ['text' => '🔑 Mis Credenciales', 'callback_data' => 'creds'], ], [ ['text' => '📋 Historial', 'callback_data' => 'history'], ['text' => '👤 Mi Perfil', 'callback_data' => 'profile'], ], [ ['text' => '🚪 Cerrar sesión', 'callback_data' => 'logout_ask'], ], ]); } // ─── Services ───────────────────────────────────────────── private function listServices(string $chatId): void { $state = $this->getState($chatId); $rolId = isset($state['user_id']) ? (User::find($state['user_id'])?->rol_id ?? Role::where('nombre', 'cliente')->value('id')) : Role::where('nombre', 'cliente')->value('id'); $servicios = Servicio::where('estado', 'activo') ->whereHas('tarifas', fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId)) ->orderBy('ubicacion') ->get(); if ($servicios->isEmpty()) { $this->send($chatId, "No hay servicios disponibles en este momento."); $this->showMainMenu($chatId); return; } $buttons = []; foreach ($servicios as $s) { $buttons[] = [['text' => $s->nombre, 'callback_data' => 'svc_view|' . $s->id]]; } $buttons[] = [['text' => '🔙 Menú principal', 'callback_data' => 'menu']]; $this->sendWithKeyboard($chatId, "📺 *Selecciona el servicio:*", $buttons); } private function viewService(string $chatId, int $servicioId): void { $state = $this->getState($chatId); $user = User::find($state['user_id']); $rolId = $user?->rol_id ?? Role::where('nombre', 'cliente')->value('id'); $servicio = Servicio::with(['tarifas' => fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId)])->find($servicioId); if (! $servicio) { $this->send($chatId, "Servicio no encontrado."); return; } $tarifas = $servicio->tarifas; if ($tarifas->isEmpty()) { $this->send($chatId, "No hay planes disponibles para {$servicio->nombre}."); $this->listServices($chatId); return; } $buttons = []; foreach ($tarifas as $t) { $precio = CuentasHelper::precio($t->id, $state['user_id'], $t->valor); $label = $servicio->por_tiempo ? "{$t->dias} días - \$" . number_format($precio) : "{$t->pantallas} pantalla(s) / {$t->dias} días - \$" . number_format($precio); $buttons[] = [['text' => $label, 'callback_data' => 'buy_start|' . $t->id]]; } $buttons[] = [['text' => '🔙 Ver servicios', 'callback_data' => 'svc_list']]; $this->sendWithKeyboard($chatId, "📺 *{$servicio->nombre}* — elige un plan:", $buttons); } // ─── Purchase ───────────────────────────────────────────── private function startPurchase(string $chatId, int $tarifaId): void { $state = $this->getState($chatId); $tarifa = Tarifas::with('servicio')->find($tarifaId); if (! $tarifa) { $this->send($chatId, "Plan no encontrado."); return; } if (CuentasHelper::contar($tarifa) < 1) { $this->send($chatId, "Sin cuentas disponibles para este plan. Intenta con otro."); $this->listServices($chatId); return; } $user = User::with('saldo')->find($state['user_id']); $saldo = $user->saldo?->valor ?? 0; $valor = CuentasHelper::precio($tarifaId, $state['user_id'], $tarifa->valor); $state['data'] = ['flow' => 'compra', 'tarifa_id' => $tarifaId, 'valor' => $valor, 'servicio' => $tarifa->servicio->nombre, 'servicio_id' => $tarifa->servicio_id]; $this->setState($chatId, $state); $detalle = $tarifa->servicio->por_tiempo ? "{$tarifa->dias} días" : "{$tarifa->pantallas} pantalla(s) / {$tarifa->dias} días"; $buttons = []; if ($saldo >= $valor) { $buttons[] = [['text' => "💳 Pagar con saldo (\$" . number_format($valor) . ")", 'callback_data' => 'buy_saldo']]; $buttons[] = [['text' => '🔙 Ver planes', 'callback_data' => 'svc_view|' . $tarifa->servicio_id]]; $this->sendWithKeyboard( $chatId, "*{$tarifa->servicio->nombre}* — {$detalle}\nPrecio: *\$" . number_format($valor) . "*\n\nElige cómo pagar:", $buttons ); } else { $falta = $valor - $saldo; $state['data']['pending_purchase'] = [ 'type' => 'tarifa', 'tarifa_id' => $tarifaId, 'valor' => $valor, 'servicio' => $tarifa->servicio->nombre, 'servicio_id' => $tarifa->servicio_id, ]; $this->setState($chatId, $state); $buttons[] = [['text' => "💳 Pagar con Bre-B directo (\$" . number_format($valor) . ")", 'callback_data' => 'breb_tarifa|' . $tarifaId . '|' . $valor]]; $buttons[] = [['text' => "💰 Recargar saldo (\$" . number_format($falta) . " faltantes)", 'callback_data' => 'rec_init']]; $buttons[] = [['text' => '🔙 Ver planes', 'callback_data' => 'svc_view|' . $tarifa->servicio_id]]; $this->sendWithKeyboard( $chatId, "*{$tarifa->servicio->nombre}* — {$detalle}\nPrecio: *\$" . number_format($valor) . "*\n\nSaldo actual: \$" . number_format($saldo) . " — te faltan *\$" . number_format($falta) . "*\n\n¿Cómo quieres pagar?", $buttons ); } } private function payWithSaldo(string $chatId): void { $state = $this->getState($chatId); $data = $state['data'] ?? []; if (($data['flow'] ?? '') !== 'compra') { $this->showMainMenu($chatId); return; } $tarifa = Tarifas::with('servicio')->find($data['tarifa_id'] ?? 0); $user = User::with('saldo')->find($state['user_id']); if (! $tarifa || ! $user) { $this->send($chatId, "Ocurrió un error. Intenta de nuevo."); return; } $precio = (float) ($data['valor'] ?? CuentasHelper::precio($tarifa->id, $state['user_id'], $tarifa->valor)); $saldo = $user->saldo; if (! $saldo || $saldo->valor < $precio) { $this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). El plan cuesta \$" . number_format($precio) . "."); return; } $cuenta = CuentasHelper::buscar($tarifa); if (! $cuenta) { $this->send($chatId, "No hay cuentas disponibles en este momento. Intenta más tarde."); return; } $nuevoSaldo = $saldo->valor - $precio; DB::transaction(function () use ($tarifa, $user, $saldo, $cuenta, $precio, $nuevoSaldo, $state) { $historial = Historiale::create([ 'fecha_inicio' => now(), 'fecha_final' => now()->addDays($tarifa->dias), 'valor' => $precio, 'utilidad' => $tarifa->utilidad ?? 0, 'tipo_pago' => 'saldo', 'estado' => 'entregado', 'vendedor_id' => $state['user_id'], 'tarifa_id' => $tarifa->id, 'cliente_id' => $state['user_id'], 'nombre_cliente' => $user->name, ]); Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]); $cuenta->update(['estado' => 'ocupado']); $saldo->update(['valor' => $nuevoSaldo]); }); $this->send($chatId, "🎉 *¡Muchas gracias por tu compra!*\n\n" . "Disfruta tu servicio. Si tienes algún inconveniente no dudes en escribirnos, estamos aquí para ayudarte. 😊" ); $code = "```\n"; $code .= "Servicio: {$tarifa->servicio->nombre}\n"; $code .= "Email: {$cuenta->correo}\n"; $code .= "Password: {$cuenta->password}\n"; if ($tarifa->pantallas) { $code .= "Perfil: {$tarifa->pantallas}\n"; } $code .= "Vence: " . now()->addDays($tarifa->dias)->format('d/m/Y') . "\n"; $code .= "Pagado: \$" . number_format($precio) . "\n"; $code .= "```"; $this->send($chatId, $code); $state['data'] = []; $this->setState($chatId, $state); $this->showMainMenu($chatId); } // ─── Direct Bre-B purchase (sin saldo previo) ──────────── private function startDirectBrebPayment(string $chatId, int $tarifaId, int $valor): void { $state = $this->getState($chatId); $tarifa = Tarifas::with('servicio')->find($tarifaId); if (! $tarifa) { $this->send($chatId, "Plan no encontrado."); return; } $state['data']['pending_purchase'] = [ 'type' => 'tarifa', 'tarifa_id' => $tarifaId, 'valor' => $valor, 'servicio' => $tarifa->servicio->nombre, 'servicio_id' => $tarifa->servicio_id, ]; $this->setState($chatId, $state); $this->askRemitenteNombre($chatId, $valor); } private function startDirectBrebPromo(string $chatId, int $promoId, int $precio): void { $state = $this->getState($chatId); $promo = \App\Models\Promociones::find($promoId); if (! $promo) { $this->send($chatId, "Promoción no encontrada."); return; } $state['data']['pending_purchase'] = [ 'type' => 'promo', 'promo_id' => $promoId, 'valor' => $precio, 'nombre' => $promo->nombre ?? 'Promoción', ]; $this->setState($chatId, $state); $this->askRemitenteNombre($chatId, $precio); } // ─── Promos ─────────────────────────────────────────────── private function listPromos(string $chatId): void { $state = $this->getState($chatId); $rolId = isset($state['user_id']) ? (User::find($state['user_id'])?->rol_id ?? Role::where('nombre', 'cliente')->value('id')) : Role::where('nombre', 'cliente')->value('id'); $promos = Promociones::where('visible', 'true') ->where(fn ($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now())) ->with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)]) ->get(); if ($promos->isEmpty()) { $this->send($chatId, "No hay promociones activas en este momento."); $this->showMainMenu($chatId); return; } $text = "🎁 *Promociones disponibles:*\n\n"; $buttons = []; foreach ($promos as $p) { $upric = isset($state['user_id']) ? Usuario_promo::where('usuario_id', $state['user_id'])->where('promocion_id', $p->id)->first() : null; $tp = $p->tarifaPromo->first(); $precio = $upric ? $upric->precio : ($tp ? $tp->precio : $p->precio); $text .= "*{$p->nombre}* — \$" . number_format($precio) . "\n"; if ($p->descripcion) { $text .= "{$p->descripcion}\n"; } if ($p->fecha_limite) { $text .= "Hasta: " . Carbon::parse($p->fecha_limite)->format('d/m/Y') . "\n"; } $text .= "\n"; $buttons[] = [['text' => ($p->nombre ?? 'Promo') . ' — $' . number_format($precio), 'callback_data' => 'promo_view|' . $p->id]]; } $buttons[] = [['text' => '🔙 Menú principal', 'callback_data' => 'menu']]; $this->sendWithKeyboard($chatId, $text, $buttons); } private function viewPromo(string $chatId, int $promoId): void { $state = $this->getState($chatId); $rolId = isset($state['user_id']) ? (User::find($state['user_id'])?->rol_id ?? Role::where('nombre', 'cliente')->value('id')) : Role::where('nombre', 'cliente')->value('id'); $promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId); if (! $promo) { $this->send($chatId, "Promoción no encontrada."); return; } $user = isset($state['user_id']) ? User::with('saldo')->find($state['user_id']) : null; $saldo = $user?->saldo?->valor ?? 0; $upric = isset($state['user_id']) ? Usuario_promo::where('usuario_id', $state['user_id'])->where('promocion_id', $promoId)->first() : null; $tp = $promo->tarifaPromo->first(); $precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio); $buttons = []; if ($saldo >= $precio) { $buttons[] = [['text' => "💳 Pagar con saldo (\$" . number_format($precio) . ")", 'callback_data' => 'promo_saldo|' . $promoId]]; $buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']]; $this->sendWithKeyboard( $chatId, "*{$promo->nombre}*\n\$" . number_format($precio) . "\n\nElige cómo pagar:", $buttons ); } else { $falta = $precio - $saldo; $state = $this->getState($chatId); $state['data']['pending_purchase'] = [ 'type' => 'promo', 'promo_id' => $promoId, 'valor' => $precio, 'nombre' => $promo->nombre ?? 'Promoción', ]; $this->setState($chatId, $state); $buttons[] = [['text' => "💳 Pagar con Bre-B directo (\$" . number_format($precio) . ")", 'callback_data' => 'breb_promo|' . $promoId . '|' . $precio]]; $buttons[] = [['text' => "💰 Recargar saldo (\$" . number_format($falta) . " faltantes)", 'callback_data' => 'rec_init']]; $buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']]; $this->sendWithKeyboard( $chatId, "*{$promo->nombre}*\n\$" . number_format($precio) . "\n\nSaldo actual: \$" . number_format($saldo) . " — te faltan *\$" . number_format($falta) . "*\n\n¿Cómo quieres pagar?", $buttons ); } } private function buyPromoSaldo(string $chatId, int $promoId): void { $state = $this->getState($chatId); $user = isset($state['user_id']) ? User::with('saldo')->find($state['user_id']) : null; if (! $user) { $this->send($chatId, "No se pudo procesar la compra."); return; } $rolId = $user->rol_id; $promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId); if (! $promo) { $this->send($chatId, "No se pudo procesar la compra."); return; } $upric = Usuario_promo::where('usuario_id', $user->id)->where('promocion_id', $promoId)->first(); $tp = $promo->tarifaPromo->first(); $precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio); $utilidad = $upric ? $upric->utilidad : ($tp ? $tp->utilidad : 0); $saldo = $user->saldo; if (! $saldo || $saldo->valor < $precio) { $this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). La promo cuesta \$" . number_format($precio) . "."); return; } $cuenta = Cuentas::where('promocion_id', $promoId)->where('estado', 'activo')->first(); if (! $cuenta) { $this->send($chatId, "No hay cuentas disponibles para esta promoción."); return; } $dias = (int) ($promo->dias ?? 30); $nuevoSaldo = $saldo->valor - $precio; DB::transaction(function () use ($promo, $user, $saldo, $cuenta, $precio, $utilidad, $promoId, $dias, $nuevoSaldo) { $historial = Historiale::create([ 'fecha_inicio' => now(), 'fecha_final' => now()->addDays($dias), 'valor' => $precio, 'utilidad' => $utilidad, 'tipo_pago' => 'saldo', 'estado' => 'entregado', 'vendedor_id' => $user->id, 'promocion_id' => $promoId, 'cliente_id' => $user->id, 'nombre_cliente' => $user->name, ]); Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]); $cuenta->update(['estado' => 'ocupado']); $saldo->update(['valor' => $nuevoSaldo]); }); $this->send($chatId, "🎉 *¡Muchas gracias por tu compra!*\n\n" . "Disfruta tu servicio. Si tienes algún inconveniente no dudes en escribirnos, estamos aquí para ayudarte. 😊" ); $code = "```\n"; $code .= "Promoción: {$promo->nombre}\n"; $code .= "Email: {$cuenta->correo}\n"; $code .= "Password: {$cuenta->password}\n"; $code .= "Vence: " . now()->addDays($dias)->format('d/m/Y') . "\n"; $code .= "Pagado: \$" . number_format($precio) . "\n"; $code .= "```"; $this->send($chatId, $code); $this->showMainMenu($chatId); } // ─── Recharge ───────────────────────────────────────────── private function showRechargeAmounts(string $chatId): void { $buttons = [ [ ['text' => '$20.000', 'callback_data' => 'rec_amount|20000'], ['text' => '$50.000', 'callback_data' => 'rec_amount|50000'], ], [ ['text' => '$100.000', 'callback_data' => 'rec_amount|100000'], ['text' => '$200.000', 'callback_data' => 'rec_amount|200000'], ], [ ['text' => '$500.000', 'callback_data' => 'rec_amount|500000'], ['text' => '✏️ Otro valor', 'callback_data' => 'rec_custom'], ], [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ]; $this->sendWithKeyboard($chatId, "💰 *¿Cuánto quieres recargar?*", $buttons); } private function askCustomAmount(string $chatId): void { $state = $this->getState($chatId); $state['step'] = 'await_amount'; $this->setState($chatId, $state); $this->send($chatId, "Escribe el monto que deseas recargar (mínimo \$1.000):"); } private function processCustomAmount(string $chatId, string $text): void { $monto = (int) preg_replace('/\D/', '', $text); if ($monto < 1000) { $this->send($chatId, "El monto debe ser al menos \$1.000. Escríbelo de nuevo:"); return; } $state = $this->getState($chatId); $state['step'] = 'menu'; $this->setState($chatId, $state); $this->chooseRechargeMethod($chatId, $monto); } private function chooseRechargeMethod(string $chatId, int $monto): void { if ($monto <= 0) { return; } $llave = ChatConfig::get('recarga_banco_llave', ''); if (! $llave) { $this->sendWithKeyboard($chatId, "⚠️ La recarga no está disponible en este momento. Contacta a un asesor.", [ [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ]); return; } $buttons = [ [['text' => '🔑 Bre-B', 'callback_data' => 'rec_breb|' . $monto]], [['text' => '🔙 Volver', 'callback_data' => 'rec_init']], ]; $this->sendWithKeyboard( $chatId, "Recarga de *\$" . number_format($monto) . "*\n\n¿Cómo quieres pagar?", $buttons ); } private function askRemitenteNombre(string $chatId, int $monto): void { $state = $this->getState($chatId); $state['data']['monto'] = $monto; $this->setState($chatId, $state); $userId = $state['user_id'] ?? null; $nombresPrevios = $userId ? SolicitudRecarga::nombresPrevios((int) $userId) : []; if (empty($nombresPrevios)) { $state['step'] = 'await_remitente_nombre'; $this->setState($chatId, $state); $this->send($chatId, "👤 Escribe tu *nombre completo* tal como aparece en tu cuenta bancaria o billetera:"); return; } // Guardar nombres en state por índice (callback_data tiene límite de 64 chars) $state['data']['nombres_previos'] = $nombresPrevios; $this->setState($chatId, $state); $keyboard = []; foreach ($nombresPrevios as $i => $nombre) { $keyboard[] = [['text' => $nombre, 'callback_data' => "breb_nom|{$i}"]]; } $keyboard[] = [['text' => '✏️ Usar otro nombre', 'callback_data' => 'breb_nom|nuevo']]; $this->sendWithKeyboard($chatId, "👤 ¿Desde qué cuenta vas a transferir?\nElige un nombre anterior o escribe uno nuevo:", $keyboard); } private function seleccionarNombreRemitente(string $chatId, string $indiceONuevo): void { $state = $this->getState($chatId); $monto = (int) ($state['data']['monto'] ?? 0); if ($monto <= 0) { $this->send($chatId, "Ocurrió un error. Por favor inicia el proceso de recarga de nuevo."); $this->showMainMenu($chatId); return; } if ($indiceONuevo === 'nuevo') { $state['step'] = 'await_remitente_nombre'; $this->setState($chatId, $state); $this->send($chatId, "👤 Escribe tu *nombre completo* tal como aparece en tu cuenta bancaria o billetera:"); return; } $nombres = $state['data']['nombres_previos'] ?? []; $nombre = $nombres[(int) $indiceONuevo] ?? null; if (! $nombre) { $state['step'] = 'await_remitente_nombre'; $this->setState($chatId, $state); $this->send($chatId, "No encontré ese nombre. Escríbelo de nuevo:"); return; } $state['step'] = 'menu'; $state['data']['nombre_remitente'] = $nombre; $this->setState($chatId, $state); $this->rechargeBreb($chatId, $monto); } private function processRemitenteNombre(string $chatId, string $texto): void { $nombre = trim($texto); $palabras = array_filter(explode(' ', $nombre), fn ($p) => strlen($p) >= 2); if (count($palabras) < 2) { $this->send($chatId, "Escribe al menos nombre y apellido. Ejemplo: *Juan Garcia*"); return; } $state = $this->getState($chatId); $monto = (int) ($state['data']['monto'] ?? 0); if ($monto <= 0) { $this->send($chatId, "Ocurrió un error. Por favor inicia el proceso de recarga de nuevo."); $this->showMainMenu($chatId); return; } $state['step'] = 'menu'; $state['data']['nombre_remitente'] = $nombre; $this->setState($chatId, $state); $this->rechargeBreb($chatId, $monto); } private function rechargeBreb(string $chatId, int $monto): void { $banco = ChatConfig::get('recarga_banco_nombre', ''); $llave = ChatConfig::get('recarga_banco_llave', ''); $titular = ChatConfig::get('recarga_banco_titular', ''); if (! $llave) { $this->send($chatId, "El pago por Bre-B no está disponible en este momento."); return; } $text = "🏧 *Pago por Bre-B*\n\n"; $text .= "Monto: *\$" . number_format($monto) . "*\n\n"; if ($banco) { $text .= "Banco/Billetera: *{$banco}*\n"; } $text .= "Llave: `{$llave}`\n"; if ($titular) { $text .= "Titular: *{$titular}*\n"; } $text .= "\n📋 *Al enviar el comprobante, asegúrate de que se vea claramente:*\n" . "✓ Tu nombre completo\n" . "✓ El valor transferido (\$" . number_format($monto) . ")\n" . "✓ La fecha y hora de la transacción\n\n" . "⚠️ Después de transferir, envía la *foto del comprobante* aquí y el sistema lo validará automáticamente." . "\n_Tienes hasta 15 minutos después de la transferencia para enviar el soporte._"; 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); $state['data']['solicitud_recarga_id'] = $solicitud->id; $this->setState($chatId, $state); } $this->sendWithKeyboard($chatId, $text, [ [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], ]); } // ─── Credentials ────────────────────────────────────────── private function showCredentials(string $chatId): void { $state = $this->getState($chatId); $historiales = Historiale::where('cliente_id', $state['user_id']) ->where('estado', 'entregado') ->where('fecha_final', '>=', now()) ->with(['tarifa.servicio', 'cuentas']) ->orderBy('fecha_final', 'desc') ->limit(10) ->get(); if ($historiales->isEmpty()) { $this->send($chatId, "No tienes planes activos en este momento."); $this->showMainMenu($chatId); return; } $this->send($chatId, "🔑 *Tus planes activos:*"); foreach ($historiales as $h) { foreach ($h->cuentas as $c) { $text = "🎬 *" . ($h->tarifa?->servicio?->nombre ?? 'Servicio') . "*\n"; $text .= "📧 Email: `{$c->correo}`\n"; $text .= "🔑 Password: `{$c->password}`\n"; if ($h->tarifa?->pantallas) { $text .= "📺 Perfil: {$h->tarifa->pantallas}\n"; } $text .= "📅 Vence: " . Carbon::parse($h->fecha_final)->format('d/m/Y'); $this->send($chatId, $text); } } $this->sendWithKeyboard($chatId, "‌", [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]); } // ─── History ────────────────────────────────────────────── private function showHistory(string $chatId): void { $state = $this->getState($chatId); $historiales = Historiale::where('cliente_id', $state['user_id']) ->with('tarifa.servicio') ->orderBy('created_at', 'desc') ->limit(10) ->get(); if ($historiales->isEmpty()) { $this->send($chatId, "No tienes compras registradas."); $this->showMainMenu($chatId); return; } $text = "📋 *Tus últimas compras:*\n\n"; foreach ($historiales as $h) { $activo = Carbon::parse($h->fecha_final)->gte(now()) ? '✅' : '❌'; $nombre = $h->tarifa?->servicio?->nombre ?? ($h->promocion_id ? 'Promo' : 'Plan'); $fecha = Carbon::parse($h->created_at)->format('d/m/Y'); $text .= "{$activo} {$nombre} — \$" . number_format($h->valor) . " ({$fecha})\n"; } $this->sendWithKeyboard($chatId, $text, [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]); } // ─── Profile ────────────────────────────────────────────── private function showProfile(string $chatId): void { $state = $this->getState($chatId); $user = User::with('saldo')->find($state['user_id']); $text = "👤 *Tu perfil*\n\n"; $text .= "Nombre: *{$user->name}*\n"; $text .= "Email: {$user->email}\n"; if ($user->celular) { $text .= "Celular: {$user->celular}\n"; } $text .= "Saldo: *\$" . number_format($user->saldo?->valor ?? 0) . "*"; $this->sendWithKeyboard($chatId, $text, [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]); } // ─── Agent ──────────────────────────────────────────────── private function transferToAgent(string $chatId): void { $state = $this->getState($chatId); if ($state['conv_id'] ?? null) { ChatConversation::where('id', $state['conv_id'])->update(['estado' => 'agente']); } $this->send($chatId, ChatConfig::get('mensaje_transferencia', 'Un asesor se comunicará contigo en breve. Por favor espera.')); } // ─── Apply validated payment ────────────────────────────── private function applyPayment(string $chatId, int $monto, ?string $ref): void { $state = $this->getState($chatId); if (! ($state['user_id'] ?? null) || $monto <= 0) { $this->send($chatId, "No se pudo aplicar la recarga."); return; } $user = User::with('saldo')->find($state['user_id']); $saldo = $user->saldo ?? Saldo::create(['usuario_id' => $state['user_id'], 'valor' => 0]); $nuevoSaldo = $saldo->valor + $monto; $saldo->update(['valor' => $nuevoSaldo]); $this->send( $chatId, "✅ Recarga de *\$" . number_format($monto) . "* aplicada.\nNuevo saldo: *\$" . number_format($nuevoSaldo) . "*" ); $this->showMainMenu($chatId); } // ─── Free text fallback ─────────────────────────────────── private function detectarPorKeywords(string $texto): ?string { // Solo intents genéricos sin parámetros — los específicos (con nombre de servicio o monto) // los maneja GeminiAgentService para extraer parámetros y responder conversacionalmente. $patrones = [ 'recarga.iniciar' => '/\b(recargar?|recarg[ao]|cargar\s+saldo|poner\s+plata|depositar|agregar\s+plata|meter\s+plata)\b/i', 'promo.listar' => '/\b(promo|oferta|combo|descuento)\b/i', 'credenciales.listar' => '/\b(credencial|contrase[ñn]a)\b|no\s+(puedo|pude)\s+entrar|no\s+(inicia|está\s+iniciando|abre|entra|carga|funciona|sirve|jala)\s+(sesi[oó]n|netflix|spotify|disney|hbo|max|prime|crunchyroll|la\s+cuenta|el\s+servicio|el\s+acceso)|error\s+de\s+(acceso|inicio|sesi[oó]n)|mis\s+datos\s+de/i', 'historial.ver' => '/\b(historial|mis\s+compras?|mis\s+pedidos?)\b/i', 'perfil.ver' => '/\bmi\s+perfil\b/i', ]; foreach ($patrones as $accion => $patron) { if (preg_match($patron, $texto)) { return $accion; } } return null; } private function handleFreeText(string $chatId, array $state): void { if (! ($state['user_id'] ?? null)) { $this->askEmail($chatId); return; } $texto = $state['data']['last_text'] ?? ''; // Detección rápida para intents genéricos; extrae monto si viene en el texto if ($texto) { $accionLocal = $this->detectarPorKeywords($texto); if ($accionLocal) { $accionData = []; if ($accionLocal === 'recarga.iniciar') { if (preg_match('/\$?\s*(\d{1,3}(?:[.,]\d{3})+|\d{4,})\b/', $texto, $m)) { $monto = (int) preg_replace('/[.,]/', '', $m[1]); if ($monto >= 1000) { $accionData['monto'] = $monto; } } } $this->dispatchAction($chatId, $accionLocal, $state, $accionData); return; } } // Agente conversacional: entiende lenguaje natural, extrae parámetros y responde if ($texto) { $contexto = GeminiAgentService::buildContexto($state, $state['user_id'] ?? null); $resultado = app(GeminiAgentService::class)->responder( $texto, $contexto, $state['user_id'] ?? null, 'telegram' ); if ($resultado['respuesta']) { $this->send($chatId, $resultado['respuesta']); } if ($resultado['accion']) { $this->dispatchAction($chatId, $resultado['accion'], $state, $resultado['accion_data']); return; } if ($resultado['respuesta']) { return; // El agente respondió la pregunta, sin acción que ejecutar } } $this->showMainMenu($chatId); } private function dispatchAction(string $chatId, string $action, array $state, array $accionData = []): void { match ($action) { 'servicios.listar' => isset($accionData['servicio']) ? $this->viewServiceByName($chatId, (string) $accionData['servicio']) : $this->listServices($chatId), 'recarga.iniciar' => isset($accionData['monto']) && (int) $accionData['monto'] > 0 ? $this->chooseRechargeMethod($chatId, (int) $accionData['monto']) : $this->showRechargeAmounts($chatId), 'promo.listar' => $this->listPromos($chatId), 'credenciales.listar' => $this->showCredentials($chatId), 'historial.ver' => $this->showHistory($chatId), 'perfil.ver' => $this->showProfile($chatId), 'asesor.solicitar' => $this->showMainMenu($chatId), default => $this->showMainMenu($chatId), }; } private function viewServiceByName(string $chatId, string $nombre): void { $servicio = Servicio::whereRaw('LOWER(nombre) LIKE ?', ['%' . strtolower($nombre) . '%']) ->where('estado', 'activo') ->first(); $servicio ? $this->viewService($chatId, $servicio->id) : $this->listServices($chatId); } // ─── State management ───────────────────────────────────── private function getState(string $chatId): array { return Cache::get("tgbot_state_{$chatId}", ['step' => 'await_email', 'data' => []]); } private function setState(string $chatId, array $state): void { Cache::put("tgbot_state_{$chatId}", $state, now()->addSeconds(self::STATE_TTL)); } // ─── Telegram API ───────────────────────────────────────── public function registrarComandos(): bool { return $this->apiCall('setMyCommands', [ 'commands' => [ ['command' => 'start', 'description' => 'Iniciar / Menú principal'], ['command' => 'menu', 'description' => 'Ver el menú principal'], ['command' => 'salir', 'description' => 'Volver al menú desde cualquier punto'], ['command' => 'cancelar','description' => 'Cancelar la acción actual'], ], ]); } public function send(string $chatId, string $text): bool { if ($this->convId) { ChatMessage::create([ 'conversation_id' => $this->convId, 'tipo' => 'bot', 'contenido' => $text, 'leido' => true, ]); } return $this->apiCall('sendMessage', [ 'chat_id' => $chatId, 'text' => $text, 'parse_mode' => 'Markdown', ]); } public function sendWithKeyboard(string $chatId, string $text, array $inlineKeyboard): bool { if ($this->convId) { ChatMessage::create([ 'conversation_id' => $this->convId, 'tipo' => 'bot', 'contenido' => $text, 'leido' => true, ]); } return $this->apiCall('sendMessage', [ 'chat_id' => $chatId, 'text' => $text ?: '​', // zero-width space fallback 'parse_mode' => 'Markdown', 'reply_markup' => json_encode(['inline_keyboard' => $inlineKeyboard]), ]); } private function answerCallback(string $callbackQueryId): void { $this->apiCall('answerCallbackQuery', ['callback_query_id' => $callbackQueryId]); } private function apiCall(string $method, array $data): bool { $token = ChatConfig::get('telegram_token'); if (! $token) { return false; } try { $response = Http::timeout(10) ->post("https://api.telegram.org/bot{$token}/{$method}", $data); return $response->successful(); } catch (\Throwable $e) { Log::warning("[TelegramBot] apiCall {$method} failed: " . $e->getMessage()); return false; } } private function downloadTelegramFile(string $fileId): ?array { $token = ChatConfig::get('telegram_token'); if (! $token) { return null; } try { $info = Http::timeout(10)->get("https://api.telegram.org/bot{$token}/getFile", ['file_id' => $fileId]); $filePath = $info->json('result.file_path'); if (! $filePath) { return null; } $content = Http::timeout(30)->get("https://api.telegram.org/file/bot{$token}/{$filePath}")->body(); if (! $content) { return null; } $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $mime = match ($ext) { 'png' => 'image/png', 'webp' => 'image/webp', default => 'image/jpeg', }; return ['content' => $content, 'mime' => $mime]; } catch (\Throwable $e) { Log::warning('[TelegramBot] downloadTelegramFile failed: ' . $e->getMessage()); return null; } } // ─── Helpers ────────────────────────────────────────────── private function maskEmail(string $email): string { [$local, $domain] = explode('@', $email, 2); $len = mb_strlen($local); $visible = min(4, $len); // siempre 4 caracteres máximo return mb_substr($local, 0, $visible) . str_repeat('*', max(0, $len - $visible)) . '@' . $domain; } }