diff --git a/app/Http/Controllers/TelegramWebhookController.php b/app/Http/Controllers/TelegramWebhookController.php index 6a1dfb3..ac751f1 100644 --- a/app/Http/Controllers/TelegramWebhookController.php +++ b/app/Http/Controllers/TelegramWebhookController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Services\ChatBotEngine; +use App\Services\TelegramBotService; use Illuminate\Http\Request; class TelegramWebhookController extends Controller @@ -10,24 +10,35 @@ class TelegramWebhookController extends Controller public function handle(Request $request) { $update = $request->all(); + $bot = new TelegramBotService(); - // Solo procesar mensajes de texto - if (! isset($update['message']['text'])) { + // Inline keyboard button taps + if (isset($update['callback_query'])) { + $cq = $update['callback_query']; + $chatId = (string) $cq['message']['chat']['id']; + $bot->handleCallback($chatId, $cq['id'], $cq['data'] ?? ''); return response()->json(['ok' => true]); } - $chatId = (string) $update['message']['chat']['id']; - $text = $update['message']['text']; - $nombre = trim( - ($update['message']['from']['first_name'] ?? '') . ' ' . - ($update['message']['from']['last_name'] ?? '') - ); + // Photos (payment receipts) + if (isset($update['message']['photo'])) { + $chatId = (string) $update['message']['chat']['id']; + $photos = $update['message']['photo']; + $fileId = end($photos)['file_id']; + $bot->handlePhoto($chatId, $fileId); + return response()->json(['ok' => true]); + } - $engine = new ChatBotEngine(); - $replies = $engine->handle('telegram', $chatId, $text, $nombre); - - foreach ($replies as $reply) { - $engine->enviarTelegram($chatId, $reply); + // Text messages + if (isset($update['message']['text'])) { + $chatId = (string) $update['message']['chat']['id']; + $text = $update['message']['text']; + $nombre = trim( + ($update['message']['from']['first_name'] ?? '') . ' ' . + ($update['message']['from']['last_name'] ?? '') + ); + $bot->handleText($chatId, $text, $nombre); + return response()->json(['ok' => true]); } return response()->json(['ok' => true]); diff --git a/app/Http/Livewire/Chat/PublicChat.php b/app/Http/Livewire/Chat/PublicChat.php index ef0640e..bab1bb8 100644 --- a/app/Http/Livewire/Chat/PublicChat.php +++ b/app/Http/Livewire/Chat/PublicChat.php @@ -10,6 +10,7 @@ use App\Models\ChatConversation; use App\Models\ChatMessage; use App\Models\Historiale; use App\Models\Historial_cuenta; +use App\Models\Preferencial; use App\Models\Promociones; use App\Models\recarga; use App\Models\Saldo; @@ -495,7 +496,11 @@ class PublicChat extends Component private function listarServicios(): void { - $servicios = Servicio::where('estado', 'activo')->orderBy('ubicacion')->get(); + $rolId = $this->userId ? User::find($this->userId)?->rol_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->guardarMensajeBot($this->convId, "No hay servicios disponibles en este momento."); @@ -503,36 +508,36 @@ class PublicChat extends Component return; } - $this->guardarMensajeBot($this->convId, "Selecciona el servicio:"); + $cards = $servicios->map(fn ($s) => [ + 'imagen' => $s->img_inicio, + 'titulo' => $s->nombre, + 'detalle' => $s->por_tiempo ? 'Por tiempo' : 'Por pantallas', + 'accion' => ['label' => 'Ver planes', 'action' => 'servicios.ver', 'data' => ['id' => $s->id]], + ])->values()->all(); - foreach ($servicios as $s) { - ChatMessage::create([ - 'conversation_id' => $this->convId, - 'tipo' => 'bot', - 'tipo_ui' => 'card', - 'contenido' => '', - 'payload' => [ - 'imagen' => $s->img_inicio, - 'titulo' => $s->nombre, - 'detalle' => $s->por_tiempo ? 'Por tiempo' : 'Por pantallas', - 'accion' => ['label' => 'Ver planes', 'action' => 'servicios.ver', 'data' => ['id' => $s->id]], - ], - 'leido' => true, - ]); - } + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'cards_slider', + 'contenido' => 'Selecciona el servicio:', + 'payload' => ['cards' => $cards], + 'leido' => true, + ]); $this->volver('menu.principal'); } private function verServicio(int $id): void { - $servicio = Servicio::with('tarifas')->find($id); + $rolId = $this->userId ? User::find($this->userId)?->rol_id : Role::where('nombre', 'cliente')->value('id'); + $servicio = Servicio::with(['tarifas' => fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId)])->find($id); + if (! $servicio) { $this->guardarMensajeBot($this->convId, "Servicio no encontrado."); return; } - $tarifas = $servicio->tarifas->where('estado', 'activo'); + $tarifas = $servicio->tarifas; if ($tarifas->isEmpty()) { $this->guardarMensajeBot($this->convId, "No hay planes para {$servicio->nombre} en este momento."); @@ -542,9 +547,10 @@ class PublicChat extends Component $botones = []; foreach ($tarifas as $t) { - $label = $servicio->por_tiempo - ? "{$t->dias} dias - $" . number_format($t->valor) - : "{$t->pantallas} pantalla(s) / {$t->dias} dias - $" . number_format($t->valor); + $precio = $this->efectivePrice($t->id, $this->userId, $t->valor); + $label = $servicio->por_tiempo + ? "{$t->dias} dias - $" . number_format($precio) + : "{$t->pantallas} pantalla(s) / {$t->dias} dias - $" . number_format($precio); $botones[] = ['label' => $label, 'action' => 'compra.iniciar', 'data' => ['tarifa_id' => $t->id]]; } $botones[] = ['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []]; @@ -578,10 +584,12 @@ class PublicChat extends Component return; } + $precio = $this->efectivePrice($tarifaId, $this->userId, $tarifa->valor); + $this->flujo = 'compra'; $this->flujoData = [ 'tarifa_id' => $tarifaId, - 'valor' => $tarifa->valor, + 'valor' => $precio, 'servicio' => $tarifa->servicio->nombre, 'dias' => $tarifa->dias, 'pantallas' => $tarifa->pantallas, @@ -590,15 +598,15 @@ class PublicChat extends Component $detalle = $tarifa->servicio->por_tiempo ? "{$tarifa->dias} dias" : "{$tarifa->pantallas} pantalla(s) / {$tarifa->dias} dias"; - $saldoInsuficiente = ! $this->userId || ($this->saldoUsuario < $tarifa->valor); + $saldoInsuficiente = ! $this->userId || ($this->saldoUsuario < $precio); ChatMessage::create([ 'conversation_id' => $this->convId, 'tipo' => 'bot', 'tipo_ui' => 'buttons', - 'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($tarifa->valor) . "\n\nElige como pagar:", + 'contenido' => "{$tarifa->servicio->nombre} - {$detalle}\nPrecio: $" . number_format($precio) . "\n\nElige como pagar:", 'payload' => ['botones' => [ - ['label' => 'Saldo ($' . number_format($tarifa->valor) . ')', + ['label' => 'Saldo ($' . number_format($precio) . ')', 'action' => 'compra.pagar.saldo', 'data' => [], 'disabled' => $saldoInsuficiente], @@ -716,7 +724,7 @@ class PublicChat extends Component private function listarPromociones(): void { $promos = Promociones::where('visible', true) - ->where(fn($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now())) + ->where(fn ($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now())) ->get(); if ($promos->isEmpty()) { @@ -725,25 +733,23 @@ class PublicChat extends Component return; } - $this->guardarMensajeBot($this->convId, "Promociones disponibles:"); + $cards = $promos->map(fn ($p) => [ + 'imagen' => $p->img_publicidad, + 'titulo' => $p->nombre ?? 'Promocion especial', + 'descripcion' => $p->descripcion ?? null, + 'precio' => $p->precio, + 'detalle' => $p->fecha_limite ? 'Hasta ' . Carbon::parse($p->fecha_limite)->format('d/m/Y') : null, + 'accion' => ['label' => 'Comprar', 'action' => 'promo.ver', 'data' => ['id' => $p->id]], + ])->values()->all(); - foreach ($promos as $p) { - ChatMessage::create([ - 'conversation_id' => $this->convId, - 'tipo' => 'bot', - 'tipo_ui' => 'card', - 'contenido' => '', - 'payload' => [ - 'imagen' => $p->img_publicidad, - 'titulo' => $p->nombre ?? 'Promocion especial', - 'descripcion' => $p->descripcion ?? null, - 'precio' => $p->precio, - 'detalle' => $p->fecha_limite ? 'Hasta ' . Carbon::parse($p->fecha_limite)->format('d/m/Y') : null, - 'accion' => ['label' => 'Comprar', 'action' => 'promo.ver', 'data' => ['id' => $p->id]], - ], - 'leido' => true, - ]); - } + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'cards_slider', + 'contenido' => 'Promociones disponibles:', + 'payload' => ['cards' => $cards], + 'leido' => true, + ]); $this->volver('menu.principal'); } @@ -1233,6 +1239,17 @@ class PublicChat extends Component ]); } + private function efectivePrice(int $tarifaId, ?int $userId, float $base): float + { + if ($userId) { + $pref = Preferencial::where('usuario_id', $userId)->where('tarifa_id', $tarifaId)->first(); + if ($pref) { + return (float) $pref->valor; + } + } + return (float) $base; + } + private function volver(string $action): void { ChatMessage::create([ diff --git a/app/Services/TelegramBotService.php b/app/Services/TelegramBotService.php new file mode 100644 index 0000000..16f6ab6 --- /dev/null +++ b/app/Services/TelegramBotService.php @@ -0,0 +1,1128 @@ +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()]); + + if ($conv->estado === 'agente') { + return; // Human agent is handling this + } + } + } + + $lower = strtolower($text); + if (in_array($lower, ['/start', '/menu', 'menu', 'inicio', 'hola'])) { + if ($state['user_id'] ?? null) { + $this->showMainMenu($chatId); + } else { + $this->askEmail($chatId); + } + return; + } + + $step = $state['step'] ?? 'await_email'; + + match ($step) { + 'await_email' => $this->processEmail($chatId, $text, $nombre), + 'await_otp' => $this->processOtp($chatId, $text), + 'await_nombre' => $this->processNombre($chatId, $text), + 'await_cedula' => $this->processCedula($chatId, $text), + 'await_celular'=> $this->processCelular($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), + '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), + 'buy_mp' => $this->payWithMP($chatId), + 'promo_list' => $this->listPromos($chatId), + 'promo_view' => $this->viewPromo($chatId, (int) ($parts[1] ?? 0)), + 'promo_saldo' => $this->buyPromoSaldo($chatId, (int) ($parts[1] ?? 0)), + 'promo_mp' => $this->buyPromoMP($chatId, (int) ($parts[1] ?? 0)), + 'rec_init' => $this->showRechargeAmounts($chatId), + 'rec_amount' => $this->chooseRechargeMethod($chatId, (int) ($parts[1] ?? 0)), + 'rec_mp' => $this->rechargeMP($chatId, (int) ($parts[1] ?? 0)), + 'rec_breb' => $this->rechargeBreb($chatId, (int) ($parts[1] ?? 0)), + 'creds' => $this->showCredentials($chatId), + 'history' => $this->showHistory($chatId), + 'profile' => $this->showProfile($chatId), + 'agent' => $this->transferToAgent($chatId), + 'pay_apply' => $this->applyPayment($chatId, (int) ($parts[1] ?? 0), $parts[2] ?? null), + 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. Contacta a un asesor."); + 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); + + if (! $datosPago) { + $this->send($chatId, "No pude leer el comprobante. Intenta con una foto más clara."); + return; + } + + $resultado = app(PagoValidadorService::class)->validar($datosPago); + $monto = number_format($datosPago['valor'] ?? 0); + $ref = $datosPago['referencia'] ?? 'N/A'; + + $texto = "📊 *Resultado del análisis:*\n\n"; + $texto .= "Monto: *\$$monto*\n"; + $texto .= "Referencia: `{$ref}`\n"; + $texto .= "Estado: *" . ($resultado['estado'] === 'confirmado' ? '✅ Confirmado' : '⚠️ No confirmado') . "*"; + + if ($resultado['estado'] === 'confirmado') { + $buttons = [ + [['text' => "✅ Aplicar recarga de \$$monto", 'callback_data' => 'pay_apply|' . ($datosPago['valor'] ?? 0) . '|' . $ref]], + [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], + ]; + } else { + $buttons = [ + [['text' => '🧑 Hablar con asesor', 'callback_data' => 'agent']], + [['text' => '🔙 Menú principal', 'callback_data' => 'menu']], + ]; + } + + $this->sendWithKeyboard($chatId, $texto, $buttons); + } catch (\Throwable $e) { + Log::warning('[TelegramBot] Error procesando foto: ' . $e->getMessage()); + $this->send($chatId, "Error analizando el comprobante. Por favor intenta de nuevo."); + } + } + + // ─── 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 + { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->send($chatId, "❌ Eso no parece un correo válido. Escríbelo de nuevo:"); + return; + } + + $user = User::where('email', $email)->first(); + + 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; + } + + $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); + } + + 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)); + + try { + Mail::to($user->email)->send(new ChatOtpMail($codigo, $user->name)); + } catch (\Throwable $e) { + Log::warning('[TelegramOTP] Error enviando correo: ' . $e->getMessage()); + } + + $this->setState($chatId, [ + 'step' => 'await_otp', + 'contact_id' => $contact->id, + 'data' => [], + ]); + + $masked = $this->maskEmail($user->email); + $this->send($chatId, "✉️ Te enviamos un código a *{$masked}*.\n\nEscribe el código de 6 dígitos:"); + } + + 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_cedula'; + $this->setState($chatId, $state); + $this->send($chatId, "Tu número de cédula (o escribe /omitir para saltarlo):"); + } + + private function processCedula(string $chatId, string $text): void + { + $state = $this->getState($chatId); + $state['data']['cedula'] = strtolower($text) === '/omitir' ? null : $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 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' => '🧑 Hablar con asesor', 'callback_data' => 'agent'], + ], + ]); + } + + // ─── Services ───────────────────────────────────────────── + + private function listServices(string $chatId): void + { + $servicios = Servicio::where('estado', 'activo')->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 = $this->efectivePrice($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; + } + + $disponibles = Cuentas::where('tarifa_id', $tarifaId)->where('estado', 'activo')->count(); + if ($disponibles < 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 = $this->efectivePrice($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' => '🏦 MercadoPago', 'callback_data' => 'buy_mp']]; + $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 + ); + } + + 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; + } + + $saldo = $user->saldo; + if (! $saldo || $saldo->valor < $tarifa->valor) { + $this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). El plan cuesta \$" . number_format($tarifa->valor) . "."); + return; + } + + $cuenta = Cuentas::where('tarifa_id', $tarifa->id)->where('estado', 'activo')->first(); + if (! $cuenta) { + $this->send($chatId, "No hay cuentas disponibles en este momento. Intenta más tarde."); + return; + } + + $historial = Historiale::create([ + 'fecha_inicio' => now(), + 'fecha_final' => now()->addDays($tarifa->dias), + 'valor' => $tarifa->valor, + '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']); + $nuevoSaldo = $saldo->valor - $tarifa->valor; + $saldo->update(['valor' => $nuevoSaldo]); + + $text = "✅ *¡Compra exitosa!*\n\n"; + $text .= "🎬 *{$tarifa->servicio->nombre}*\n"; + $text .= "📧 Email: `{$cuenta->correo}`\n"; + $text .= "🔑 Password: `{$cuenta->password}`\n"; + if ($tarifa->pantallas) { + $text .= "📺 Perfil: {$tarifa->pantallas}\n"; + } + $text .= "📅 Vence: " . now()->addDays($tarifa->dias)->format('d/m/Y') . "\n"; + $text .= "\nSaldo restante: *\$" . number_format($nuevoSaldo) . "*"; + + $this->send($chatId, $text); + + $state['data'] = []; + $this->setState($chatId, $state); + $this->showMainMenu($chatId); + } + + private function payWithMP(string $chatId): void + { + $state = $this->getState($chatId); + $data = $state['data'] ?? []; + + if (($data['flow'] ?? '') !== 'compra') { + $this->showMainMenu($chatId); + return; + } + + $valor = (int) ($data['valor'] ?? 0); + $servicio = $data['servicio'] ?? 'Plan streaming'; + $ref = 'CHAT-' . strtoupper(Str::random(12)); + $url = $this->createMPPreference($servicio, $valor, $ref); + + if (! $url) { + $this->send($chatId, "No se pudo generar el link de pago. Intenta de nuevo o contacta a un asesor."); + return; + } + + $this->sendWithKeyboard( + $chatId, + "🏦 *Pago con MercadoPago*\n\n{$servicio} — \$" . number_format($valor) . "\nRef: `{$ref}`\n\n[👉 Pagar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.", + [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]] + ); + + $state['data'] = []; + $this->setState($chatId, $state); + } + + // ─── Promos ─────────────────────────────────────────────── + + private function listPromos(string $chatId): void + { + $promos = Promociones::where('visible', true) + ->where(fn ($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now())) + ->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) { + $text .= "*{$p->nombre}* — \$" . number_format($p->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($p->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); + $promo = Promociones::find($promoId); + + if (! $promo) { + $this->send($chatId, "Promoción no encontrada."); + return; + } + + $user = User::with('saldo')->find($state['user_id']); + $saldo = $user->saldo?->valor ?? 0; + + $buttons = []; + if ($saldo >= $promo->precio) { + $buttons[] = [['text' => "💳 Pagar con saldo (\$" . number_format($promo->precio) . ")", 'callback_data' => 'promo_saldo|' . $promoId]]; + } + $buttons[] = [['text' => '🏦 MercadoPago', 'callback_data' => 'promo_mp|' . $promoId]]; + $buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']]; + + $this->sendWithKeyboard( + $chatId, + "*{$promo->nombre}*\n\$" . number_format($promo->precio) . "\n\nElige cómo pagar:", + $buttons + ); + } + + private function buyPromoSaldo(string $chatId, int $promoId): void + { + $state = $this->getState($chatId); + $promo = Promociones::find($promoId); + $user = User::with('saldo')->find($state['user_id']); + + if (! $promo || ! $user) { + $this->send($chatId, "No se pudo procesar la compra."); + return; + } + + $saldo = $user->saldo; + if (! $saldo || $saldo->valor < $promo->precio) { + $this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). La promo cuesta \$" . number_format($promo->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; + } + + $historial = Historiale::create([ + 'fecha_inicio' => now(), + 'fecha_final' => now()->addDays(30), + 'valor' => $promo->precio, + 'utilidad' => $promo->utilidad ?? 0, + 'tipo_pago' => 'saldo', + 'estado' => 'entregado', + 'vendedor_id' => $state['user_id'], + 'promocion_id' => $promoId, + 'cliente_id' => $state['user_id'], + 'nombre_cliente' => $user->name, + ]); + + Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]); + $cuenta->update(['estado' => 'ocupado']); + $nuevoSaldo = $saldo->valor - $promo->precio; + $saldo->update(['valor' => $nuevoSaldo]); + + $text = "✅ *¡Promo activada!*\n\n"; + $text .= "🎬 *{$promo->nombre}*\n"; + $text .= "📧 Email: `{$cuenta->correo}`\n"; + $text .= "🔑 Password: `{$cuenta->password}`\n"; + $text .= "📅 Vence: " . now()->addDays(30)->format('d/m/Y') . "\n"; + $text .= "\nSaldo restante: *\$" . number_format($nuevoSaldo) . "*"; + + $this->send($chatId, $text); + $this->showMainMenu($chatId); + } + + private function buyPromoMP(string $chatId, int $promoId): void + { + $promo = Promociones::find($promoId); + if (! $promo) { + return; + } + + $ref = 'PROMO-' . strtoupper(Str::random(10)); + $url = $this->createMPPreference($promo->nombre ?? 'Promocion streaming', (int) $promo->precio, $ref); + + if (! $url) { + $this->send($chatId, "No se pudo generar el link de pago. Intenta de nuevo o contacta a un asesor."); + return; + } + + $this->sendWithKeyboard( + $chatId, + "🏦 *Pago con MercadoPago*\n\n{$promo->nombre} — \$" . number_format($promo->precio) . "\nRef: `{$ref}`\n\n[👉 Pagar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.", + [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]] + ); + } + + // ─── Recharge ───────────────────────────────────────────── + + private function showRechargeAmounts(string $chatId): void + { + $buttons = []; + foreach ([20000, 50000, 100000, 200000, 500000] as $m) { + $buttons[] = [['text' => '$' . number_format($m), 'callback_data' => 'rec_amount|' . $m]]; + } + $buttons[] = [['text' => '🔙 Menú principal', 'callback_data' => 'menu']]; + + $this->sendWithKeyboard($chatId, "💰 *¿Cuánto quieres recargar?*", $buttons); + } + + private function chooseRechargeMethod(string $chatId, int $monto): void + { + if ($monto <= 0) { + return; + } + + $llave = ChatConfig::get('recarga_banco_llave', ''); + $buttons = [ + [['text' => '🏦 MercadoPago', 'callback_data' => 'rec_mp|' . $monto]], + ]; + if ($llave) { + $buttons[] = [['text' => '🔑 Bre-B', 'callback_data' => 'rec_breb|' . $monto]]; + } + $buttons[] = [['text' => '🔙 Volver', 'callback_data' => 'rec_init']]; + + $this->sendWithKeyboard( + $chatId, + "Recarga de *\$" . number_format($monto) . "*\n\n¿Cómo quieres pagar?", + $buttons + ); + } + + private function rechargeMP(string $chatId, int $monto): void + { + $state = $this->getState($chatId); + + if ($monto <= 0) { + $this->send($chatId, "Monto inválido."); + return; + } + + $ref = 'REC-' . strtoupper(Str::random(12)); + $url = $this->createMPPreference('Recarga de saldo', $monto, $ref); + + if (! $url) { + $this->send($chatId, "No se pudo generar el link de recarga. Intenta de nuevo o contacta a un asesor."); + return; + } + + if ($state['user_id'] ?? null) { + $user = User::with('saldo')->find($state['user_id']); + $saldo = $user->saldo ?? Saldo::create(['usuario_id' => $state['user_id'], 'valor' => 0]); + recarga::create([ + 'monto' => $monto, + 'reference' => $ref, + 'status' => 'pendiente_mp', + 'usuario_id' => $state['user_id'], + 'saldo_id' => $saldo->id, + ]); + } + + $this->sendWithKeyboard( + $chatId, + "🏦 *Recarga con MercadoPago*\n\n\$" . number_format($monto) . "\nRef: `{$ref}`\n\n[👉 Recargar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.", + [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]] + ); + } + + 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. Usa MercadoPago o contacta a un asesor."); + 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⚠️ Después de transferir, envía la *foto del comprobante* aquí y el sistema lo validará automáticamente."; + + $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') + ->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 handleFreeText(string $chatId, array $state): void + { + if (! ($state['user_id'] ?? null)) { + $this->askEmail($chatId); + return; + } + + if ($state['conv_id'] ?? null) { + $conv = ChatConversation::find($state['conv_id']); + if ($conv && $conv->estado === 'agente') { + return; + } + } + + $this->showMainMenu($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 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; + } + + $url = "https://api.telegram.org/bot{$token}/{$method}"; + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => json_encode($data), + 'timeout' => 10, + ], + ]); + + $result = @file_get_contents($url, false, $ctx); + return $result !== false; + } + + private function downloadTelegramFile(string $fileId): ?array + { + $token = ChatConfig::get('telegram_token'); + if (! $token) { + return null; + } + + $info = @file_get_contents("https://api.telegram.org/bot{$token}/getFile?file_id={$fileId}"); + if (! $info) { + return null; + } + + $filePath = json_decode($info, true)['result']['file_path'] ?? null; + if (! $filePath) { + return null; + } + + $content = @file_get_contents("https://api.telegram.org/file/bot{$token}/{$filePath}"); + 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]; + } + + // ─── Helpers ────────────────────────────────────────────── + + private function efectivePrice(int $tarifaId, ?int $userId, float $base): float + { + if ($userId) { + $pref = Preferencial::where('usuario_id', $userId)->where('tarifa_id', $tarifaId)->first(); + if ($pref) { + return (float) $pref->valor; + } + } + return (float) $base; + } + + private function maskEmail(string $email): string + { + [$local, $domain] = explode('@', $email, 2); + $visible = mb_substr($local, 0, min(3, mb_strlen($local))); + return $visible . str_repeat('*', max(0, mb_strlen($local) - 3)) . '@' . $domain; + } + + private function createMPPreference(string $titulo, int $valor, string $ref): ?string + { + $accessToken = config('services.mercadopago.token'); + if (! $accessToken) { + return null; + } + + try { + $response = Http::withToken($accessToken) + ->timeout(10) + ->post('https://api.mercadopago.com/checkout/preferences', [ + 'items' => [[ + 'title' => $titulo, + 'quantity' => 1, + 'unit_price' => $valor, + 'currency_id' => 'COP', + ]], + 'external_reference' => $ref, + 'back_urls' => [ + 'success' => url('/chat'), + 'failure' => url('/chat'), + 'pending' => url('/chat'), + ], + 'auto_return' => 'approved', + 'payment_methods' => [ + 'excluded_payment_types' => [['id' => 'ticket']], + ], + ]); + + return $response->successful() ? $response->json('init_point') : null; + } catch (\Throwable $e) { + Log::warning('[TelegramMP] Error: ' . $e->getMessage()); + return null; + } + } +} diff --git a/resources/views/livewire/chat/public-chat.blade.php b/resources/views/livewire/chat/public-chat.blade.php index e327a65..5d3867d 100644 --- a/resources/views/livewire/chat/public-chat.blade.php +++ b/resources/views/livewire/chat/public-chat.blade.php @@ -292,6 +292,53 @@ + {{-- CARDS SLIDER: servicios / promos en carrusel horizontal --}} + @elseif ($tipoUi === 'cards_slider') +
+ @if ($msg['contenido']) +
+

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+ @endif + {{-- Scroll horizontal de cards --}} +
+ @foreach ($payload['cards'] ?? [] as $card) +
+ @if (!empty($card['imagen'])) + + @else +
+ + + +
+ @endif +
+

{{ $card['titulo'] ?? '' }}

+ @if (!empty($card['precio'])) +

${{ number_format($card['precio']) }}

+ @endif + @if (!empty($card['detalle'])) +

{{ $card['detalle'] }}

+ @endif + @if (!empty($card['descripcion'])) +

{{ $card['descripcion'] }}

+ @endif + @if (!empty($card['accion'])) + + @endif +
+
+ @endforeach +
+
+ {{-- LINK: botón de pago externo --}} @elseif ($tipoUi === 'link')