fix: indicador de subida colgado y apertura directa de servicio por nombre

1) "Subiendo comprobante..." no se quitaba nunca.

   La miniatura solo se limpiaba con livewire-upload-finish, que cubre la
   subida del archivo pero no el procesado posterior. Y updatedFotoComprobante
   tenía tres rutas de salida tempranas (sin convId, análisis deshabilitado,
   validación fallida) que ni siquiera llegaban al finally.

   Ahora todo el cuerpo va dentro de un try/finally que emite siempre
   'comprobante-listo', y el cliente limpia con ese evento del servidor en vez
   de depender de los eventos internos de Livewire. Se añade además un timeout
   de 45s como red de seguridad.

2) "cuenta de hbo" abría el listado completo en vez de HBO.

   El regex de servicios.listar tenía las marcas quemadas (netflix|hbo|disney…),
   así que capturaba la frase y devolvía data vacía antes de que la IA pudiera
   extraer el nombre. Telegram no falla porque sus keywords son solo intents
   genéricos y deja los específicos al agente.

   - Se sacan las marcas del regex genérico.
   - Nuevo detectarServicioPorNombre(): resuelve contra los nombres reales de
     la tabla servicios (nada quemado en código, sin llamada a la IA) y abre
     el servicio pedido.
   - clickBoton('servicios.listar') ahora honra data.servicio, como hace
     dispatchAction en Telegram.
   - El prompt de GeminiIntentService pide data.servicio. Este servicio lo usa
     solo el chat web; Telegram usa GeminiAgentService y no se toca.

   El patrón de credenciales se refuerza y pasa a evaluarse primero, para que
   "no puedo entrar a netflix" siga siendo soporte y no una intención de compra.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro
2026-08-01 14:19:34 +00:00
co-authored by Claude Sonnet 4.6
parent 281c9d5b02
commit 807472f07d
3 changed files with 128 additions and 37 deletions
+106 -32
View File
@@ -495,7 +495,9 @@ class PublicChat extends Component
match (true) {
$action === 'menu.principal' => $this->mostrarMenuPrincipal($this->convId),
$action === 'servicios.listar' => $this->listarServicios(),
$action === 'servicios.listar' => ! empty($data['servicio'])
? $this->verServicioPorNombre((string) $data['servicio'])
: $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(),
@@ -530,43 +532,45 @@ class PublicChat extends Component
// Upload comprobante de pago
// ─────────────────────────────────────────────────────────
/**
* El finally exterior es importante: pase lo que pase hay que emitir
* 'comprobante-listo' para que el cliente quite la miniatura de "Subiendo
* comprobante…". Depender solo de livewire-upload-finish dejaba el
* indicador colgado en cuanto el método salía por una de las rutas cortas.
*/
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();
$this->dispatchBrowserEvent('comprobante-listo');
return;
}
try {
$this->validate([
'fotoComprobante' => 'image|mimes:jpg,jpeg,png,webp,heic|max:8192',
]);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->fotoComprobante = null;
$this->guardarMensajeBot($this->convId, "Ese archivo no sirve como comprobante. Envía una imagen (JPG o PNG) de máximo 8 MB.");
$this->cargarMensajes();
return;
}
// Este método debe terminar rápido: la lectura con IA y la búsqueda en
// el correo tardan más de lo que aguanta el gateway, así que se hacen
// en ValidarPagoWebJob y aquí solo se guarda la imagen y se despacha.
try {
// Verificar solicitud activa ANTES de guardar nada
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
if (! $solicitud) {
$this->fotoComprobante = null;
$this->guardarMensajeBot($this->convId, "⚠️ No tienes ninguna solicitud de recarga activa. Primero inicia el proceso de recarga desde el menú.");
$this->cargarMensajes();
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.");
return;
}
try {
$this->validate([
'fotoComprobante' => 'image|mimes:jpg,jpeg,png,webp,heic|max:8192',
]);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->guardarMensajeBot($this->convId, "Ese archivo no sirve como comprobante. Envía una imagen (JPG o PNG) de máximo 8 MB.");
return;
}
// Verificar solicitud activa ANTES de guardar nada
$solicitud = $this->userId ? \App\Models\SolicitudRecarga::pendiente($this->userId) : null;
if (! $solicitud) {
$this->guardarMensajeBot($this->convId, "⚠️ No tienes ninguna solicitud de recarga activa. Primero inicia el proceso de recarga desde el menú.");
return;
}
// Este método debe terminar rápido: la lectura con IA y la búsqueda
// en el correo tardan más de lo que aguanta el gateway, así que las
// hace ValidarPagoWebJob. Aquí solo se guarda la imagen.
//
// Disco 'local' + carpeta photos: es el único symlink publicado
// (config/filesystems.php links). El disco 'public' no tiene symlink
// en este proyecto, por eso las imágenes daban 404.
@@ -593,6 +597,7 @@ class PublicChat extends Component
} finally {
$this->fotoComprobante = null;
$this->cargarMensajes();
$this->dispatchBrowserEvent('comprobante-listo');
$this->dispatchBrowserEvent('scroll-chat');
}
}
@@ -1543,9 +1548,75 @@ class PublicChat extends Component
private function detectarIntencion(string $texto): ?array
{
return $this->detectarIntencionKeyword($texto)
?? $this->detectarServicioPorNombre($texto)
?? $this->detectarIntencionIA($texto);
}
/**
* "cuenta de hbo" debe abrir HBO, no el listado completo.
* Se resuelve contra los nombres reales de la tabla servicios, así que no
* hay marcas quemadas en el código ni hace falta una llamada a la IA.
*
* Corre DESPUÉS de las keywords genéricas a propósito: frases como
* "no puedo entrar a netflix" son un problema de credenciales, no una
* intención de compra, y esas ya las capturó el paso anterior.
*/
private function detectarServicioPorNombre(string $texto): ?array
{
$t = $this->normalizarTexto($texto);
if ($t === '') {
return null;
}
$servicios = Servicio::where('estado', 'activo')->get(['id', 'nombre']);
// Nombre más largo primero: "hbo max" debe ganarle a un eventual "max"
$candidatos = [];
foreach ($servicios as $s) {
$nombre = $this->normalizarTexto($s->nombre);
if ($nombre === '') {
continue;
}
foreach (array_unique(array_merge([$nombre], explode(' ', $nombre))) as $clave) {
if (mb_strlen($clave) >= 3) {
$candidatos[] = ['clave' => $clave, 'id' => $s->id];
}
}
}
usort($candidatos, fn ($a, $b) => mb_strlen($b['clave']) <=> mb_strlen($a['clave']));
foreach ($candidatos as $c) {
if (preg_match('/\b' . preg_quote($c['clave'], '/') . '\b/u', $t)) {
return ['action' => 'servicios.ver', 'data' => ['id' => $c['id']]];
}
}
return null;
}
/** Minúsculas y sin tildes, para comparar sin sorpresas. */
private function normalizarTexto(string $s): string
{
$s = mb_strtolower(trim($s));
return iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) ?: $s;
}
/** Resuelve un servicio por nombre cuando la IA devuelve data.servicio. */
private function verServicioPorNombre(string $nombre): void
{
$nombre = trim($nombre);
$servicio = $nombre === '' ? null : Servicio::where('estado', 'activo')
->whereRaw('LOWER(nombre) LIKE ?', ['%' . mb_strtolower($nombre) . '%'])
->first();
$servicio ? $this->verServicio($servicio->id) : $this->listarServicios();
}
private function detectarIntencionKeyword(string $texto): ?array
{
$t = mb_strtolower(trim($texto));
@@ -1553,12 +1624,15 @@ class PublicChat extends Component
$patrones = [
// Recarga de saldo
'recarga.iniciar' => '/recarg|recargar|cargar\s*(saldo|cuenta|dinero|plata)|abonar|depositar|agregar\s*(saldo|dinero|plata)|quiero\s*poner\s*saldo/',
// Ver servicios / planes
'servicios.listar' => '/servicio|plan(es)?|cat[aá]logo|qu[eé]\s*(hay|tienen|tienes)|ver\s*plan|netflix|disney|hbo|prime|spotify|crunchyroll|paramount|streaming|aplicacion|aplicaci[oó]n/',
// Credenciales / acceso. Va antes que los servicios porque
// "no puedo entrar a netflix" es soporte, no intención de compra.
'credenciales.listar' => '/credencial|contrase[ñn]a|clave|password|mis\s*datos\s*de\s*acceso|mi\s*cuenta\b|c[oó]mo\s*(entro|accedo|me\s*conecto)|no\s*(puedo|me\s*deja|logro)\s*(entrar|acceder|iniciar)|no\s*(inicia|abre|carga|funciona|sirve)\b/',
// Ver servicios / planes. Sin marcas: los nombres concretos los
// resuelve detectarServicioPorNombre() contra la tabla servicios,
// que además abre el servicio pedido en vez del listado entero.
'servicios.listar' => '/servicio|plan(es)?|cat[aá]logo|qu[eé]\s*(hay|tienen|tienes)|ver\s*plan|streaming|aplicacion|aplicaci[oó]n/',
// Promociones
'promo.listar' => '/promo(ci[oó]n)?|oferta|descuento|especial/',
// Credenciales / acceso
'credenciales.listar' => '/credencial|contrase[ñn]a|clave|password|acceso|usuario|mi\s*cuenta|mis\s*datos\s*de\s*acceso|c[oó]mo\s*(entro|accedo|me\s*conecto)/',
// Historial de compras
'historial.ver' => '/historial|compra(s)?|pedido(s)?|lo\s*que\s*(compr[eé]|he\s*comprado)|mis\s*compras/',
// Perfil
+6 -2
View File
@@ -115,6 +115,8 @@ Los usuarios escriben en español latinoamericano informal, con jerga colombiana
Acciones válidas devuelve EXACTAMENTE una de estas:
- menu.principal saludo, "hola", "inicio", "volver", "menú", pedir ayuda general
- servicios.listar ver planes, catálogo, qué servicios hay, "quiero Netflix", "qué tienen", "cuánto vale", preguntar por precios
Si el usuario nombra un servicio concreto (Netflix, HBO, Disney, Spotify…),
devuélvelo en data.servicio para abrir ese servicio directamente.
- promo.listar ver promociones, ofertas, combos, descuentos, "hay algo especial"
- recarga.iniciar recargar saldo, cargar dinero, "quiero poner saldo", "cómo recargo", "agregar plata", "depositar"
- credenciales.listar ver contraseña, cómo entrar, "no puedo acceder", "dame el usuario", "mis datos de Netflix", "cómo me conecto"
@@ -126,8 +128,10 @@ Ejemplos de clasificación:
"quiero recargar" recarga.iniciar
"cargar saldo" recarga.iniciar
"poner plata" recarga.iniciar
"ver planes" servicios.listar
"qué tienen de Netflix" servicios.listar
"ver planes" servicios.listar, data {}
"qué tienen de Netflix" servicios.listar, data {"servicio": "Netflix"}
"cuenta de hbo" servicios.listar, data {"servicio": "HBO"}
"cuánto vale disney" servicios.listar, data {"servicio": "Disney"}
"hay promociones" promo.listar
"mis contraseñas" credenciales.listar
"no puedo entrar a mi cuenta" credenciales.listar
@@ -191,8 +191,12 @@
}
}"
x-on:livewire-upload-start.window="uploading = true"
x-on:livewire-upload-finish.window="uploading = false; previewUrl = null"
x-on:livewire-upload-error.window="uploading = false; previewUrl = null">
x-on:livewire-upload-finish.window="uploading = false"
x-on:livewire-upload-error.window="uploading = false; previewUrl = null"
{{-- Quien manda es el servidor: emite 'comprobante-listo' en todas
sus rutas de salida. Los eventos de upload de Livewire solo
cubren la subida del archivo, no el procesado posterior. --}}
x-on:comprobante-listo.window="uploading = false; previewUrl = null">
{{-- Header --}}
<div class="bg-[#202c33] shadow-md flex-shrink-0 safe-top safe-left safe-right">
@@ -743,7 +747,16 @@
{{-- Botón adjuntar foto --}}
<label class="flex-shrink-0 cursor-pointer text-[#8696a0] active:text-[#00a884] transition" style="min-width:44px; min-height:44px; display:flex; align-items:center; justify-content:center;">
<input type="file" wire:model="fotoComprobante" accept="image/*" class="hidden"
x-on:change="if($event.target.files[0]){ let r=new FileReader(); r.onload=e=>previewUrl=e.target.result; r.readAsDataURL($event.target.files[0]); }"
x-on:change="
if ($event.target.files[0]) {
let r = new FileReader();
r.onload = e => previewUrl = e.target.result;
r.readAsDataURL($event.target.files[0]);
// Red de seguridad: si la respuesta nunca llega,
// no dejamos la miniatura girando para siempre.
setTimeout(() => { previewUrl = null; uploading = false; }, 45000);
}
"
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />