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:
co-authored by
Claude Sonnet 4.6
parent
a56a69dccb
commit
536377363c
@@ -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'] : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user