Files
sirpremiumv2/app/Services/TelegramBotService.php
LizandroandClaude Sonnet 4.6 16315da176 fix: correcciones de seguridad pre-producción identificadas en code review
- autoAplicarRecarga: null-check en User::find + DB::transaction atómico
- pagarConSaldo: $cuenta->update('ocupado') dentro de la transacción (evita doble venta)
- comprarPromoConSaldo: Historiale::create movido dentro de la transacción (evita registros huérfanos)
- mostrarTransferencia: array_merge en lugar de asignación directa (preserva pending_purchase)
- Blade copiarTodo: lee valores desde x-ref del DOM en vez de escapado PHP→JS
- ValidarPagoTelegramJob: eliminado import App\Models\User no utilizado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-29 19:24:36 +00:00

1706 lines
68 KiB
PHP
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Mail\ChatOtpMail;
use App\Models\AiUsageLog;
use App\Models\ChatConfig;
use App\Models\ChatContact;
use App\Models\ChatConversation;
use App\Models\ChatMessage;
use App\Models\Cuentas;
use App\Models\Historiale;
use App\Models\Historial_cuenta;
use App\Models\Preferencial;
use App\Models\Promociones;
use App\Services\CuentasHelper;
use App\Models\TarifaPromo;
use App\Models\Usuario_promo;
use App\Models\recarga;
use App\Models\Role;
use App\Models\Saldo;
use App\Models\SolicitudRecarga;
use App\Models\Servicio;
use App\Models\Tarifas;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use App\Jobs\ValidarPagoTelegramJob;
use App\Services\GeminiAgentService;
class TelegramBotService
{
private const STATE_TTL = 315360000; // 10 years — session never expires automatically
private ?int $convId = null;
// ─── Entry points ─────────────────────────────────────────
public function handleText(string $chatId, string $text, string $nombre = ''): void
{
$text = trim($text);
$state = $this->getState($chatId);
$this->convId = $state['conv_id'] ?? null;
// Save user message if we have a conversation
if ($this->convId && $text !== '') {
$conv = ChatConversation::find($this->convId);
if ($conv) {
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'usuario',
'contenido' => $text,
'leido' => false,
]);
$conv->update(['ultimo_mensaje_at' => now()]);
}
}
$lower = strtolower(trim($text));
// "salir" siempre pide confirmación de cierre de sesión
if (in_array($lower, ['salir', '/salir'])) {
if ($state['user_id'] ?? null) {
$this->showLogoutConfirmation($chatId);
} else {
$this->askEmail($chatId);
}
return;
}
// Palabras que siempre regresan al menú, desde cualquier punto del flujo
$menuTriggers = [
'/start', '/menu', '/inicio', '/cancelar',
'menu', 'menú', 'inicio', 'hola', 'volver',
'regresar', 'cancelar', 'empezar', 'comenzar', 'start',
];
if (in_array($lower, $menuTriggers)) {
if ($state['user_id'] ?? null) {
// Limpiar cualquier flujo pendiente
$cleanState = $this->getState($chatId);
unset($cleanState['step']);
$cleanState['step'] = 'idle';
$this->setState($chatId, $cleanState);
$this->showMainMenu($chatId);
} else {
$this->askEmail($chatId);
}
return;
}
$step = $state['step'] ?? 'await_email';
// Guardar último texto para que handleFreeText pueda pasarlo a Gemini
if (! in_array($step, ['await_email', 'await_otp', 'await_nombre', 'await_celular', 'await_amount', 'await_remitente_nombre'])) {
$state['data']['last_text'] = $text;
$this->setState($chatId, $state);
}
match ($step) {
'await_email' => $this->processEmail($chatId, $text, $nombre),
'await_otp' => $this->processOtp($chatId, $text),
'await_nombre' => $this->processNombre($chatId, $text),
'await_celular' => $this->processCelular($chatId, $text),
'await_amount' => $this->processCustomAmount($chatId, $text),
'await_remitente_nombre' => $this->processRemitenteNombre($chatId, $text),
default => $this->handleFreeText($chatId, $state),
};
}
public function handleCallback(string $chatId, string $callbackId, string $data): void
{
$this->answerCallback($callbackId);
$state = $this->getState($chatId);
$this->convId = $state['conv_id'] ?? null;
if (! ($state['user_id'] ?? null)) {
$this->askEmail($chatId);
return;
}
$parts = explode('|', $data, 3);
$action = $parts[0];
match ($action) {
'menu' => $this->showMainMenu($chatId),
'logout_ask' => $this->showLogoutConfirmation($chatId),
'logout_confirm' => $this->confirmLogout($chatId),
'svc_list' => $this->listServices($chatId),
'svc_view' => $this->viewService($chatId, (int) ($parts[1] ?? 0)),
'buy_start' => $this->startPurchase($chatId, (int) ($parts[1] ?? 0)),
'buy_saldo' => $this->payWithSaldo($chatId),
'breb_tarifa' => $this->startDirectBrebPayment($chatId, (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0)),
'promo_list' => $this->listPromos($chatId),
'promo_view' => $this->viewPromo($chatId, (int) ($parts[1] ?? 0)),
'promo_saldo' => $this->buyPromoSaldo($chatId, (int) ($parts[1] ?? 0)),
'breb_promo' => $this->startDirectBrebPromo($chatId, (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0)),
'rec_init' => $this->showRechargeAmounts($chatId),
'rec_custom' => $this->askCustomAmount($chatId),
'rec_amount' => $this->chooseRechargeMethod($chatId, (int) ($parts[1] ?? 0)),
'rec_breb' => $this->askRemitenteNombre($chatId, (int) ($parts[1] ?? 0)),
'breb_nom' => $this->seleccionarNombreRemitente($chatId, (string) ($parts[1] ?? 'nuevo')),
'creds' => $this->showCredentials($chatId),
'history' => $this->showHistory($chatId),
'profile' => $this->showProfile($chatId),
'agent' => $this->showMainMenu($chatId),
'pay_retry' => $this->retryPayment($chatId),
default => $this->showMainMenu($chatId),
};
}
public function handlePhoto(string $chatId, string $fileId): void
{
$state = $this->getState($chatId);
$this->convId = $state['conv_id'] ?? null;
if (! ($state['user_id'] ?? null)) {
$this->send($chatId, "Para enviar un comprobante primero debes identificarte. Escribe tu correo.");
return;
}
if (ChatConfig::get('validacion_foto_habilitada', '0') !== '1') {
$this->send($chatId, "El análisis de comprobantes no está habilitado.");
return;
}
$usuarioId = $state['user_id'] ?? null;
$nombreUsuario = $state['data']['nombre_remitente'] ?? ($usuarioId ? (User::find($usuarioId)?->name ?? '') : '');
// Verificar solicitud de recarga activa ANTES de cualquier llamada de API
$solicitud = $usuarioId ? SolicitudRecarga::pendiente($usuarioId) : null;
if (! $solicitud) {
$this->send($chatId, "⚠️ No tienes ninguna solicitud de recarga activa. Primero inicia el proceso de recarga desde el menú.");
$this->showMainMenu($chatId);
return;
}
$this->send($chatId, "⏳ Analizando tu comprobante...");
try {
$imageData = $this->downloadTelegramFile($fileId);
if (! $imageData) {
$this->send($chatId, "No pude descargar la imagen. Intenta de nuevo.");
return;
}
$base64 = base64_encode($imageData['content']);
$mime = $imageData['mime'];
$datosPago = app(GeminiVisionService::class)->extraerPago($base64, $mime, $usuarioId, 'telegram');
if (! $datosPago) {
$this->send($chatId, "No pude leer el comprobante. Intenta con una foto más clara.");
return;
}
if (isset($datosPago['__error'])) {
$this->send($chatId, "⚠️ Error de API:\n`" . $datosPago['__error'] . "`");
return;
}
if (isset($datosPago['__parse_error'])) {
$this->send($chatId, "⚠️ Gemini respondió pero no es JSON válido:\n`" . $datosPago['__parse_error'] . "`");
return;
}
// Verificar que el monto del comprobante coincida con la solicitud
$valorIA = (int) ($datosPago['valor'] ?? 0);
if ($valorIA !== $solicitud->monto) {
$this->send($chatId, "⚠️ El monto del comprobante (*\$" . number_format($valorIA) . "*) no coincide con tu solicitud de recarga (*\$" . number_format($solicitud->monto) . "*).");
$this->showMainMenu($chatId);
return;
}
$resultado = app(PagoValidadorService::class)->validar(
$datosPago, $usuarioId, $nombreUsuario, 'telegram'
);
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
$this->autoAplicarRecarga($chatId, $state, $solicitud, $valorIA);
} elseif ($resultado['estado'] === 'ya_usado') {
$this->sendWithKeyboard($chatId, "⛔ *Comprobante ya utilizado.*\nEste pago ya fue registrado anteriormente.", [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]);
} elseif ($motivo === 'sin_coincidencia' || $motivo === 'sin_correos') {
// Guardar para reintento manual (por si el job agota sus intentos)
$state['data']['pago_pendiente'] = $datosPago;
$this->setState($chatId, $state);
$this->send($chatId,
"⏳ *Estamos validando tu pago*, esto puede demorar unos minutos.\n"
. "Cuando terminemos te confirmaremos por este medio. Espera un momento."
);
// Lanzar job que reintenta cada 20s hasta 9 veces (≈3 min)
ValidarPagoTelegramJob::dispatch($chatId, $datosPago, 1)
->delay(now()->addSeconds(20));
} else {
$motivoTexto = match ($motivo) {
'correo_deshabilitado' => '📧 Verificación por correo no configurada',
'error_imap' => '📧 Error al leer correos IMAP',
'error_sistema' => '⚙️ Error interno, intenta de nuevo en unos minutos',
default => '📧 No se pudo verificar automáticamente',
};
$this->sendWithKeyboard($chatId, "⚠️ *No confirmado*\n_{$motivoTexto}_", [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]);
}
} catch (\Throwable $e) {
Log::warning('[TelegramBot] Error procesando foto: ' . $e->getMessage());
$this->send($chatId, "Error analizando el comprobante. Por favor intenta de nuevo.");
}
}
private function retryPayment(string $chatId): void
{
$state = $this->getState($chatId);
$this->convId = $state['conv_id'] ?? null;
$datosPago = $state['data']['pago_pendiente'] ?? null;
$usuarioId = $state['user_id'] ?? null;
if (! $datosPago || ! $usuarioId) {
$this->send($chatId, "No hay ningún comprobante pendiente. Envía la foto de tu comprobante.");
return;
}
$solicitud = SolicitudRecarga::pendiente($usuarioId);
if (! $solicitud) {
$this->send($chatId, "⚠️ Tu solicitud de recarga expiró. Inicia una nueva recarga desde el menú.");
$this->showMainMenu($chatId);
return;
}
$this->send($chatId, "⏳ Verificando de nuevo...");
$nombreUsuario = User::find($usuarioId)?->name ?? '';
$resultado = app(PagoValidadorService::class)->validar($datosPago, $usuarioId, $nombreUsuario, 'telegram');
$motivo = $resultado['motivo'] ?? '';
if ($resultado['estado'] === 'confirmado') {
$this->autoAplicarRecarga($chatId, $state, $solicitud, $solicitud->monto);
} elseif ($resultado['estado'] === 'ya_usado') {
unset($state['data']['pago_pendiente']);
$this->setState($chatId, $state);
$this->sendWithKeyboard($chatId, "⛔ *Comprobante ya utilizado.*\nEste pago ya fue registrado anteriormente.", [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]);
} else {
$this->sendWithKeyboard($chatId,
"⏳ *Aún no encontramos tu pago.*\nPuede demorar entre 1 y 2 minutos en llegar. Espera un momento y presiona Reintentar.",
[
[['text' => '🔄 Reintentar', 'callback_data' => 'pay_retry']],
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]
);
}
}
public function autoAplicarRecarga(string $chatId, array $state, SolicitudRecarga $solicitud, int $monto): void
{
$usuarioId = $state['user_id'];
$user = User::with('saldo')->find($usuarioId);
if (! $user) {
Log::warning("[TelegramBot] autoAplicarRecarga: usuario {$usuarioId} no encontrado.");
$this->send($chatId, "⚠️ Error al aplicar la recarga. Por favor contacta a un asesor.");
return;
}
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $usuarioId, 'valor' => 0]);
$nuevoSaldo = $saldo->valor + $monto;
DB::transaction(function () use ($saldo, $nuevoSaldo, $usuarioId, $monto, $solicitud) {
$saldo->update(['valor' => $nuevoSaldo]);
\App\Models\recarga::create([
'usuario_id' => $usuarioId,
'saldo_id' => $saldo->id,
'monto' => $monto,
'valor_recarga' => $monto,
'status' => 'Confirmado',
'reference' => 'breb-bot-' . $solicitud->id,
]);
$solicitud->confirmar();
});
$pp = $state['data']['pending_purchase'] ?? null;
unset($state['data']['pago_pendiente'], $state['data']['solicitud_recarga_id'], $state['data']['pending_purchase']);
$this->setState($chatId, $state);
$this->send($chatId,
"✅ *¡Tu recarga de \$" . number_format($monto) . " fue aplicada exitosamente!*\n"
. "Tu nuevo saldo es *\$" . number_format($nuevoSaldo) . "*"
);
if ($pp && ($pp['type'] ?? '') === 'tarifa') {
// Configurar estado para la compra y ejecutarla directamente
$state = $this->getState($chatId);
$state['data'] = [
'flow' => 'compra',
'tarifa_id' => $pp['tarifa_id'],
'valor' => $pp['valor'],
'servicio' => $pp['servicio'],
'servicio_id' => $pp['servicio_id'],
];
$this->setState($chatId, $state);
$this->payWithSaldo($chatId);
} elseif ($pp && ($pp['type'] ?? '') === 'promo') {
$this->buyPromoSaldo($chatId, $pp['promo_id']);
} else {
$this->showMainMenu($chatId);
}
}
public function handleVoice(string $chatId, string $fileId, string $nombre = '', int $duracionSegundos = 0): void
{
$state = $this->getState($chatId);
$this->convId = $state['conv_id'] ?? null;
$usuarioId = $state['user_id'] ?? null;
if (ChatConfig::get('whisper_habilitado', '0') !== '1') {
$this->send($chatId, "🎤 Recibí tu audio, pero la transcripción de voz no está habilitada. Por favor escribe tu mensaje.");
return;
}
$this->send($chatId, "🎤 Transcribiendo tu audio...");
try {
$audioData = $this->downloadTelegramFile($fileId);
if (! $audioData) {
$this->send($chatId, "No pude descargar el audio. Intenta de nuevo o escribe tu mensaje.");
return;
}
$resultado = $this->transcribeAudio($audioData['content'], $duracionSegundos, $usuarioId);
if (! $resultado['texto']) {
$this->send($chatId, "No pude entender el audio. Intenta de nuevo o escribe tu mensaje.");
return;
}
$this->send($chatId, "🎙️ Escuché: _\"{$resultado['texto']}\"_\nProcesando...");
$this->handleText($chatId, $resultado['texto'], $nombre);
} catch (\Throwable $e) {
Log::warning('[TelegramBot] handleVoice error: ' . $e->getMessage());
$this->send($chatId, "Error procesando el audio. Escribe tu mensaje.");
}
}
private function transcribeAudio(string $audioContent, int $duracionSegundos = 0, ?int $usuarioId = null): array
{
$whisperUrl = ChatConfig::get('whisper_url');
$whisperToken = ChatConfig::get('whisper_token');
$inicio = microtime(true);
if (! $whisperUrl) {
return ['texto' => null];
}
try {
// response_format y language como query params
$sep = str_contains($whisperUrl, '?') ? '&' : '?';
$url = rtrim($whisperUrl, '?') . $sep . 'response_format=json&language=es';
$response = Http::timeout(60)
->withBasicAuth('whisper', $whisperToken)
->attach('audio_file', $audioContent, 'audio.ogg')
->post($url);
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
// Intentar JSON primero, luego texto plano como fallback
$json = $response->json();
$texto = trim($json['text'] ?? $json['transcription'] ?? '');
if ($texto === '' && $response->successful()) {
// El servidor devolvió texto plano
$raw = trim($response->body());
if ($raw !== '' && ! str_starts_with($raw, '{') && ! str_starts_with($raw, '[')) {
$texto = $raw;
}
}
$ok = $response->successful() && $texto !== '';
AiUsageLog::registrar([
'servicio' => 'whisper',
'canal' => 'telegram',
'usuario_id' => $usuarioId,
'audio_segundos' => $duracionSegundos ?: null,
'tiempo_ms' => $tiempoMs,
'costo' => $duracionSegundos, // $1 por segundo
'resultado' => $ok ? 'ok' : 'error',
'detalle' => [
'texto' => $ok ? substr($texto, 0, 200) : null,
'error' => $ok ? null : substr($response->body(), 0, 300),
'segundos' => $duracionSegundos,
],
]);
if (! $ok) {
Log::warning('[TelegramBot] Whisper error: ' . $response->body());
return ['texto' => null];
}
return ['texto' => $texto];
} catch (\Throwable $e) {
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::warning('[TelegramBot] Whisper exception: ' . $e->getMessage());
AiUsageLog::registrar([
'servicio' => 'whisper',
'canal' => 'telegram',
'usuario_id' => $usuarioId,
'audio_segundos' => $duracionSegundos ?: null,
'tiempo_ms' => $tiempoMs,
'resultado' => 'error',
'detalle' => ['error' => $e->getMessage()],
]);
return ['texto' => null];
}
}
// ─── Auth flow ────────────────────────────────────────────
private function askEmail(string $chatId): void
{
$this->setState($chatId, ['step' => 'await_email', 'data' => []]);
$this->send($chatId, "👋 Hola! Para continuar escribe tu correo electrónico registrado:");
}
private function processEmail(string $chatId, string $email, string $nombre): void
{
$email = strtolower(trim($email));
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->send($chatId, "❌ Eso no parece un correo válido. Escríbelo de nuevo:");
return;
}
try {
$user = User::whereRaw('LOWER(email) = ?', [$email])->first();
} catch (\Throwable $e) {
Log::error('[TelegramBot] processEmail DB error: ' . $e->getMessage());
$this->send($chatId, "⚠️ Error de conexión. Escribe tu correo de nuevo:");
return;
}
if (! $user) {
$this->setState($chatId, [
'step' => 'await_nombre',
'data' => ['email' => $email, 'nombre_tg' => $nombre],
]);
$this->send($chatId,
"No encontré una cuenta con ese correo.\n\n" .
"¿Deseas crear una cuenta? Escribe tu *nombre completo* o escribe /cancelar para intentar con otro correo."
);
return;
}
try {
$contact = ChatContact::where('canal', 'telegram')->where('canal_id', (string) $chatId)->first();
if ($contact) {
$contact->update(['user_id' => $user->id, 'nombre' => $user->name]);
} else {
$contact = ChatContact::create([
'canal' => 'telegram',
'canal_id' => (string) $chatId,
'nombre' => $user->name,
'user_id' => $user->id,
]);
}
} catch (\Throwable $e) {
Log::error('[TelegramBot] processEmail contact error: ' . $e->getMessage());
$this->send($chatId, "⚠️ Error interno. Escribe tu correo de nuevo:");
return;
}
$this->sendOtp($chatId, $contact, $user);
}
private function sendOtp(string $chatId, ChatContact $contact, User $user): void
{
$codigo = (string) random_int(100000, 999999);
Cache::put("chat_otp_{$contact->id}", $codigo, now()->addMinutes(10));
$mailError = null;
try {
Mail::to($user->email)->send(new ChatOtpMail($codigo, $user->name));
} catch (\Throwable $e) {
$mailError = $e->getMessage();
Log::warning('[TelegramOTP] Error enviando correo: ' . $mailError);
}
$this->setState($chatId, [
'step' => 'await_otp',
'contact_id' => $contact->id,
'data' => [],
]);
$masked = $this->maskEmail($user->email);
if (! $mailError) {
$this->send($chatId, "✉️ Te enviamos un código a `{$masked}`\n\nEscribe el código de 6 dígitos:");
} else {
$this->send($chatId,
"⚠️ Error enviando correo a `{$masked}`\n\n`{$mailError}`\n\nContacta al administrador."
);
}
}
private function processOtp(string $chatId, string $text): void
{
$state = $this->getState($chatId);
$contactId = $state['contact_id'] ?? null;
if (! $contactId || ! preg_match('/^\d{6}$/', $text)) {
$this->send($chatId, "El código debe tener 6 dígitos. Intenta de nuevo:");
return;
}
$saved = Cache::get("chat_otp_{$contactId}");
if (! $saved) {
$this->send($chatId, "El código expiró. Escribe tu correo de nuevo:");
$this->setState($chatId, ['step' => 'await_email', 'data' => []]);
return;
}
if ($text !== $saved) {
$this->send($chatId, "❌ Código incorrecto. Intenta de nuevo:");
return;
}
Cache::forget("chat_otp_{$contactId}");
$contact = ChatContact::find($contactId);
$user = User::with('saldo')->find($contact->user_id);
$this->openSession($chatId, $contact, $user);
}
// ─── Registration flow ────────────────────────────────────
private function processNombre(string $chatId, string $text): void
{
if (strtolower($text) === '/cancelar') {
$this->setState($chatId, ['step' => 'await_email', 'data' => []]);
$this->send($chatId, "De acuerdo. Escribe tu correo electrónico:");
return;
}
$state = $this->getState($chatId);
$state['data']['nombre'] = $text;
$state['step'] = 'await_celular';
$this->setState($chatId, $state);
$this->send($chatId, "Tu número de celular (o escribe /omitir para saltarlo):");
}
private function processCelular(string $chatId, string $text): void
{
$state = $this->getState($chatId);
$state['data']['celular'] = strtolower($text) === '/omitir' ? null : $text;
$email = $state['data']['email'] ?? null;
$nombre = $state['data']['nombre'] ?? null;
if (! $email || ! $nombre) {
$this->send($chatId, "Ocurrió un error. Escribe /start para comenzar de nuevo.");
$this->setState($chatId, ['step' => 'await_email', 'data' => []]);
return;
}
$rolCliente = Role::where('nombre', 'cliente')->first();
$user = User::create([
'name' => $nombre,
'email' => $email,
'password' => Hash::make(Str::random(16)),
'cedula' => $state['data']['cedula'] ?? null,
'celular' => $state['data']['celular'] ?? null,
'estado' => 'activo',
'rol_id' => $rolCliente?->id,
]);
$contact = ChatContact::where('canal', 'telegram')->where('canal_id', $chatId)->first();
if ($contact) {
$contact->update(['user_id' => $user->id, 'nombre' => $user->name]);
} else {
$contact = ChatContact::create([
'canal' => 'telegram',
'canal_id' => $chatId,
'nombre' => $user->name,
'user_id' => $user->id,
]);
}
$this->sendOtp($chatId, $contact, $user);
}
// ─── Open session ─────────────────────────────────────────
private function openSession(string $chatId, ChatContact $contact, User $user): void
{
$conv = $contact->activeConversation();
if (! $conv) {
$conv = ChatConversation::create([
'contact_id' => $contact->id,
'canal' => 'telegram',
'estado' => 'bot',
'ultimo_mensaje_at' => now(),
]);
}
$this->convId = $conv->id;
$this->setState($chatId, [
'step' => 'menu',
'user_id' => $user->id,
'contact_id' => $contact->id,
'conv_id' => $conv->id,
'data' => [],
]);
$saludo = "¡Bienvenido, *{$user->name}*! 🎉\nSaldo disponible: *\$" . number_format($user->saldo?->valor ?? 0) . "*";
$this->send($chatId, $saludo);
$this->showMainMenu($chatId);
}
// ─── Main menu ────────────────────────────────────────────
private function showLogoutConfirmation(string $chatId): void
{
$this->sendWithKeyboard(
$chatId,
"⚠️ ¿Deseas cerrar sesión?\nDeberás volver a ingresar tu correo y código para continuar.",
[
[
['text' => '✅ Sí, cerrar sesión', 'callback_data' => 'logout_confirm'],
['text' => '❌ No, quedarme', 'callback_data' => 'menu'],
],
]
);
}
private function confirmLogout(string $chatId): void
{
$state = $this->getState($chatId);
unset($state['user_id'], $state['conv_id'], $state['step'], $state['data']);
$state['step'] = 'await_email';
$this->setState($chatId, $state);
$this->send($chatId, "👋 Sesión cerrada. Hasta pronto.");
$this->askEmail($chatId);
}
private function showMainMenu(string $chatId): void
{
$state = $this->getState($chatId);
$state['step'] = 'menu';
$state['data'] = [];
$this->setState($chatId, $state);
$this->sendWithKeyboard($chatId, "¿En qué te puedo ayudar?", [
[
['text' => '📺 Ver Servicios', 'callback_data' => 'svc_list'],
['text' => '🎁 Promociones', 'callback_data' => 'promo_list'],
],
[
['text' => '💰 Recargar Saldo', 'callback_data' => 'rec_init'],
['text' => '🔑 Mis Credenciales', 'callback_data' => 'creds'],
],
[
['text' => '📋 Historial', 'callback_data' => 'history'],
['text' => '👤 Mi Perfil', 'callback_data' => 'profile'],
],
[
['text' => '🚪 Cerrar sesión', 'callback_data' => 'logout_ask'],
],
]);
}
// ─── Services ─────────────────────────────────────────────
private function listServices(string $chatId): void
{
$state = $this->getState($chatId);
$rolId = isset($state['user_id'])
? (User::find($state['user_id'])?->rol_id ?? Role::where('nombre', 'cliente')->value('id'))
: Role::where('nombre', 'cliente')->value('id');
$servicios = Servicio::where('estado', 'activo')
->whereHas('tarifas', fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId))
->orderBy('ubicacion')
->get();
if ($servicios->isEmpty()) {
$this->send($chatId, "No hay servicios disponibles en este momento.");
$this->showMainMenu($chatId);
return;
}
$buttons = [];
foreach ($servicios as $s) {
$buttons[] = [['text' => $s->nombre, 'callback_data' => 'svc_view|' . $s->id]];
}
$buttons[] = [['text' => '🔙 Menú principal', 'callback_data' => 'menu']];
$this->sendWithKeyboard($chatId, "📺 *Selecciona el servicio:*", $buttons);
}
private function viewService(string $chatId, int $servicioId): void
{
$state = $this->getState($chatId);
$user = User::find($state['user_id']);
$rolId = $user?->rol_id ?? Role::where('nombre', 'cliente')->value('id');
$servicio = Servicio::with(['tarifas' => fn ($q) => $q->where('estado', 'activo')->where('rol_id', $rolId)])->find($servicioId);
if (! $servicio) {
$this->send($chatId, "Servicio no encontrado.");
return;
}
$tarifas = $servicio->tarifas;
if ($tarifas->isEmpty()) {
$this->send($chatId, "No hay planes disponibles para {$servicio->nombre}.");
$this->listServices($chatId);
return;
}
$buttons = [];
foreach ($tarifas as $t) {
$precio = CuentasHelper::precio($t->id, $state['user_id'], $t->valor);
$label = $servicio->por_tiempo
? "{$t->dias} días - \$" . number_format($precio)
: "{$t->pantallas} pantalla(s) / {$t->dias} días - \$" . number_format($precio);
$buttons[] = [['text' => $label, 'callback_data' => 'buy_start|' . $t->id]];
}
$buttons[] = [['text' => '🔙 Ver servicios', 'callback_data' => 'svc_list']];
$this->sendWithKeyboard($chatId, "📺 *{$servicio->nombre}* — elige un plan:", $buttons);
}
// ─── Purchase ─────────────────────────────────────────────
private function startPurchase(string $chatId, int $tarifaId): void
{
$state = $this->getState($chatId);
$tarifa = Tarifas::with('servicio')->find($tarifaId);
if (! $tarifa) {
$this->send($chatId, "Plan no encontrado.");
return;
}
if (CuentasHelper::contar($tarifa) < 1) {
$this->send($chatId, "Sin cuentas disponibles para este plan. Intenta con otro.");
$this->listServices($chatId);
return;
}
$user = User::with('saldo')->find($state['user_id']);
$saldo = $user->saldo?->valor ?? 0;
$valor = CuentasHelper::precio($tarifaId, $state['user_id'], $tarifa->valor);
$state['data'] = ['flow' => 'compra', 'tarifa_id' => $tarifaId, 'valor' => $valor, 'servicio' => $tarifa->servicio->nombre, 'servicio_id' => $tarifa->servicio_id];
$this->setState($chatId, $state);
$detalle = $tarifa->servicio->por_tiempo
? "{$tarifa->dias} días"
: "{$tarifa->pantallas} pantalla(s) / {$tarifa->dias} días";
$buttons = [];
if ($saldo >= $valor) {
$buttons[] = [['text' => "💳 Pagar con saldo (\$" . number_format($valor) . ")", 'callback_data' => 'buy_saldo']];
$buttons[] = [['text' => '🔙 Ver planes', 'callback_data' => 'svc_view|' . $tarifa->servicio_id]];
$this->sendWithKeyboard(
$chatId,
"*{$tarifa->servicio->nombre}* — {$detalle}\nPrecio: *\$" . number_format($valor) . "*\n\nElige cómo pagar:",
$buttons
);
} else {
$falta = $valor - $saldo;
$state['data']['pending_purchase'] = [
'type' => 'tarifa',
'tarifa_id' => $tarifaId,
'valor' => $valor,
'servicio' => $tarifa->servicio->nombre,
'servicio_id' => $tarifa->servicio_id,
];
$this->setState($chatId, $state);
$buttons[] = [['text' => "💳 Pagar con Bre-B directo (\$" . number_format($valor) . ")", 'callback_data' => 'breb_tarifa|' . $tarifaId . '|' . $valor]];
$buttons[] = [['text' => "💰 Recargar saldo (\$" . number_format($falta) . " faltantes)", 'callback_data' => 'rec_init']];
$buttons[] = [['text' => '🔙 Ver planes', 'callback_data' => 'svc_view|' . $tarifa->servicio_id]];
$this->sendWithKeyboard(
$chatId,
"*{$tarifa->servicio->nombre}* — {$detalle}\nPrecio: *\$" . number_format($valor) . "*\n\nSaldo actual: \$" . number_format($saldo) . " — te faltan *\$" . number_format($falta) . "*\n\n¿Cómo quieres pagar?",
$buttons
);
}
}
private function payWithSaldo(string $chatId): void
{
$state = $this->getState($chatId);
$data = $state['data'] ?? [];
if (($data['flow'] ?? '') !== 'compra') {
$this->showMainMenu($chatId);
return;
}
$tarifa = Tarifas::with('servicio')->find($data['tarifa_id'] ?? 0);
$user = User::with('saldo')->find($state['user_id']);
if (! $tarifa || ! $user) {
$this->send($chatId, "Ocurrió un error. Intenta de nuevo.");
return;
}
$precio = (float) ($data['valor'] ?? CuentasHelper::precio($tarifa->id, $state['user_id'], $tarifa->valor));
$saldo = $user->saldo;
if (! $saldo || $saldo->valor < $precio) {
$this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). El plan cuesta \$" . number_format($precio) . ".");
return;
}
$cuenta = CuentasHelper::buscar($tarifa);
if (! $cuenta) {
$this->send($chatId, "No hay cuentas disponibles en este momento. Intenta más tarde.");
return;
}
$nuevoSaldo = $saldo->valor - $precio;
DB::transaction(function () use ($tarifa, $user, $saldo, $cuenta, $precio, $nuevoSaldo, $state) {
$historial = Historiale::create([
'fecha_inicio' => now(),
'fecha_final' => now()->addDays($tarifa->dias),
'valor' => $precio,
'utilidad' => $tarifa->utilidad ?? 0,
'tipo_pago' => 'saldo',
'estado' => 'entregado',
'vendedor_id' => $state['user_id'],
'tarifa_id' => $tarifa->id,
'cliente_id' => $state['user_id'],
'nombre_cliente' => $user->name,
]);
Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]);
$cuenta->update(['estado' => 'ocupado']);
$saldo->update(['valor' => $nuevoSaldo]);
});
$this->send($chatId,
"🎉 *¡Muchas gracias por tu compra!*\n\n" .
"Disfruta tu servicio. Si tienes algún inconveniente no dudes en escribirnos, estamos aquí para ayudarte. 😊"
);
$code = "```\n";
$code .= "Servicio: {$tarifa->servicio->nombre}\n";
$code .= "Email: {$cuenta->correo}\n";
$code .= "Password: {$cuenta->password}\n";
if ($tarifa->pantallas) {
$code .= "Perfil: {$tarifa->pantallas}\n";
}
$code .= "Vence: " . now()->addDays($tarifa->dias)->format('d/m/Y') . "\n";
$code .= "Pagado: \$" . number_format($precio) . "\n";
$code .= "```";
$this->send($chatId, $code);
$state['data'] = [];
$this->setState($chatId, $state);
$this->showMainMenu($chatId);
}
// ─── Direct Bre-B purchase (sin saldo previo) ────────────
private function startDirectBrebPayment(string $chatId, int $tarifaId, int $valor): void
{
$state = $this->getState($chatId);
$tarifa = Tarifas::with('servicio')->find($tarifaId);
if (! $tarifa) {
$this->send($chatId, "Plan no encontrado.");
return;
}
$state['data']['pending_purchase'] = [
'type' => 'tarifa',
'tarifa_id' => $tarifaId,
'valor' => $valor,
'servicio' => $tarifa->servicio->nombre,
'servicio_id' => $tarifa->servicio_id,
];
$this->setState($chatId, $state);
$this->askRemitenteNombre($chatId, $valor);
}
private function startDirectBrebPromo(string $chatId, int $promoId, int $precio): void
{
$state = $this->getState($chatId);
$promo = \App\Models\Promociones::find($promoId);
if (! $promo) {
$this->send($chatId, "Promoción no encontrada.");
return;
}
$state['data']['pending_purchase'] = [
'type' => 'promo',
'promo_id' => $promoId,
'valor' => $precio,
'nombre' => $promo->nombre ?? 'Promoción',
];
$this->setState($chatId, $state);
$this->askRemitenteNombre($chatId, $precio);
}
// ─── Promos ───────────────────────────────────────────────
private function listPromos(string $chatId): void
{
$state = $this->getState($chatId);
$rolId = isset($state['user_id'])
? (User::find($state['user_id'])?->rol_id ?? Role::where('nombre', 'cliente')->value('id'))
: Role::where('nombre', 'cliente')->value('id');
$promos = Promociones::where('visible', 'true')
->where(fn ($q) => $q->whereNull('fecha_limite')->orWhere('fecha_limite', '>=', now()))
->with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])
->get();
if ($promos->isEmpty()) {
$this->send($chatId, "No hay promociones activas en este momento.");
$this->showMainMenu($chatId);
return;
}
$text = "🎁 *Promociones disponibles:*\n\n";
$buttons = [];
foreach ($promos as $p) {
$upric = isset($state['user_id'])
? Usuario_promo::where('usuario_id', $state['user_id'])->where('promocion_id', $p->id)->first()
: null;
$tp = $p->tarifaPromo->first();
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $p->precio);
$text .= "*{$p->nombre}* — \$" . number_format($precio) . "\n";
if ($p->descripcion) {
$text .= "{$p->descripcion}\n";
}
if ($p->fecha_limite) {
$text .= "Hasta: " . Carbon::parse($p->fecha_limite)->format('d/m/Y') . "\n";
}
$text .= "\n";
$buttons[] = [['text' => ($p->nombre ?? 'Promo') . ' — $' . number_format($precio), 'callback_data' => 'promo_view|' . $p->id]];
}
$buttons[] = [['text' => '🔙 Menú principal', 'callback_data' => 'menu']];
$this->sendWithKeyboard($chatId, $text, $buttons);
}
private function viewPromo(string $chatId, int $promoId): void
{
$state = $this->getState($chatId);
$rolId = isset($state['user_id'])
? (User::find($state['user_id'])?->rol_id ?? Role::where('nombre', 'cliente')->value('id'))
: Role::where('nombre', 'cliente')->value('id');
$promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId);
if (! $promo) {
$this->send($chatId, "Promoción no encontrada.");
return;
}
$user = isset($state['user_id']) ? User::with('saldo')->find($state['user_id']) : null;
$saldo = $user?->saldo?->valor ?? 0;
$upric = isset($state['user_id'])
? Usuario_promo::where('usuario_id', $state['user_id'])->where('promocion_id', $promoId)->first()
: null;
$tp = $promo->tarifaPromo->first();
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio);
$buttons = [];
if ($saldo >= $precio) {
$buttons[] = [['text' => "💳 Pagar con saldo (\$" . number_format($precio) . ")", 'callback_data' => 'promo_saldo|' . $promoId]];
$buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']];
$this->sendWithKeyboard(
$chatId,
"*{$promo->nombre}*\n\$" . number_format($precio) . "\n\nElige cómo pagar:",
$buttons
);
} else {
$falta = $precio - $saldo;
$state = $this->getState($chatId);
$state['data']['pending_purchase'] = [
'type' => 'promo',
'promo_id' => $promoId,
'valor' => $precio,
'nombre' => $promo->nombre ?? 'Promoción',
];
$this->setState($chatId, $state);
$buttons[] = [['text' => "💳 Pagar con Bre-B directo (\$" . number_format($precio) . ")", 'callback_data' => 'breb_promo|' . $promoId . '|' . $precio]];
$buttons[] = [['text' => "💰 Recargar saldo (\$" . number_format($falta) . " faltantes)", 'callback_data' => 'rec_init']];
$buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']];
$this->sendWithKeyboard(
$chatId,
"*{$promo->nombre}*\n\$" . number_format($precio) . "\n\nSaldo actual: \$" . number_format($saldo) . " — te faltan *\$" . number_format($falta) . "*\n\n¿Cómo quieres pagar?",
$buttons
);
}
}
private function buyPromoSaldo(string $chatId, int $promoId): void
{
$state = $this->getState($chatId);
$user = isset($state['user_id']) ? User::with('saldo')->find($state['user_id']) : null;
if (! $user) {
$this->send($chatId, "No se pudo procesar la compra.");
return;
}
$rolId = $user->rol_id;
$promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId);
if (! $promo) {
$this->send($chatId, "No se pudo procesar la compra.");
return;
}
$upric = Usuario_promo::where('usuario_id', $user->id)->where('promocion_id', $promoId)->first();
$tp = $promo->tarifaPromo->first();
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio);
$utilidad = $upric ? $upric->utilidad : ($tp ? $tp->utilidad : 0);
$saldo = $user->saldo;
if (! $saldo || $saldo->valor < $precio) {
$this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). La promo cuesta \$" . number_format($precio) . ".");
return;
}
$cuenta = Cuentas::where('promocion_id', $promoId)->where('estado', 'activo')->first();
if (! $cuenta) {
$this->send($chatId, "No hay cuentas disponibles para esta promoción.");
return;
}
$dias = (int) ($promo->dias ?? 30);
$nuevoSaldo = $saldo->valor - $precio;
DB::transaction(function () use ($promo, $user, $saldo, $cuenta, $precio, $utilidad, $promoId, $dias, $nuevoSaldo) {
$historial = Historiale::create([
'fecha_inicio' => now(),
'fecha_final' => now()->addDays($dias),
'valor' => $precio,
'utilidad' => $utilidad,
'tipo_pago' => 'saldo',
'estado' => 'entregado',
'vendedor_id' => $user->id,
'promocion_id' => $promoId,
'cliente_id' => $user->id,
'nombre_cliente' => $user->name,
]);
Historial_cuenta::create(['historial_id' => $historial->id, 'cuenta_id' => $cuenta->id]);
$cuenta->update(['estado' => 'ocupado']);
$saldo->update(['valor' => $nuevoSaldo]);
});
$this->send($chatId,
"🎉 *¡Muchas gracias por tu compra!*\n\n" .
"Disfruta tu servicio. Si tienes algún inconveniente no dudes en escribirnos, estamos aquí para ayudarte. 😊"
);
$code = "```\n";
$code .= "Promoción: {$promo->nombre}\n";
$code .= "Email: {$cuenta->correo}\n";
$code .= "Password: {$cuenta->password}\n";
$code .= "Vence: " . now()->addDays($dias)->format('d/m/Y') . "\n";
$code .= "Pagado: \$" . number_format($precio) . "\n";
$code .= "```";
$this->send($chatId, $code);
$this->showMainMenu($chatId);
}
// ─── Recharge ─────────────────────────────────────────────
private function showRechargeAmounts(string $chatId): void
{
$buttons = [
[
['text' => '$20.000', 'callback_data' => 'rec_amount|20000'],
['text' => '$50.000', 'callback_data' => 'rec_amount|50000'],
],
[
['text' => '$100.000', 'callback_data' => 'rec_amount|100000'],
['text' => '$200.000', 'callback_data' => 'rec_amount|200000'],
],
[
['text' => '$500.000', 'callback_data' => 'rec_amount|500000'],
['text' => '✏️ Otro valor', 'callback_data' => 'rec_custom'],
],
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
];
$this->sendWithKeyboard($chatId, "💰 *¿Cuánto quieres recargar?*", $buttons);
}
private function askCustomAmount(string $chatId): void
{
$state = $this->getState($chatId);
$state['step'] = 'await_amount';
$this->setState($chatId, $state);
$this->send($chatId, "Escribe el monto que deseas recargar (mínimo \$1.000):");
}
private function processCustomAmount(string $chatId, string $text): void
{
$monto = (int) preg_replace('/\D/', '', $text);
if ($monto < 1000) {
$this->send($chatId, "El monto debe ser al menos \$1.000. Escríbelo de nuevo:");
return;
}
$state = $this->getState($chatId);
$state['step'] = 'menu';
$this->setState($chatId, $state);
$this->chooseRechargeMethod($chatId, $monto);
}
private function chooseRechargeMethod(string $chatId, int $monto): void
{
if ($monto <= 0) {
return;
}
$llave = ChatConfig::get('recarga_banco_llave', '');
if (! $llave) {
$this->sendWithKeyboard($chatId, "⚠️ La recarga no está disponible en este momento. Contacta a un asesor.", [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]);
return;
}
$buttons = [
[['text' => '🔑 Bre-B', 'callback_data' => 'rec_breb|' . $monto]],
[['text' => '🔙 Volver', 'callback_data' => 'rec_init']],
];
$this->sendWithKeyboard(
$chatId,
"Recarga de *\$" . number_format($monto) . "*\n\n¿Cómo quieres pagar?",
$buttons
);
}
private function askRemitenteNombre(string $chatId, int $monto): void
{
$state = $this->getState($chatId);
$state['data']['monto'] = $monto;
$this->setState($chatId, $state);
$userId = $state['user_id'] ?? null;
$nombresPrevios = $userId ? SolicitudRecarga::nombresPrevios((int) $userId) : [];
if (empty($nombresPrevios)) {
$state['step'] = 'await_remitente_nombre';
$this->setState($chatId, $state);
$this->send($chatId, "👤 Escribe tu *nombre completo* tal como aparece en tu cuenta bancaria o billetera:");
return;
}
// Guardar nombres en state por índice (callback_data tiene límite de 64 chars)
$state['data']['nombres_previos'] = $nombresPrevios;
$this->setState($chatId, $state);
$keyboard = [];
foreach ($nombresPrevios as $i => $nombre) {
$keyboard[] = [['text' => $nombre, 'callback_data' => "breb_nom|{$i}"]];
}
$keyboard[] = [['text' => '✏️ Usar otro nombre', 'callback_data' => 'breb_nom|nuevo']];
$this->sendWithKeyboard($chatId, "👤 ¿Desde qué cuenta vas a transferir?\nElige un nombre anterior o escribe uno nuevo:", $keyboard);
}
private function seleccionarNombreRemitente(string $chatId, string $indiceONuevo): void
{
$state = $this->getState($chatId);
$monto = (int) ($state['data']['monto'] ?? 0);
if ($monto <= 0) {
$this->send($chatId, "Ocurrió un error. Por favor inicia el proceso de recarga de nuevo.");
$this->showMainMenu($chatId);
return;
}
if ($indiceONuevo === 'nuevo') {
$state['step'] = 'await_remitente_nombre';
$this->setState($chatId, $state);
$this->send($chatId, "👤 Escribe tu *nombre completo* tal como aparece en tu cuenta bancaria o billetera:");
return;
}
$nombres = $state['data']['nombres_previos'] ?? [];
$nombre = $nombres[(int) $indiceONuevo] ?? null;
if (! $nombre) {
$state['step'] = 'await_remitente_nombre';
$this->setState($chatId, $state);
$this->send($chatId, "No encontré ese nombre. Escríbelo de nuevo:");
return;
}
$state['step'] = 'menu';
$state['data']['nombre_remitente'] = $nombre;
$this->setState($chatId, $state);
$this->rechargeBreb($chatId, $monto);
}
private function processRemitenteNombre(string $chatId, string $texto): void
{
$nombre = trim($texto);
$palabras = array_filter(explode(' ', $nombre), fn ($p) => strlen($p) >= 2);
if (count($palabras) < 2) {
$this->send($chatId, "Escribe al menos nombre y apellido. Ejemplo: *Juan Garcia*");
return;
}
$state = $this->getState($chatId);
$monto = (int) ($state['data']['monto'] ?? 0);
if ($monto <= 0) {
$this->send($chatId, "Ocurrió un error. Por favor inicia el proceso de recarga de nuevo.");
$this->showMainMenu($chatId);
return;
}
$state['step'] = 'menu';
$state['data']['nombre_remitente'] = $nombre;
$this->setState($chatId, $state);
$this->rechargeBreb($chatId, $monto);
}
private function rechargeBreb(string $chatId, int $monto): void
{
$banco = ChatConfig::get('recarga_banco_nombre', '');
$llave = ChatConfig::get('recarga_banco_llave', '');
$titular = ChatConfig::get('recarga_banco_titular', '');
if (! $llave) {
$this->send($chatId, "El pago por Bre-B no está disponible en este momento.");
return;
}
$text = "🏧 *Pago por Bre-B*\n\n";
$text .= "Monto: *\$" . number_format($monto) . "*\n\n";
if ($banco) {
$text .= "Banco/Billetera: *{$banco}*\n";
}
$text .= "Llave: `{$llave}`\n";
if ($titular) {
$text .= "Titular: *{$titular}*\n";
}
$text .= "\n📋 *Al enviar el comprobante, asegúrate de que se vea claramente:*\n"
. "✓ Tu nombre completo\n"
. "✓ El valor transferido (\$" . number_format($monto) . ")\n"
. "✓ La fecha y hora de la transacción\n\n"
. "⚠️ Después de transferir, envía la *foto del comprobante* aquí y el sistema lo validará automáticamente."
. "\n_Tienes hasta 15 minutos después de la transferencia para enviar el soporte._";
if ($userId = ($this->getState($chatId)['user_id'] ?? null)) {
$state = $this->getState($chatId);
$nombre = $state['data']['nombre_remitente'] ?? null;
$solicitud = SolicitudRecarga::crear($userId, $monto, 'telegram', $nombre);
$state['data']['solicitud_recarga_id'] = $solicitud->id;
$this->setState($chatId, $state);
}
$this->sendWithKeyboard($chatId, $text, [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
]);
}
// ─── Credentials ──────────────────────────────────────────
private function showCredentials(string $chatId): void
{
$state = $this->getState($chatId);
$historiales = Historiale::where('cliente_id', $state['user_id'])
->where('estado', 'entregado')
->where('fecha_final', '>=', now())
->with(['tarifa.servicio', 'cuentas'])
->orderBy('fecha_final', 'desc')
->limit(10)
->get();
if ($historiales->isEmpty()) {
$this->send($chatId, "No tienes planes activos en este momento.");
$this->showMainMenu($chatId);
return;
}
$this->send($chatId, "🔑 *Tus planes activos:*");
foreach ($historiales as $h) {
foreach ($h->cuentas as $c) {
$text = "🎬 *" . ($h->tarifa?->servicio?->nombre ?? 'Servicio') . "*\n";
$text .= "📧 Email: `{$c->correo}`\n";
$text .= "🔑 Password: `{$c->password}`\n";
if ($h->tarifa?->pantallas) {
$text .= "📺 Perfil: {$h->tarifa->pantallas}\n";
}
$text .= "📅 Vence: " . Carbon::parse($h->fecha_final)->format('d/m/Y');
$this->send($chatId, $text);
}
}
$this->sendWithKeyboard($chatId, "", [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]);
}
// ─── History ──────────────────────────────────────────────
private function showHistory(string $chatId): void
{
$state = $this->getState($chatId);
$historiales = Historiale::where('cliente_id', $state['user_id'])
->with('tarifa.servicio')
->orderBy('created_at', 'desc')
->limit(10)
->get();
if ($historiales->isEmpty()) {
$this->send($chatId, "No tienes compras registradas.");
$this->showMainMenu($chatId);
return;
}
$text = "📋 *Tus últimas compras:*\n\n";
foreach ($historiales as $h) {
$activo = Carbon::parse($h->fecha_final)->gte(now()) ? '✅' : '❌';
$nombre = $h->tarifa?->servicio?->nombre ?? ($h->promocion_id ? 'Promo' : 'Plan');
$fecha = Carbon::parse($h->created_at)->format('d/m/Y');
$text .= "{$activo} {$nombre}\$" . number_format($h->valor) . " ({$fecha})\n";
}
$this->sendWithKeyboard($chatId, $text, [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]);
}
// ─── Profile ──────────────────────────────────────────────
private function showProfile(string $chatId): void
{
$state = $this->getState($chatId);
$user = User::with('saldo')->find($state['user_id']);
$text = "👤 *Tu perfil*\n\n";
$text .= "Nombre: *{$user->name}*\n";
$text .= "Email: {$user->email}\n";
if ($user->celular) {
$text .= "Celular: {$user->celular}\n";
}
$text .= "Saldo: *\$" . number_format($user->saldo?->valor ?? 0) . "*";
$this->sendWithKeyboard($chatId, $text, [[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]);
}
// ─── Agent ────────────────────────────────────────────────
private function transferToAgent(string $chatId): void
{
$state = $this->getState($chatId);
if ($state['conv_id'] ?? null) {
ChatConversation::where('id', $state['conv_id'])->update(['estado' => 'agente']);
}
$this->send($chatId, ChatConfig::get('mensaje_transferencia', 'Un asesor se comunicará contigo en breve. Por favor espera.'));
}
// ─── Apply validated payment ──────────────────────────────
private function applyPayment(string $chatId, int $monto, ?string $ref): void
{
$state = $this->getState($chatId);
if (! ($state['user_id'] ?? null) || $monto <= 0) {
$this->send($chatId, "No se pudo aplicar la recarga.");
return;
}
$user = User::with('saldo')->find($state['user_id']);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $state['user_id'], 'valor' => 0]);
$nuevoSaldo = $saldo->valor + $monto;
$saldo->update(['valor' => $nuevoSaldo]);
$this->send(
$chatId,
"✅ Recarga de *\$" . number_format($monto) . "* aplicada.\nNuevo saldo: *\$" . number_format($nuevoSaldo) . "*"
);
$this->showMainMenu($chatId);
}
// ─── Free text fallback ───────────────────────────────────
private function detectarPorKeywords(string $texto): ?string
{
// Solo intents genéricos sin parámetros — los específicos (con nombre de servicio o monto)
// los maneja GeminiAgentService para extraer parámetros y responder conversacionalmente.
$patrones = [
'recarga.iniciar' => '/\b(recargar?|recarg[ao]|cargar\s+saldo|poner\s+plata|depositar|agregar\s+plata|meter\s+plata)\b/i',
'promo.listar' => '/\b(promo|oferta|combo|descuento)\b/i',
'credenciales.listar' => '/\b(credencial|contrase[ñn]a)\b|no\s+(puedo|pude)\s+entrar|no\s+(inicia|está\s+iniciando|abre|entra|carga|funciona|sirve|jala)\s+(sesi[oó]n|netflix|spotify|disney|hbo|max|prime|crunchyroll|la\s+cuenta|el\s+servicio|el\s+acceso)|error\s+de\s+(acceso|inicio|sesi[oó]n)|mis\s+datos\s+de/i',
'historial.ver' => '/\b(historial|mis\s+compras?|mis\s+pedidos?)\b/i',
'perfil.ver' => '/\bmi\s+perfil\b/i',
];
foreach ($patrones as $accion => $patron) {
if (preg_match($patron, $texto)) {
return $accion;
}
}
return null;
}
private function handleFreeText(string $chatId, array $state): void
{
if (! ($state['user_id'] ?? null)) {
$this->askEmail($chatId);
return;
}
$texto = $state['data']['last_text'] ?? '';
// Detección rápida para intents genéricos; extrae monto si viene en el texto
if ($texto) {
$accionLocal = $this->detectarPorKeywords($texto);
if ($accionLocal) {
$accionData = [];
if ($accionLocal === 'recarga.iniciar') {
if (preg_match('/\$?\s*(\d{1,3}(?:[.,]\d{3})+|\d{4,})\b/', $texto, $m)) {
$monto = (int) preg_replace('/[.,]/', '', $m[1]);
if ($monto >= 1000) {
$accionData['monto'] = $monto;
}
}
}
$this->dispatchAction($chatId, $accionLocal, $state, $accionData);
return;
}
}
// Agente conversacional: entiende lenguaje natural, extrae parámetros y responde
if ($texto) {
$contexto = GeminiAgentService::buildContexto($state, $state['user_id'] ?? null);
$resultado = app(GeminiAgentService::class)->responder(
$texto, $contexto, $state['user_id'] ?? null, 'telegram'
);
if ($resultado['respuesta']) {
$this->send($chatId, $resultado['respuesta']);
}
if ($resultado['accion']) {
$this->dispatchAction($chatId, $resultado['accion'], $state, $resultado['accion_data']);
return;
}
if ($resultado['respuesta']) {
return; // El agente respondió la pregunta, sin acción que ejecutar
}
}
$this->showMainMenu($chatId);
}
private function dispatchAction(string $chatId, string $action, array $state, array $accionData = []): void
{
match ($action) {
'servicios.listar' => isset($accionData['servicio'])
? $this->viewServiceByName($chatId, (string) $accionData['servicio'])
: $this->listServices($chatId),
'recarga.iniciar' => isset($accionData['monto']) && (int) $accionData['monto'] > 0
? $this->chooseRechargeMethod($chatId, (int) $accionData['monto'])
: $this->showRechargeAmounts($chatId),
'promo.listar' => $this->listPromos($chatId),
'credenciales.listar' => $this->showCredentials($chatId),
'historial.ver' => $this->showHistory($chatId),
'perfil.ver' => $this->showProfile($chatId),
'asesor.solicitar' => $this->showMainMenu($chatId),
default => $this->showMainMenu($chatId),
};
}
private function viewServiceByName(string $chatId, string $nombre): void
{
$servicio = Servicio::whereRaw('LOWER(nombre) LIKE ?', ['%' . strtolower($nombre) . '%'])
->where('estado', 'activo')
->first();
$servicio
? $this->viewService($chatId, $servicio->id)
: $this->listServices($chatId);
}
// ─── State management ─────────────────────────────────────
private function getState(string $chatId): array
{
return Cache::get("tgbot_state_{$chatId}", ['step' => 'await_email', 'data' => []]);
}
private function setState(string $chatId, array $state): void
{
Cache::put("tgbot_state_{$chatId}", $state, now()->addSeconds(self::STATE_TTL));
}
// ─── Telegram API ─────────────────────────────────────────
public function registrarComandos(): bool
{
return $this->apiCall('setMyCommands', [
'commands' => [
['command' => 'start', 'description' => 'Iniciar / Menú principal'],
['command' => 'menu', 'description' => 'Ver el menú principal'],
['command' => 'salir', 'description' => 'Volver al menú desde cualquier punto'],
['command' => 'cancelar','description' => 'Cancelar la acción actual'],
],
]);
}
public function send(string $chatId, string $text): bool
{
if ($this->convId) {
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'contenido' => $text,
'leido' => true,
]);
}
return $this->apiCall('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown',
]);
}
public function sendWithKeyboard(string $chatId, string $text, array $inlineKeyboard): bool
{
if ($this->convId) {
ChatMessage::create([
'conversation_id' => $this->convId,
'tipo' => 'bot',
'contenido' => $text,
'leido' => true,
]);
}
return $this->apiCall('sendMessage', [
'chat_id' => $chatId,
'text' => $text ?: '', // zero-width space fallback
'parse_mode' => 'Markdown',
'reply_markup' => json_encode(['inline_keyboard' => $inlineKeyboard]),
]);
}
private function answerCallback(string $callbackQueryId): void
{
$this->apiCall('answerCallbackQuery', ['callback_query_id' => $callbackQueryId]);
}
private function apiCall(string $method, array $data): bool
{
$token = ChatConfig::get('telegram_token');
if (! $token) {
return false;
}
try {
$response = Http::timeout(10)
->post("https://api.telegram.org/bot{$token}/{$method}", $data);
return $response->successful();
} catch (\Throwable $e) {
Log::warning("[TelegramBot] apiCall {$method} failed: " . $e->getMessage());
return false;
}
}
private function downloadTelegramFile(string $fileId): ?array
{
$token = ChatConfig::get('telegram_token');
if (! $token) {
return null;
}
try {
$info = Http::timeout(10)->get("https://api.telegram.org/bot{$token}/getFile", ['file_id' => $fileId]);
$filePath = $info->json('result.file_path');
if (! $filePath) {
return null;
}
$content = Http::timeout(30)->get("https://api.telegram.org/file/bot{$token}/{$filePath}")->body();
if (! $content) {
return null;
}
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
$mime = match ($ext) {
'png' => 'image/png',
'webp' => 'image/webp',
default => 'image/jpeg',
};
return ['content' => $content, 'mime' => $mime];
} catch (\Throwable $e) {
Log::warning('[TelegramBot] downloadTelegramFile failed: ' . $e->getMessage());
return null;
}
}
// ─── Helpers ──────────────────────────────────────────────
private function maskEmail(string $email): string
{
[$local, $domain] = explode('@', $email, 2);
$len = mb_strlen($local);
$visible = min(4, $len); // siempre 4 caracteres máximo
return mb_substr($local, 0, $visible) . str_repeat('*', max(0, $len - $visible)) . '@' . $domain;
}
}