feat: web chat con IA, validación de pagos y OTP por correo

- 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 <noreply@anthropic.com>
This commit is contained in:
Lizandro
2026-07-14 00:09:31 +00:00
co-authored by Claude Sonnet 4.6
parent a56a69dccb
commit 536377363c
13 changed files with 1862 additions and 95 deletions
File diff suppressed because it is too large Load Diff
@@ -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()
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ChatOtpMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $codigo,
public string $nombre,
) {}
public function build(): static
{
return $this
->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 <<<HTML
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
<tr><td style="background:#10b981;padding:28px 32px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:22px;font-weight:700;">{$appName}</h1>
</td></tr>
<tr><td style="padding:36px 40px;">
<p style="margin:0 0 8px;font-size:15px;color:#374151;">Hola, <strong>{$nombre}</strong></p>
<p style="margin:0 0 28px;font-size:15px;color:#6b7280;">Tu codigo de verificacion para acceder al chat es:</p>
<div style="text-align:center;margin:0 0 28px;">
<span style="display:inline-block;background:#f0fdf4;border:2px solid #10b981;border-radius:10px;padding:16px 40px;font-size:36px;font-weight:700;letter-spacing:10px;color:#059669;">{$codigo}</span>
</div>
<p style="margin:0 0 6px;font-size:13px;color:#9ca3af;text-align:center;">Este codigo expira en <strong>10 minutos</strong>.</p>
<p style="margin:0;font-size:13px;color:#9ca3af;text-align:center;">Si no solicitaste este codigo, ignora este correo.</p>
</td></tr>
<tr><td style="background:#f9fafb;padding:16px 40px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">&copy; {$appName} soporte a traves del chat</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
HTML;
}
}
+11 -1
View File
@@ -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');
+9 -1
View File
@@ -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
{
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Services;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiIntentService
{
private string $apiKey;
private string $apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent';
public function __construct()
{
$this->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 <<<PROMPT
Eres un clasificador de intenciones para una tienda de streaming (peliculas, series).
{$promptExtra}
Acciones validas (devuelve EXACTAMENTE una de estas en el campo "action"):
- menu.principal (usuario quiere ver el menu, inicio, ayuda general)
- servicios.listar (quiere ver servicios, planes, catalogo, que hay disponible)
- promo.listar (quiere ver promociones, ofertas, descuentos)
- recarga.iniciar (quiere recargar saldo, agregar dinero, cargar cuenta)
- credenciales.listar (quiere ver sus credenciales, usuario, contrasena, cuenta, acceso)
- historial.ver (quiere ver historial, compras anteriores, que ha comprado)
- perfil.ver (quiere ver su perfil, sus datos, su informacion)
- asesor.solicitar (quiere hablar con una persona, agente, asesor, humano, soporte)
Texto del usuario: "{$texto}"
Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion:
{"action": "la_accion", "data": {}}
Si no puedes determinar la intencion con certeza, usa: {"action": "menu.principal", "data": {}}
PROMPT;
}
private function parseRespuesta(string $text): ?array
{
$text = trim($text);
// Limpiar markdown si Gemini lo incluye
$text = preg_replace('/```json\s*|\s*```/', '', $text);
$text = trim($text);
$json = json_decode($text, true);
if (! is_array($json) || empty($json['action'])) {
return null;
}
$accionesValidas = [
'menu.principal', 'servicios.listar', 'promo.listar',
'recarga.iniciar', 'credenciales.listar', 'historial.ver',
'perfil.ver', 'asesor.solicitar',
];
if (! in_array($json['action'], $accionesValidas)) {
return ['action' => 'menu.principal', 'data' => []];
}
return [
'action' => $json['action'],
'data' => is_array($json['data'] ?? null) ? $json['data'] : [],
];
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Services;
use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeminiVisionService
{
private string $apiKey;
private string $apiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent';
public function __construct()
{
$this->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 <<<PROMPT
Analiza esta imagen de comprobante de pago bancario o transferencia.
Extrae los siguientes campos:
- banco: nombre del banco (Bancolombia, Nequi, Davivienda, etc.)
- valor: monto en numeros enteros sin simbolos ni puntos (ej: 50000)
- referencia: numero de referencia o transaccion (si existe)
- fecha: fecha en formato dd/mm/yyyy
- hora: hora en formato HH:mm (si existe)
- remitente: nombre de quien envia el dinero (si aparece)
Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion:
{"banco": "...", "valor": 50000, "referencia": "...", "fecha": "...", "hora": "...", "remitente": "..."}
Si no encuentras un campo, usa null para ese campo.
Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"}
PROMPT;
}
private function parseRespuesta(string $text): ?array
{
$text = trim($text);
$text = preg_replace('/```json\s*|\s*```/', '', $text);
$text = trim($text);
$json = json_decode($text, true);
if (! is_array($json)) {
return null;
}
if (isset($json['error'])) {
return null;
}
// Normalizar valor a entero
if (isset($json['valor'])) {
$json['valor'] = (int) preg_replace('/[^0-9]/', '', (string) $json['valor']);
}
return [
'banco' => $json['banco'] ?? null,
'valor' => $json['valor'] ?? null,
'referencia' => $json['referencia'] ?? null,
'fecha' => $json['fecha'] ?? null,
'hora' => $json['hora'] ?? null,
'remitente' => $json['remitente'] ?? null,
];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use App\Models\ChatConfig;
use App\Models\WhatsappSystemConfig;
use Illuminate\Support\Facades\Log;
class PagoValidadorService
{
/**
* Cruza los datos extraídos por IA con los correos IMAP recientes.
*
* @param array $datosPago ['banco', 'valor', 'referencia', 'fecha', 'hora', 'remitente']
* @return array ['estado' => 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);
}
}