From 536377363c2b32cd1d7bd0d216cc4af397da741e Mon Sep 17 00:00:00 2001 From: Lizandro Date: Tue, 14 Jul 2026 00:09:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20web=20chat=20con=20IA,=20validaci=C3=B3?= =?UTF-8?q?n=20de=20pagos=20y=20OTP=20por=20correo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat público en /chat con flujos de compra, recarga y credenciales - Verificación de identidad por código OTP enviado al correo registrado - Botones enriquecidos: cards, links, credenciales, validación de comprobante - Gemini IA para detección de intención en texto libre - Gemini Vision para extraer datos de foto de comprobante - Validador de pagos cruzando IA con correos IMAP (Bancolombia) - MercadoPago como pasarela de pago (reemplaza Wompi en el chat) - Migraciones: payload en chat_messages, user_id en chat_contacts - Config admin: API key Gemini, toggles IA y validación de foto Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Livewire/Chat/PublicChat.php | 1061 ++++++++++++++++- .../Livewire/Chat/ShowConfiguracionChat.php | 58 +- app/Mail/ChatOtpMail.php | 61 + app/Models/ChatContact.php | 12 +- app/Models/ChatMessage.php | 10 +- app/Services/GeminiIntentService.php | 111 ++ app/Services/GeminiVisionService.php | 116 ++ app/Services/PagoValidadorService.php | 83 ++ config/services.php | 1 + ...13_000001_add_payload_to_chat_messages.php | 25 + ...13_000002_add_user_id_to_chat_contacts.php | 25 + .../views/livewire/chat/public-chat.blade.php | 335 +++++- .../chat/show-configuracion-chat.blade.php | 59 +- 13 files changed, 1862 insertions(+), 95 deletions(-) create mode 100644 app/Mail/ChatOtpMail.php create mode 100644 app/Services/GeminiIntentService.php create mode 100644 app/Services/GeminiVisionService.php create mode 100644 app/Services/PagoValidadorService.php create mode 100644 database/migrations/2026_07_13_000001_add_payload_to_chat_messages.php create mode 100644 database/migrations/2026_07_13_000002_add_user_id_to_chat_contacts.php diff --git a/app/Http/Livewire/Chat/PublicChat.php b/app/Http/Livewire/Chat/PublicChat.php index a10e980..1283a6f 100644 --- a/app/Http/Livewire/Chat/PublicChat.php +++ b/app/Http/Livewire/Chat/PublicChat.php @@ -2,28 +2,68 @@ namespace App\Http\Livewire\Chat; +use App\Mail\ChatOtpMail; +use App\Models\Cuentas; use App\Models\ChatContact; use App\Models\ChatConversation; use App\Models\ChatMessage; -use App\Services\ChatBotEngine; +use App\Models\Historiale; +use App\Models\Historial_cuenta; +use App\Models\Promociones; +use App\Models\recarga; +use App\Models\Saldo; +use App\Models\Servicio; +use App\Models\Tarifas; +use App\Models\User; +use Carbon\Carbon; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use Livewire\Component; +use Livewire\WithFileUploads; class PublicChat extends Component { - public string $paso = 'telefono'; // 'telefono' | 'chat' - public string $telefono = ''; - public string $nombre = ''; - public string $input = ''; - public array $mensajes = []; - public ?int $convId = null; - public string $estadoConv = 'bot'; + use WithFileUploads; - public function iniciar() + // ── Paso actual ─────────────────────────────────────────── + public string $paso = 'telefono'; // telefono | verificacion | chat + public string $telefono = ''; + public string $nombre = ''; + public string $codigoIngresado = ''; + public ?string $emailMascarado = null; + public ?int $contactIdPending = null; // contact.id mientras espera verificacion + public string $input = ''; + public array $mensajes = []; + public ?int $convId = null; + public string $estadoConv = 'bot'; + + // ── Usuario identificado ────────────────────────────────── + public ?int $userId = null; + public ?string $nombreUsuario = null; + public ?float $saldoUsuario = null; + + // ── Estado del wizard ───────────────────────────────────── + public string $flujo = ''; + public array $flujoData = []; + + // ── Upload comprobante ──────────────────────────────────── + public $fotoComprobante = null; + public bool $procesandoImagen = false; + + protected $rules = [ + 'telefono' => 'required|min:7|max:20', + 'nombre' => 'nullable|max:80', + 'codigoIngresado' => 'nullable|digits:6', + ]; + + // ───────────────────────────────────────────────────────── + // Paso 1: identificar usuario + // ───────────────────────────────────────────────────────── + + public function iniciar(): void { - $this->validate([ - 'telefono' => 'required|min:7|max:20', - 'nombre' => 'nullable|max:80', - ]); + $this->validate(['telefono' => 'required|min:7|max:20', 'nombre' => 'nullable|max:80']); $contact = ChatContact::firstOrCreate( ['canal' => 'web', 'canal_id' => $this->telefono], @@ -34,26 +74,147 @@ class PublicChat extends Component $contact->update(['nombre' => $this->nombre]); } + // Buscar usuario en el sistema por cédula o email + $user = null; + if (! $contact->user_id) { + $user = User::where('cedula', $this->telefono) + ->orWhere('email', $this->telefono) + ->first(); + if ($user) { + $contact->update(['user_id' => $user->id]); + } + } else { + $user = User::find($contact->user_id); + } + + // Si se encontró el usuario: enviar OTP al correo registrado + if ($user && $user->email) { + $this->contactIdPending = $contact->id; + $this->emailMascarado = $this->enmascararEmail($user->email); + $this->enviarCodigoOtp($contact->id, $user); + $this->paso = 'verificacion'; + return; + } + + // Sin cuenta registrada: acceso como invitado (sin saldo ni compras) + $this->abrirChat($contact); + } + + private function enviarCodigoOtp(int $contactId, User $user): void + { + $codigo = (string) random_int(100000, 999999); + Cache::put("chat_otp_{$contactId}", $codigo, now()->addMinutes(10)); + + try { + Mail::to($user->email)->send(new ChatOtpMail($codigo, $user->name)); + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('[ChatOTP] Error enviando correo: ' . $e->getMessage()); + } + } + + // ───────────────────────────────────────────────────────── + // Paso 2: verificar código OTP + // ───────────────────────────────────────────────────────── + + public function verificarCodigo(): void + { + $this->validate(['codigoIngresado' => 'required|digits:6']); + + if (! $this->contactIdPending) { + $this->addError('codigoIngresado', 'Sesion expirada. Recarga la pagina.'); + return; + } + + $codigoGuardado = Cache::get("chat_otp_{$this->contactIdPending}"); + + if (! $codigoGuardado) { + $this->addError('codigoIngresado', 'El codigo expiro. Solicita uno nuevo.'); + return; + } + + if ($this->codigoIngresado !== $codigoGuardado) { + $this->addError('codigoIngresado', 'Codigo incorrecto. Intentalo de nuevo.'); + return; + } + + Cache::forget("chat_otp_{$this->contactIdPending}"); + + $contact = ChatContact::find($this->contactIdPending); + if (! $contact) { + $this->addError('codigoIngresado', 'Sesion invalida. Recarga la pagina.'); + return; + } + + $this->contactIdPending = null; + $this->codigoIngresado = ''; + $this->abrirChat($contact); + } + + public function reenviarCodigo(): void + { + if (! $this->contactIdPending) { + return; + } + + $contact = ChatContact::find($this->contactIdPending); + if (! $contact || ! $contact->user_id) { + return; + } + + $user = User::find($contact->user_id); + if ($user) { + $this->enviarCodigoOtp($this->contactIdPending, $user); + session()->flash('otp_reenviado', 'Codigo reenviado a tu correo.'); + } + } + + // ───────────────────────────────────────────────────────── + // Abrir chat (después de identificación) + // ───────────────────────────────────────────────────────── + + private function abrirChat(ChatContact $contact): void + { + if ($contact->user_id) { + $user = User::with('saldo')->find($contact->user_id); + $this->userId = $user->id; + $this->nombreUsuario = $user->name; + $this->saldoUsuario = $user->saldo?->valor ?? 0; + } + $conv = $contact->activeConversation(); if (! $conv) { - // Primera vez — el engine inicia el flujo con "hola" - $engine = new ChatBotEngine(); - $engine->handle('web', $this->telefono, 'hola', $contact->nombre); - $contact->refresh(); - $conv = $contact->activeConversation(); + $conv = ChatConversation::create([ + 'contact_id' => $contact->id, + 'canal' => 'web', + 'estado' => 'bot', + 'ultimo_mensaje_at' => now(), + ]); + $saludo = $this->nombreUsuario + ? "¡Hola, {$this->nombreUsuario}! Bienvenido." + : "¡Hola! Bienvenido."; + $this->guardarMensajeBot($conv->id, $saludo); } - if ($conv) { - $this->convId = $conv->id; - $this->estadoConv = $conv->estado; - $this->cargarMensajes(); - } - - $this->paso = 'chat'; + $this->convId = $conv->id; + $this->estadoConv = $conv->estado; + $this->paso = 'chat'; + $this->cargarMensajes(); + $this->mostrarMenuPrincipal($conv->id); } - public function enviar() + private function enmascararEmail(string $email): string + { + [$local, $dominio] = explode('@', $email, 2); + $visible = mb_substr($local, 0, min(3, mb_strlen($local))); + return $visible . str_repeat('*', max(0, mb_strlen($local) - 3)) . '@' . $dominio; + } + + // ───────────────────────────────────────────────────────── + // Envío de texto libre + // ───────────────────────────────────────────────────────── + + public function enviar(): void { if (! $this->convId || trim($this->input) === '') { return; @@ -62,19 +223,813 @@ class PublicChat extends Component $texto = trim($this->input); $this->input = ''; - $engine = new ChatBotEngine(); - $engine->handle('web', $this->telefono, $texto, $this->nombre ?: $this->telefono); + $this->guardarMensajeUsuario($this->convId, $texto); $conv = ChatConversation::find($this->convId); - if ($conv) { - $this->estadoConv = $conv->fresh()->estado; + if (! $conv || $conv->estado === 'agente') { + $this->cargarMensajes(); + return; + } + + $accionIA = $this->detectarIntencionIA($texto); + + if ($accionIA) { + $this->clickBoton($accionIA['action'], $accionIA['data'] ?? []); + } else { + $this->guardarMensajeBot($this->convId, "No entendi tu mensaje. Usa los botones o escribe lo que deseas:"); + $this->mostrarMenuPrincipal($this->convId); } $this->cargarMensajes(); - // scroll ya se dispara desde cargarMensajes() + $this->dispatchBrowserEvent('scroll-chat'); } - public function cargarMensajes() + // ───────────────────────────────────────────────────────── + // Clic en botón + // ───────────────────────────────────────────────────────── + + public function clickBoton(string $action, array $data = []): void + { + if (! $this->convId) { + return; + } + + match (true) { + $action === 'menu.principal' => $this->mostrarMenuPrincipal($this->convId), + $action === 'servicios.listar' => $this->listarServicios(), + $action === 'servicios.ver' => $this->verServicio((int) ($data['id'] ?? 0)), + $action === 'compra.iniciar' => $this->iniciarCompra((int) ($data['tarifa_id'] ?? 0)), + $action === 'compra.pagar.saldo' => $this->pagarConSaldo(), + $action === 'compra.pagar.mp' => $this->pagarConMP(), + $action === 'promo.listar' => $this->listarPromociones(), + $action === 'promo.ver' => $this->verPromocion((int) ($data['id'] ?? 0)), + $action === 'promo.comprar.saldo' => $this->comprarPromoConSaldo((int) ($data['id'] ?? 0)), + $action === 'promo.comprar.mp' => $this->comprarPromoConMP((int) ($data['id'] ?? 0)), + $action === 'recarga.iniciar' => $this->mostrarFormRecarga(), + $action === 'recarga.mp' => $this->generarLinkRecargaMP((int) ($data['monto'] ?? 0)), + $action === 'credenciales.listar' => $this->listarCredenciales(), + $action === 'historial.ver' => $this->verHistorial(), + $action === 'perfil.ver' => $this->verPerfil(), + $action === 'asesor.solicitar' => $this->solicitarAsesor(), + $action === 'pago.aplicar' => $this->aplicarRecargaValidada($data), + default => $this->mostrarMenuPrincipal($this->convId), + }; + + $this->cargarMensajes(); + $this->dispatchBrowserEvent('scroll-chat'); + } + + // ───────────────────────────────────────────────────────── + // Upload comprobante de pago + // ───────────────────────────────────────────────────────── + + public function updatedFotoComprobante(): void + { + if (! $this->fotoComprobante || ! $this->convId) { + return; + } + + if (\App\Models\ChatConfig::get('validacion_foto_habilitada', '0') !== '1') { + $this->guardarMensajeBot($this->convId, "El analisis de comprobantes no esta habilitado. Contacta a un asesor."); + $this->fotoComprobante = null; + $this->cargarMensajes(); + return; + } + + $this->procesandoImagen = true; + + try { + $path = $this->fotoComprobante->store('comprobantes', 'public'); + $url = asset('storage/' . $path); + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'usuario', + 'tipo_ui' => 'imagen', + 'contenido' => '[Comprobante de pago]', + 'payload' => ['url' => $url], + 'leido' => false, + ]); + + $base64 = base64_encode(file_get_contents($this->fotoComprobante->getRealPath())); + $mimeType = $this->fotoComprobante->getMimeType(); + + $datosPago = app(\App\Services\GeminiVisionService::class)->extraerPago($base64, $mimeType); + + if (! $datosPago) { + $this->guardarMensajeBot($this->convId, "No pude leer el comprobante. Intenta con una foto mas clara."); + $this->cargarMensajes(); + return; + } + + $resultado = app(\App\Services\PagoValidadorService::class)->validar($datosPago); + + $botones = []; + if ($resultado['estado'] === 'confirmado') { + $botones[] = [ + 'label' => 'Aplicar recarga de $' . number_format($datosPago['valor'] ?? 0), + 'action' => 'pago.aplicar', + 'data' => ['monto' => $datosPago['valor'], 'referencia' => $datosPago['referencia'] ?? null], + ]; + } + $botones[] = ['label' => 'Hablar con asesor', 'action' => 'asesor.solicitar', 'data' => []]; + $botones[] = ['label' => '<- Menu', 'action' => 'menu.principal', 'data' => []]; + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'validacion', + 'contenido' => 'Resultado del analisis:', + 'payload' => [ + 'estado' => $resultado['estado'], + 'ia_datos' => $datosPago, + 'botones' => $botones, + ], + 'leido' => true, + ]); + + } catch (\Throwable $e) { + $this->guardarMensajeBot($this->convId, "Error analizando el comprobante. Por favor intenta de nuevo."); + } finally { + $this->procesandoImagen = false; + $this->fotoComprobante = null; + $this->cargarMensajes(); + $this->dispatchBrowserEvent('scroll-chat'); + } + } + + // ───────────────────────────────────────────────────────── + // Menu principal + // ───────────────────────────────────────────────────────── + + private function mostrarMenuPrincipal(int $convId): void + { + $this->flujo = ''; + $this->flujoData = []; + + ChatMessage::create([ + 'conversation_id' => $convId, + 'tipo' => 'bot', + 'tipo_ui' => 'buttons', + 'contenido' => 'En que te puedo ayudar?', + 'payload' => ['botones' => [ + ['label' => 'Ver Servicios', 'action' => 'servicios.listar', 'data' => []], + ['label' => 'Promociones', 'action' => 'promo.listar', 'data' => []], + ['label' => 'Recargar Saldo', 'action' => 'recarga.iniciar', 'data' => []], + ['label' => 'Mis Credenciales', 'action' => 'credenciales.listar', 'data' => []], + ['label' => 'Historial', 'action' => 'historial.ver', 'data' => []], + ['label' => 'Mi Perfil', 'action' => 'perfil.ver', 'data' => []], + ['label' => 'Hablar con asesor', 'action' => 'asesor.solicitar', 'data' => []], + ]], + 'leido' => true, + ]); + } + + // ───────────────────────────────────────────────────────── + // Servicios + // ───────────────────────────────────────────────────────── + + private function listarServicios(): void + { + $servicios = Servicio::where('estado', 'activo')->orderBy('ubicacion')->get(); + + if ($servicios->isEmpty()) { + $this->guardarMensajeBot($this->convId, "No hay servicios disponibles en este momento."); + $this->volver('menu.principal'); + return; + } + + $this->guardarMensajeBot($this->convId, "Selecciona el servicio:"); + + 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, + ]); + } + + $this->volver('menu.principal'); + } + + private function verServicio(int $id): void + { + $servicio = Servicio::with('tarifas')->find($id); + if (! $servicio) { + $this->guardarMensajeBot($this->convId, "Servicio no encontrado."); + return; + } + + $tarifas = $servicio->tarifas->where('estado', 'activo'); + + if ($tarifas->isEmpty()) { + $this->guardarMensajeBot($this->convId, "No hay planes para {$servicio->nombre} en este momento."); + $this->volver('servicios.listar'); + return; + } + + $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); + $botones[] = ['label' => $label, 'action' => 'compra.iniciar', 'data' => ['tarifa_id' => $t->id]]; + } + $botones[] = ['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []]; + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'buttons', + 'contenido' => "Planes de {$servicio->nombre}:", + 'payload' => ['botones' => $botones], + 'leido' => true, + ]); + } + + // ───────────────────────────────────────────────────────── + // Compra de plan + // ───────────────────────────────────────────────────────── + + private function iniciarCompra(int $tarifaId): void + { + $tarifa = Tarifas::with('servicio')->find($tarifaId); + if (! $tarifa) { + $this->guardarMensajeBot($this->convId, "Plan no encontrado."); + return; + } + + $disponibles = Cuentas::where('tarifa_id', $tarifaId)->where('estado', 'activo')->count(); + if ($disponibles < 1) { + $this->guardarMensajeBot($this->convId, "Sin cuentas disponibles para este plan. Intenta con otro."); + $this->volver('servicios.listar'); + return; + } + + $this->flujo = 'compra'; + $this->flujoData = [ + 'tarifa_id' => $tarifaId, + 'valor' => $tarifa->valor, + 'servicio' => $tarifa->servicio->nombre, + 'dias' => $tarifa->dias, + 'pantallas' => $tarifa->pantallas, + ]; + + $detalle = $tarifa->servicio->por_tiempo + ? "{$tarifa->dias} dias" + : "{$tarifa->pantallas} pantalla(s) / {$tarifa->dias} dias"; + $saldoInsuficiente = ! $this->userId || ($this->saldoUsuario < $tarifa->valor); + + 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:", + 'payload' => ['botones' => [ + ['label' => 'Saldo ($' . number_format($tarifa->valor) . ')', + 'action' => 'compra.pagar.saldo', + 'data' => [], + 'disabled' => $saldoInsuficiente], + ['label' => 'MercadoPago', 'action' => 'compra.pagar.mp', 'data' => []], + ['label' => 'Volver', 'action' => 'servicios.listar', 'data' => []], + ]], + 'leido' => true, + ]); + } + + private function pagarConSaldo(): void + { + if (! $this->userId || $this->flujo !== 'compra') { + $this->guardarMensajeBot($this->convId, "Debes estar identificado para pagar con saldo."); + return; + } + + $tarifa = Tarifas::with('servicio')->find($this->flujoData['tarifa_id'] ?? 0); + if (! $tarifa) { + $this->guardarMensajeBot($this->convId, "Ocurrio un error. Intenta de nuevo."); + return; + } + + $user = User::with('saldo')->find($this->userId); + $saldo = $user->saldo; + + if (! $saldo || $saldo->valor < $tarifa->valor) { + $this->guardarMensajeBot( + $this->convId, + "Saldo insuficiente. Tienes $" . number_format($saldo?->valor ?? 0) . + " y el plan cuesta $" . number_format($tarifa->valor) . "." + ); + $this->volver('menu.principal'); + return; + } + + $cuenta = Cuentas::where('tarifa_id', $tarifa->id)->where('estado', 'activo')->first(); + if (! $cuenta) { + $this->guardarMensajeBot($this->convId, "No hay cuentas disponibles en este momento."); + 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' => $this->userId, + 'tarifa_id' => $tarifa->id, + 'cliente_id' => $this->userId, + 'nombre_cliente' => $user->name, + ]); + + Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]); + $cuenta->update(['estado' => 'ocupado']); + $saldo->update(['valor' => $saldo->valor - $tarifa->valor]); + $this->saldoUsuario = $saldo->valor - $tarifa->valor; + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'credenciales', + 'contenido' => 'Compra exitosa! Tus credenciales:', + 'payload' => [ + 'servicio' => $tarifa->servicio->nombre, + 'email' => $cuenta->correo, + 'password' => $cuenta->password, + 'perfil' => $tarifa->pantallas ?? null, + 'vence' => now()->addDays($tarifa->dias)->format('d/m/Y'), + ], + 'leido' => true, + ]); + + $this->flujo = ''; + $this->flujoData = []; + $this->volver('menu.principal'); + } + + private function pagarConMP(): void + { + if ($this->flujo !== 'compra') { + return; + } + + $valor = (int) ($this->flujoData['valor'] ?? 0); + $servicio = $this->flujoData['servicio'] ?? 'Plan streaming'; + $ref = 'CHAT-' . strtoupper(Str::random(12)); + $url = $this->crearPreferenciaMP($servicio, $valor, $ref); + + if (! $url) { + $this->guardarMensajeBot($this->convId, "No se pudo generar el link de pago. Intenta de nuevo o contacta a un asesor."); + return; + } + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'link', + 'contenido' => "Link de pago por $" . number_format($valor) . " listo. Despues de pagar, envia el comprobante aqui.", + 'payload' => ['url' => $url, 'label' => 'Pagar $' . number_format($valor) . ' con MercadoPago', 'nota' => 'Ref: ' . $ref], + 'leido' => true, + ]); + + $this->flujo = ''; + $this->flujoData = []; + $this->volver('menu.principal'); + } + + // ───────────────────────────────────────────────────────── + // Promociones + // ───────────────────────────────────────────────────────── + + private function listarPromociones(): void + { + $promos = Promociones::where('visible', true) + ->where(fn($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now())) + ->get(); + + if ($promos->isEmpty()) { + $this->guardarMensajeBot($this->convId, "No hay promociones activas en este momento."); + $this->volver('menu.principal'); + return; + } + + $this->guardarMensajeBot($this->convId, "Promociones disponibles:"); + + 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, + ]); + } + + $this->volver('menu.principal'); + } + + private function verPromocion(int $id): void + { + $promo = Promociones::find($id); + if (! $promo) { + $this->guardarMensajeBot($this->convId, "Promocion no encontrada."); + return; + } + + $saldoInsuficiente = ! $this->userId || ($this->saldoUsuario < $promo->precio); + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'buttons', + 'contenido' => ($promo->nombre ?? 'Promocion') . "\n$" . number_format($promo->precio) . "\n\nElige como pagar:", + 'payload' => ['botones' => [ + ['label' => 'Saldo ($' . number_format($promo->precio) . ')', + 'action' => 'promo.comprar.saldo', + 'data' => ['id' => $id], + 'disabled' => $saldoInsuficiente], + ['label' => 'MercadoPago', 'action' => 'promo.comprar.mp', 'data' => ['id' => $id]], + ['label' => 'Volver', 'action' => 'promo.listar', 'data' => []], + ]], + 'leido' => true, + ]); + } + + private function comprarPromoConSaldo(int $promoId): void + { + $promo = Promociones::find($promoId); + if (! $promo || ! $this->userId) { + $this->guardarMensajeBot($this->convId, "No se pudo procesar la compra."); + return; + } + + $user = User::with('saldo')->find($this->userId); + $saldo = $user->saldo; + + if (! $saldo || $saldo->valor < $promo->precio) { + $this->guardarMensajeBot($this->convId, "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->guardarMensajeBot($this->convId, "No hay cuentas disponibles para esta promocion."); + 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' => $this->userId, + 'promocion_id' => $promoId, + 'cliente_id' => $this->userId, + 'nombre_cliente' => $user->name, + ]); + + Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]); + $cuenta->update(['estado' => 'ocupado']); + $saldo->update(['valor' => $saldo->valor - $promo->precio]); + $this->saldoUsuario = $saldo->valor - $promo->precio; + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'credenciales', + 'contenido' => 'Promo activada! Tus credenciales:', + 'payload' => [ + 'servicio' => $promo->nombre ?? 'Promocion', + 'email' => $cuenta->correo, + 'password' => $cuenta->password, + 'vence' => now()->addDays(30)->format('d/m/Y'), + ], + 'leido' => true, + ]); + + $this->volver('menu.principal'); + } + + private function comprarPromoConMP(int $promoId): void + { + $promo = Promociones::find($promoId); + if (! $promo) { + return; + } + + $ref = 'PROMO-' . strtoupper(Str::random(10)); + $url = $this->crearPreferenciaMP($promo->nombre ?? 'Promocion streaming', (int) $promo->precio, $ref); + + if (! $url) { + $this->guardarMensajeBot($this->convId, "No se pudo generar el link de pago. Intenta de nuevo o contacta a un asesor."); + return; + } + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'link', + 'contenido' => "Link de pago por $" . number_format($promo->precio) . ". Envia el comprobante aqui al pagar.", + 'payload' => ['url' => $url, 'label' => 'Pagar con MercadoPago', 'nota' => 'Ref: ' . $ref], + 'leido' => true, + ]); + + $this->volver('menu.principal'); + } + + // ───────────────────────────────────────────────────────── + // Recarga de saldo + // ───────────────────────────────────────────────────────── + + private function mostrarFormRecarga(): void + { + $botones = []; + foreach ([20000, 50000, 100000, 200000, 500000] as $m) { + $botones[] = ['label' => '$' . number_format($m), 'action' => 'recarga.mp', 'data' => ['monto' => $m]]; + } + $botones[] = ['label' => 'Volver', 'action' => 'menu.principal', 'data' => []]; + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'buttons', + 'contenido' => "Selecciona el monto a recargar:", + 'payload' => ['botones' => $botones], + 'leido' => true, + ]); + } + + private function generarLinkRecargaMP(int $monto): void + { + if ($monto <= 0) { + $this->guardarMensajeBot($this->convId, "Monto invalido."); + return; + } + + $ref = 'REC-' . strtoupper(Str::random(12)); + $url = $this->crearPreferenciaMP('Recarga de saldo', $monto, $ref); + + if (! $url) { + $this->guardarMensajeBot($this->convId, "No se pudo generar el link de recarga. Intenta de nuevo o contacta a un asesor."); + return; + } + + if ($this->userId) { + $user = User::with('saldo')->find($this->userId); + $saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->userId, 'valor' => 0]); + recarga::create([ + 'monto' => $monto, + 'reference' => $ref, + 'status' => 'pendiente_mp', + 'usuario_id' => $this->userId, + 'saldo_id' => $saldo->id, + ]); + } + + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'link', + 'contenido' => "Tu link de recarga por $" . number_format($monto) . " esta listo. Despues de pagar, envia el comprobante aqui.", + 'payload' => ['url' => $url, 'label' => 'Recargar $' . number_format($monto) . ' con MercadoPago', 'nota' => 'Ref: ' . $ref], + 'leido' => true, + ]); + + $this->volver('menu.principal'); + } + + private function crearPreferenciaMP(string $titulo, int $valor, string $ref): ?string + { + $accessToken = config('services.mercadopago.token'); + + if (! $accessToken) { + return null; + } + + try { + $response = \Illuminate\Support\Facades\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']], + ], + ]); + + if ($response->successful()) { + return $response->json('init_point'); + } + + \Illuminate\Support\Facades\Log::warning('[ChatMP] Error creando preferencia: ' . $response->body()); + return null; + + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('[ChatMP] Excepcion: ' . $e->getMessage()); + return null; + } + } + + // ───────────────────────────────────────────────────────── + // Credenciales + // ───────────────────────────────────────────────────────── + + private function listarCredenciales(): void + { + if (! $this->userId) { + $this->guardarMensajeBot($this->convId, "No encontre tu cuenta. Contacta a un asesor para ver tus credenciales."); + $this->volver('menu.principal'); + return; + } + + $historiales = Historiale::where('cliente_id', $this->userId) + ->where('estado', 'entregado') + ->where('fecha_final', '>=', now()) + ->with('tarifa.servicio') + ->orderBy('fecha_final', 'desc') + ->limit(10) + ->get(); + + if ($historiales->isEmpty()) { + $this->guardarMensajeBot($this->convId, "No tienes planes activos en este momento."); + $this->volver('menu.principal'); + return; + } + + $this->guardarMensajeBot($this->convId, "Tus planes activos:"); + + foreach ($historiales as $h) { + foreach ($h->cuentas as $c) { + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'credenciales', + 'contenido' => '', + 'payload' => [ + 'servicio' => $h->tarifa?->servicio?->nombre ?? 'Servicio', + 'email' => $c->correo, + 'password' => $c->password, + 'perfil' => $h->tarifa?->pantallas ?? null, + 'vence' => Carbon::parse($h->fecha_final)->format('d/m/Y'), + ], + 'leido' => true, + ]); + } + } + + $this->volver('menu.principal'); + } + + // ───────────────────────────────────────────────────────── + // Historial + // ───────────────────────────────────────────────────────── + + private function verHistorial(): void + { + if (! $this->userId) { + $this->guardarMensajeBot($this->convId, "Inicia sesion para ver tu historial."); + $this->volver('menu.principal'); + return; + } + + $historiales = Historiale::where('cliente_id', $this->userId) + ->with('tarifa.servicio') + ->orderBy('created_at', 'desc') + ->limit(10) + ->get(); + + if ($historiales->isEmpty()) { + $this->guardarMensajeBot($this->convId, "No tienes compras registradas."); + $this->volver('menu.principal'); + return; + } + + $lineas = ["Tus ultimas compras:\n"]; + foreach ($historiales as $h) { + $activo = Carbon::parse($h->fecha_final)->gte(now()) ? '[activo]' : '[vencido]'; + $nombre = $h->tarifa?->servicio?->nombre ?? ($h->promocion_id ? 'Promo' : 'Plan'); + $fecha = Carbon::parse($h->created_at)->format('d/m/Y'); + $lineas[] = "{$activo} {$nombre} - $" . number_format($h->valor) . " ({$fecha})"; + } + + $this->guardarMensajeBot($this->convId, implode("\n", $lineas)); + $this->volver('menu.principal'); + } + + // ───────────────────────────────────────────────────────── + // Perfil + // ───────────────────────────────────────────────────────── + + private function verPerfil(): void + { + if (! $this->userId) { + $this->guardarMensajeBot($this->convId, "No encontre tu cuenta. Contacta a un asesor."); + $this->volver('menu.principal'); + return; + } + + $user = User::with('saldo')->find($this->userId); + + $this->guardarMensajeBot( + $this->convId, + "Tu perfil\nNombre: {$user->name}\nEmail: {$user->email}\nSaldo: $" . number_format($user->saldo?->valor ?? 0) + ); + + $this->volver('menu.principal'); + } + + // ───────────────────────────────────────────────────────── + // Asesor + // ───────────────────────────────────────────────────────── + + private function solicitarAsesor(): void + { + $conv = ChatConversation::find($this->convId); + if ($conv) { + $conv->update(['estado' => 'agente']); + $this->estadoConv = 'agente'; + } + $this->guardarMensajeBot($this->convId, "Un asesor se comunicara contigo en breve. Por favor espera."); + } + + // ───────────────────────────────────────────────────────── + // Aplicar recarga validada por IA + // ───────────────────────────────────────────────────────── + + private function aplicarRecargaValidada(array $data): void + { + if (! $this->userId) { + $this->guardarMensajeBot($this->convId, "No puedo aplicar la recarga: no estas identificado."); + return; + } + + $monto = (int) ($data['monto'] ?? 0); + if ($monto <= 0) { + $this->guardarMensajeBot($this->convId, "Monto invalido."); + return; + } + + $user = User::with('saldo')->find($this->userId); + $saldo = $user->saldo ?? Saldo::create(['usuario_id' => $this->userId, 'valor' => 0]); + $nuevoSaldo = $saldo->valor + $monto; + $saldo->update(['valor' => $nuevoSaldo]); + $this->saldoUsuario = $nuevoSaldo; + + $this->guardarMensajeBot( + $this->convId, + "Recarga de $" . number_format($monto) . " aplicada. Tu nuevo saldo: $" . number_format($nuevoSaldo) . "." + ); + + $this->volver('menu.principal'); + } + + // ───────────────────────────────────────────────────────── + // IA: detectar intencion + // ───────────────────────────────────────────────────────── + + private function detectarIntencionIA(string $texto): ?array + { + try { + return app(\App\Services\GeminiIntentService::class)->detectar($texto); + } catch (\Throwable) { + return null; + } + } + + // ───────────────────────────────────────────────────────── + // Cargar mensajes + // ───────────────────────────────────────────────────────── + + public function cargarMensajes(): void { if (! $this->convId) { return; @@ -85,7 +1040,9 @@ class PublicChat extends Component ->get() ->map(fn($m) => [ 'tipo' => $m->tipo, + 'tipo_ui' => $m->tipo_ui ?? 'text', 'contenido' => $m->contenido, + 'payload' => $m->payload ?? [], 'created_at' => $m->created_at->format('H:i'), ]) ->toArray(); @@ -93,6 +1050,46 @@ class PublicChat extends Component $this->dispatchBrowserEvent('scroll-chat'); } + // ───────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────── + + private function guardarMensajeBot(int $convId, string $contenido): void + { + ChatMessage::create([ + 'conversation_id' => $convId, + 'tipo' => 'bot', + 'tipo_ui' => 'text', + 'contenido' => $contenido, + 'leido' => true, + ]); + } + + private function guardarMensajeUsuario(int $convId, string $contenido): void + { + ChatMessage::create([ + 'conversation_id' => $convId, + 'tipo' => 'usuario', + 'tipo_ui' => 'text', + 'contenido' => $contenido, + 'leido' => false, + ]); + } + + private function volver(string $action): void + { + ChatMessage::create([ + 'conversation_id' => $this->convId, + 'tipo' => 'bot', + 'tipo_ui' => 'buttons', + 'contenido' => '', + 'payload' => ['botones' => [ + ['label' => 'Menu principal', 'action' => $action, 'data' => []], + ]], + 'leido' => true, + ]); + } + public function render() { return view('livewire.chat.public-chat'); diff --git a/app/Http/Livewire/Chat/ShowConfiguracionChat.php b/app/Http/Livewire/Chat/ShowConfiguracionChat.php index 8b747e2..a6455af 100644 --- a/app/Http/Livewire/Chat/ShowConfiguracionChat.php +++ b/app/Http/Livewire/Chat/ShowConfiguracionChat.php @@ -10,30 +10,61 @@ class ShowConfiguracionChat extends Component { use LivewireAlert; + // ── Chat general ────────────────────────────────────────── public string $telegram_token = ''; public string $mensaje_bienvenida = ''; public string $mensaje_transferencia = ''; public string $webhookResult = ''; + // ── Gemini IA ───────────────────────────────────────────── + public string $gemini_api_key = ''; + public string $gemini_habilitado = '0'; + public string $gemini_prompt_extra = ''; + + // ── Validación de pago por foto + correo ────────────────── + public string $validacion_foto_habilitada = '0'; + public string $validacion_accion_auto = 'solo_notificar'; // recargar_saldo | solo_notificar + public string $validacion_monto_tolerancia = '0'; + public function mount(): void { - $this->telegram_token = ChatConfig::get('telegram_token', ''); - $this->mensaje_bienvenida = ChatConfig::get('mensaje_bienvenida', 'Hola 👋 Bienvenido. Escribe tu consulta.'); - $this->mensaje_transferencia = ChatConfig::get('mensaje_transferencia', 'Un agente se comunicará contigo en breve. Por favor espera.'); + $keys = [ + 'telegram_token', 'mensaje_bienvenida', 'mensaje_transferencia', + 'gemini_api_key', 'gemini_habilitado', 'gemini_prompt_extra', + 'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia', + ]; + + $defaults = [ + 'mensaje_bienvenida' => 'Hola! Bienvenido. Escribe tu consulta.', + 'mensaje_transferencia' => 'Un agente se comunicara contigo en breve. Por favor espera.', + 'validacion_accion_auto' => 'solo_notificar', + 'validacion_monto_tolerancia' => '0', + ]; + + foreach ($keys as $key) { + $this->{$key} = ChatConfig::get($key, $defaults[$key] ?? ''); + } } public function guardar(): void { $this->validate([ - 'mensaje_bienvenida' => 'required|string|max:500', - 'mensaje_transferencia' => 'required|string|max:500', + 'mensaje_bienvenida' => 'required|string|max:500', + 'mensaje_transferencia' => 'required|string|max:500', + 'validacion_monto_tolerancia' => 'nullable|integer|min:0', ]); - ChatConfig::set('telegram_token', $this->telegram_token); - ChatConfig::set('mensaje_bienvenida', $this->mensaje_bienvenida); - ChatConfig::set('mensaje_transferencia', $this->mensaje_transferencia); + $keys = [ + 'telegram_token', 'mensaje_bienvenida', 'mensaje_transferencia', + 'gemini_api_key', 'gemini_habilitado', 'gemini_prompt_extra', + 'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia', + ]; - $this->alert('success', 'Configuración guardada correctamente.'); + foreach ($keys as $key) { + ChatConfig::set($key, $this->{$key}); + } + + $this->alert('success', 'Configuracion guardada correctamente.'); } public function registrarWebhook(): void @@ -41,11 +72,10 @@ class ShowConfiguracionChat extends Component $token = trim($this->telegram_token); if (! $token) { - $this->webhookResult = '⚠️ El token de Telegram está vacío.'; + $this->webhookResult = 'El token de Telegram esta vacio.'; return; } - // Guardar el token antes de registrar ChatConfig::set('telegram_token', $token); $webhookUrl = url('/chat/webhook/telegram'); @@ -63,14 +93,14 @@ class ShowConfiguracionChat extends Component $result = @file_get_contents($apiUrl, false, $context); if ($result === false) { - $this->webhookResult = '❌ Error de conexión con la API de Telegram.'; + $this->webhookResult = 'Error de conexion con la API de Telegram.'; return; } $data = json_decode($result, true); $this->webhookResult = ($data['ok'] ?? false) - ? '✅ Webhook registrado correctamente: ' . ($data['description'] ?? 'OK') - : '❌ Error: ' . ($data['description'] ?? 'respuesta desconocida'); + ? 'Webhook registrado: ' . ($data['description'] ?? 'OK') + : 'Error: ' . ($data['description'] ?? 'respuesta desconocida'); } public function render() diff --git a/app/Mail/ChatOtpMail.php b/app/Mail/ChatOtpMail.php new file mode 100644 index 0000000..18d9609 --- /dev/null +++ b/app/Mail/ChatOtpMail.php @@ -0,0 +1,61 @@ +subject('Tu codigo de verificacion - ' . config('app.name')) + ->html($this->renderHtml()); + } + + private function renderHtml(): string + { + $appName = config('app.name', 'SirPremium'); + $codigo = $this->codigo; + $nombre = htmlspecialchars($this->nombre); + + return << + + + + + +
+ + + + +
+

{$appName}

+
+

Hola, {$nombre}

+

Tu codigo de verificacion para acceder al chat es:

+
+ {$codigo} +
+

Este codigo expira en 10 minutos.

+

Si no solicitaste este codigo, ignora este correo.

+
+

© {$appName} — soporte a traves del chat

+
+
+ + +HTML; + } +} diff --git a/app/Models/ChatContact.php b/app/Models/ChatContact.php index 13b091f..7acba30 100644 --- a/app/Models/ChatContact.php +++ b/app/Models/ChatContact.php @@ -3,14 +3,24 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; class ChatContact extends Model { protected $table = 'chat_contacts'; - protected $fillable = ['canal', 'canal_id', 'nombre', 'telefono', 'metadata']; + + protected $fillable = [ + 'canal', 'canal_id', 'nombre', 'telefono', 'metadata', 'user_id', + ]; + protected $casts = ['metadata' => 'array']; + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function conversations(): HasMany { return $this->hasMany(ChatConversation::class, 'contact_id'); diff --git a/app/Models/ChatMessage.php b/app/Models/ChatMessage.php index 496c649..1b94cf1 100644 --- a/app/Models/ChatMessage.php +++ b/app/Models/ChatMessage.php @@ -8,7 +8,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class ChatMessage extends Model { protected $table = 'chat_messages'; - protected $fillable = ['conversation_id', 'tipo', 'contenido', 'leido']; + + protected $fillable = [ + 'conversation_id', 'tipo', 'tipo_ui', 'contenido', 'payload', 'leido', + ]; + + protected $casts = [ + 'payload' => 'array', + 'leido' => 'boolean', + ]; public function conversation(): BelongsTo { diff --git a/app/Services/GeminiIntentService.php b/app/Services/GeminiIntentService.php new file mode 100644 index 0000000..f72ad72 --- /dev/null +++ b/app/Services/GeminiIntentService.php @@ -0,0 +1,111 @@ +apiKey = ChatConfig::get('gemini_api_key', ''); + } + + /** + * Detecta la intención del texto del usuario y la mapea a una acción del sistema. + * Retorna ['action' => string, 'data' => array] o null si no se puede detectar. + */ + public function detectar(string $texto): ?array + { + if (! $this->apiKey || ChatConfig::get('gemini_habilitado', '0') !== '1') { + return null; + } + + $prompt = $this->buildPrompt($texto); + + try { + $response = Http::withHeaders(['Content-Type' => 'application/json']) + ->timeout(8) + ->post("{$this->apiUrl}?key={$this->apiKey}", [ + 'contents' => [['parts' => [['text' => $prompt]]]], + 'generationConfig' => [ + 'temperature' => 0.1, + 'maxOutputTokens' => 100, + ], + ]); + + if (! $response->successful()) { + Log::warning('[Gemini Intent] API error: ' . $response->body()); + return null; + } + + $text = $response->json('candidates.0.content.parts.0.text', ''); + return $this->parseRespuesta($text); + + } catch (\Throwable $e) { + Log::warning('[Gemini Intent] Excepcion: ' . $e->getMessage()); + return null; + } + } + + private function buildPrompt(string $texto): string + { + $promptExtra = ChatConfig::get('gemini_prompt_extra', ''); + + return << 'menu.principal', 'data' => []]; + } + + return [ + 'action' => $json['action'], + 'data' => is_array($json['data'] ?? null) ? $json['data'] : [], + ]; + } +} diff --git a/app/Services/GeminiVisionService.php b/app/Services/GeminiVisionService.php new file mode 100644 index 0000000..df6d7a5 --- /dev/null +++ b/app/Services/GeminiVisionService.php @@ -0,0 +1,116 @@ +apiKey = ChatConfig::get('gemini_api_key', ''); + } + + /** + * Extrae datos de pago de una imagen de comprobante. + * + * @param string $base64 Imagen en base64 + * @param string $mimeType MIME type (image/jpeg, image/png, etc.) + * @return array|null ['banco', 'valor', 'referencia', 'fecha', 'hora', 'remitente'] o null + */ + public function extraerPago(string $base64, string $mimeType): ?array + { + if (! $this->apiKey) { + Log::warning('[Gemini Vision] No hay API key configurada.'); + return null; + } + + $prompt = $this->buildPrompt(); + + try { + $response = Http::withHeaders(['Content-Type' => 'application/json']) + ->timeout(15) + ->post("{$this->apiUrl}?key={$this->apiKey}", [ + 'contents' => [[ + 'parts' => [ + ['text' => $prompt], + ['inline_data' => ['mime_type' => $mimeType, 'data' => $base64]], + ], + ]], + 'generationConfig' => [ + 'temperature' => 0.1, + 'maxOutputTokens' => 200, + ], + ]); + + if (! $response->successful()) { + Log::warning('[Gemini Vision] API error: ' . $response->body()); + return null; + } + + $text = $response->json('candidates.0.content.parts.0.text', ''); + return $this->parseRespuesta($text); + + } catch (\Throwable $e) { + Log::error('[Gemini Vision] Excepcion: ' . $e->getMessage()); + return null; + } + } + + private function buildPrompt(): string + { + return << $json['banco'] ?? null, + 'valor' => $json['valor'] ?? null, + 'referencia' => $json['referencia'] ?? null, + 'fecha' => $json['fecha'] ?? null, + 'hora' => $json['hora'] ?? null, + 'remitente' => $json['remitente'] ?? null, + ]; + } +} diff --git a/app/Services/PagoValidadorService.php b/app/Services/PagoValidadorService.php new file mode 100644 index 0000000..6b7ee40 --- /dev/null +++ b/app/Services/PagoValidadorService.php @@ -0,0 +1,83 @@ + confirmado|no_encontrado|monto_incorrecto, 'correo' => array|null] + */ + public function validar(array $datosPago): array + { + if (WhatsappSystemConfig::get('correo_imap_enabled', '0') !== '1') { + return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'correo_deshabilitado']; + } + + try { + $correos = $this->fetchCorreos(); + } catch (\Throwable $e) { + Log::warning('[PagoValidador] Error leyendo correos: ' . $e->getMessage()); + return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'error_imap']; + } + + if (empty($correos)) { + return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_correos']; + } + + $valorIA = (int) ($datosPago['valor'] ?? 0); + + foreach ($correos as $correo) { + $datosCorreo = BancolombiaParser::parse($correo['body'] ?? ''); + + if (! $datosCorreo) { + continue; + } + + $valorCorreo = (int) ($datosCorreo['valor'] ?? 0); + + // Verificar referencia si ambas están disponibles + if (! empty($datosPago['referencia']) && ! empty($datosCorreo['referencia'])) { + if ($datosPago['referencia'] === $datosCorreo['referencia']) { + return ['estado' => 'confirmado', 'correo' => $datosCorreo]; + } + } + + // Verificar valor + if ($valorIA > 0 && $valorCorreo > 0) { + $tolerancia = (int) ChatConfig::get('validacion_monto_tolerancia', '0'); + + if (abs($valorIA - $valorCorreo) <= $tolerancia) { + return ['estado' => 'confirmado', 'correo' => $datosCorreo]; + } else { + return ['estado' => 'monto_incorrecto', 'correo' => $datosCorreo, + 'esperado' => $valorCorreo, 'recibido' => $valorIA]; + } + } + } + + return ['estado' => 'no_encontrado', 'correo' => null, 'motivo' => 'sin_coincidencia']; + } + + private function fetchCorreos(): array + { + $minutos = (int) WhatsappSystemConfig::get('correo_imap_minutos', '5'); + + $service = new CorreoImapService( + host: WhatsappSystemConfig::get('correo_imap_host', ''), + port: (int) WhatsappSystemConfig::get('correo_imap_port', '993'), + useSsl: WhatsappSystemConfig::get('correo_imap_ssl', '1') === '1', + user: WhatsappSystemConfig::get('correo_imap_user', ''), + password: WhatsappSystemConfig::get('correo_imap_password', ''), + folder: WhatsappSystemConfig::get('correo_imap_folder', 'INBOX'), + ); + + return $service->fetchLatest(limit: 10, minutesBack: $minutos); + } +} diff --git a/config/services.php b/config/services.php index 5266528..a84e744 100755 --- a/config/services.php +++ b/config/services.php @@ -36,4 +36,5 @@ return [ 'token' => env('MP_ACCESS_TOKEN'), ], + ]; diff --git a/database/migrations/2026_07_13_000001_add_payload_to_chat_messages.php b/database/migrations/2026_07_13_000001_add_payload_to_chat_messages.php new file mode 100644 index 0000000..a4ea3e4 --- /dev/null +++ b/database/migrations/2026_07_13_000001_add_payload_to_chat_messages.php @@ -0,0 +1,25 @@ +string('tipo_ui', 20)->default('text')->after('tipo'); + // Datos estructurados para tipos enriquecidos + $table->json('payload')->nullable()->after('contenido'); + }); + } + + public function down(): void + { + Schema::table('chat_messages', function (Blueprint $table) { + $table->dropColumn(['tipo_ui', 'payload']); + }); + } +}; diff --git a/database/migrations/2026_07_13_000002_add_user_id_to_chat_contacts.php b/database/migrations/2026_07_13_000002_add_user_id_to_chat_contacts.php new file mode 100644 index 0000000..115a2b0 --- /dev/null +++ b/database/migrations/2026_07_13_000002_add_user_id_to_chat_contacts.php @@ -0,0 +1,25 @@ +unsignedBigInteger('user_id')->nullable()->after('id'); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('chat_contacts', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + }); + } +}; diff --git a/resources/views/livewire/chat/public-chat.blade.php b/resources/views/livewire/chat/public-chat.blade.php index 7aaf45f..0f7aed5 100644 --- a/resources/views/livewire/chat/public-chat.blade.php +++ b/resources/views/livewire/chat/public-chat.blade.php @@ -1,7 +1,3 @@ -{{-- - id="chat-root" es el ancla que usa el visualViewport JS para ajustar altura cuando - aparece el teclado iOS. Ocupa todo el viewport disponible (cuerpo del body). ---}}
@@ -23,33 +19,20 @@
- - {{-- font-size 16px (via CSS global) evita el zoom automático en Safari --}} - Teléfono * + - @error('telefono') - {{ $message }} - @enderror + @error('telefono') {{ $message }} @enderror
- - Nombre (opcional) +
- {{-- min-height 48px = cumple Apple HIG de target táctil --}}
- {{-- ══ PASO 2: Interfaz de chat ══ --}} + {{-- ══ PASO 2: Verificación OTP ══ --}} + @elseif ($paso === 'verificacion') +
+
+ +
+
+ + + +
+

Verifica tu identidad

+

+ Enviamos un código de 6 dígitos a
+ {{ $emailMascarado }} +

+
+ + @if (session('otp_reenviado')) +

{{ session('otp_reenviado') }}

+ @endif + + +
+ + + @error('codigoIngresado') {{ $message }} @enderror +
+ + + + +
+ + +
+ +
+
+ + {{-- ══ PASO 3: Interfaz de chat ══ --}} @else
- {{-- Header + safe area top (para el notch / Dynamic Island) --}} + {{-- Header --}}
@@ -79,62 +119,265 @@
-

Soporte en línea

+

+ {{ $nombreUsuario ?: 'Soporte en línea' }} +

@if ($estadoConv === 'agente') Atendido por un agente @elseif ($estadoConv === 'cerrada') Conversación cerrada - @else Bot activo - @endif + @else Bot activo @endif

+ {{-- Saldo si hay usuario identificado --}} + @if ($saldoUsuario !== null) +
+

Saldo

+

${{ number_format($saldoUsuario) }}

+
+ @endif
- {{-- Mensajes — ios-scroll da momentum nativo en iPhone --}} -
+ @forelse($mensajes as $msg) + + {{-- ── Mensaje del USUARIO ── --}} @if ($msg['tipo'] === 'usuario')
-
-

{{ $msg['contenido'] }}

- {{ $msg['created_at'] }} -
+ @if (($msg['tipo_ui'] ?? 'text') === 'imagen') +
+ +
+ {{ $msg['created_at'] }} +
+
+ @else +
+

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+ @endif
+ + {{-- ── Mensajes del BOT / AGENTE ── --}} @else
-
- @if ($msg['tipo'] === 'agente') - Agente - @endif -

{{ $msg['contenido'] }}

- {{ $msg['created_at'] }} -
+ @php $tipoUi = $msg['tipo_ui'] ?? 'text'; $payload = $msg['payload'] ?? []; @endphp + + {{-- TEXTO normal --}} + @if ($tipoUi === 'text') +
+ @if ($msg['tipo'] === 'agente') + Agente + @endif +

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+ + {{-- BUTTONS: texto + fila de botones --}} + @elseif ($tipoUi === 'buttons') +
+ @if ($msg['contenido']) +
+

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+ @endif +
+ @foreach ($payload['botones'] ?? [] as $btn) + @if ($btn['disabled'] ?? false) + + @else + + @endif + @endforeach +
+
+ + {{-- CARD: tarjeta de servicio o promo --}} + @elseif ($tipoUi === 'card') +
+ @if (!empty($payload['imagen'])) + + @endif +
+

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

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

{{ $payload['descripcion'] }}

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

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

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

{{ $payload['detalle'] }}

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

{{ $msg['contenido'] }}

+ + + + + {{ $payload['label'] ?? 'Ir a pagar' }} + + @if (!empty($payload['nota'])) +

{{ $payload['nota'] }}

+ @endif +
+ {{ $msg['created_at'] }} +
+
+ + {{-- VALIDACION: resultado de comprobante de pago --}} + @elseif ($tipoUi === 'validacion') +
+ {{-- Header estado --}} + @php + $estado = $payload['estado'] ?? 'no_encontrado'; + $esConfirmado = $estado === 'confirmado'; + $esIncorrecto = $estado === 'monto_incorrecto'; + @endphp +
+

+ @if ($esConfirmado) ✅ Pago confirmado + @elseif ($esIncorrecto) ⚠️ Monto no coincide + @else 🔍 No encontrado en correos + @endif +

+
+ {{-- Datos extraídos --}} +
+

Datos del comprobante

+ @foreach (['banco' => 'Banco', 'valor' => 'Valor', 'remitente' => 'De', 'referencia' => 'Referencia', 'fecha' => 'Fecha'] as $key => $label) + @if (!empty($payload['ia_datos'][$key])) +
+ {{ $label }} + {{ $payload['ia_datos'][$key] }} +
+ @endif + @endforeach +
+ {{-- Botones de acción --}} +
+ @foreach ($payload['botones'] ?? [] as $btn) + + @endforeach +
+
+ {{ $msg['created_at'] }} +
+
+ + {{-- CREDENCIALES: tarjeta con usuario/contraseña --}} + @elseif ($tipoUi === 'credenciales') +
+
+

{{ $payload['servicio'] ?? 'Credenciales' }}

+
+
+ @if (!empty($payload['email'])) +
+

Usuario / Email

+

{{ $payload['email'] }}

+
+ @endif + @if (!empty($payload['password'])) +
+

Contraseña

+

{{ $payload['password'] }}

+
+ @endif + @if (!empty($payload['perfil'])) +
+

Perfil

+

{{ $payload['perfil'] }}

+
+ @endif + @if (!empty($payload['vence'])) +

Vence: {{ $payload['vence'] }}

+ @endif +
+
+ {{ $msg['created_at'] }} +
+
+ + {{-- fallback --}} + @else +
+

{{ $msg['contenido'] }}

+ {{ $msg['created_at'] }} +
+ @endif
@endif + @empty
Iniciando chat...
@endforelse - {{-- Ancla para scroll al fondo --}} +
- {{-- Input + safe area bottom (home indicator iPhone X+) --}} + {{-- Input + upload --}} @if ($estadoConv !== 'cerrada')
+ {{-- Indicador de procesando imagen --}} + @if ($procesandoImagen) +
+
+ + + + + Analizando comprobante con IA... +
+
+ @endif
+ {{-- Botón adjuntar foto --}} + - {{-- 44x44 mínimo Apple HIG --}}
+
+ + {{-- Gemini IA --}} +
+

Inteligencia Artificial (Gemini)

+
+
+ +

Obtén tu clave en aistudio.google.com

+ +
+
+ + +
+
+ + +
+
+
+ +
+ + {{-- Validacion de pagos --}} +
+

Validacion de pagos por foto + correo IMAP

+

La IA lee el comprobante y cruza los datos con los correos del IMAP configurado en la seccion WhatsApp.

+
+
+ + +
+
+ + +
+
+ + +

Diferencia maxima permitida entre comprobante y correo. Recomendado: 0.

+
+
+
+ {{-- Guardar --}}