Files
sirpremiumv2/app/Services/TelegramBotService.php
T
LizandroandClaude Sonnet 4.6 e42dd40d74 feat: registrar recarga en tabla recargas al auto-aplicar (Telegram y web chat)
Usa el mismo modelo recarga que el flujo admin, con status='Confirmado'
y reference='breb-bot/chat-{solicitud_id}' para trazabilidad.

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

1629 lines
65 KiB
PHP
Executable File
Raw 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\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'])) {
$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),
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),
'buy_mp' => $this->payWithMP($chatId),
'promo_list' => $this->listPromos($chatId),
'promo_view' => $this->viewPromo($chatId, (int) ($parts[1] ?? 0)),
'promo_saldo' => $this->buyPromoSaldo($chatId, (int) ($parts[1] ?? 0)),
'promo_mp' => $this->buyPromoMP($chatId, (int) ($parts[1] ?? 0)),
'rec_init' => $this->showRechargeAmounts($chatId),
'rec_custom' => $this->askCustomAmount($chatId),
'rec_amount' => $this->chooseRechargeMethod($chatId, (int) ($parts[1] ?? 0)),
'rec_mp' => $this->rechargeMP($chatId, (int) ($parts[1] ?? 0)),
'rec_breb' => $this->rechargeBreb($chatId, (int) ($parts[1] ?? 0)),
'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 = $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') {
$state['data']['pago_pendiente'] = $datosPago;
$this->setState($chatId, $state);
$this->sendWithKeyboard($chatId,
"⏳ *Por ahora no encontramos tu pago de confirmación.*\n_Puede 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']],
]
);
} 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']],
]
);
}
}
private function autoAplicarRecarga(string $chatId, array $state, SolicitudRecarga $solicitud, int $monto): void
{
$usuarioId = $state['user_id'];
$user = User::with('saldo')->find($usuarioId);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $usuarioId, 'valor' => 0]);
$nuevoSaldo = $saldo->valor + $monto;
$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();
unset($state['data']['pago_pendiente'], $state['data']['solicitud_recarga_id']);
$this->setState($chatId, $state);
$this->send($chatId,
"✅ *¡Tu recarga de \$" . number_format($monto) . " fue aplicada exitosamente!*\n"
. "Tu nuevo saldo es *\$" . number_format($nuevoSaldo) . "*"
);
$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;
$buttons[] = [['text' => '💰 Recargar saldo', '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\nRecarga para continuar:",
$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]);
});
$text = "✅ *¡Compra exitosa!*\n\n";
$text .= "🎬 *{$tarifa->servicio->nombre}*\n";
$text .= "📧 Email: `{$cuenta->correo}`\n";
$text .= "🔑 Password: `{$cuenta->password}`\n";
if ($tarifa->pantallas) {
$text .= "📺 Perfil: {$tarifa->pantallas}\n";
}
$text .= "📅 Vence: " . now()->addDays($tarifa->dias)->format('d/m/Y') . "\n";
$text .= "💰 Pagado: *\$" . number_format($precio) . "*\n";
$text .= "\nSaldo restante: *\$" . number_format($nuevoSaldo) . "*";
$this->send($chatId, $text);
$state['data'] = [];
$this->setState($chatId, $state);
$this->showMainMenu($chatId);
}
private function payWithMP(string $chatId): void
{
$state = $this->getState($chatId);
$data = $state['data'] ?? [];
if (($data['flow'] ?? '') !== 'compra') {
$this->showMainMenu($chatId);
return;
}
$valor = (int) ($data['valor'] ?? 0);
$servicio = $data['servicio'] ?? 'Plan streaming';
$ref = 'CHAT-' . strtoupper(Str::random(12));
$url = $this->createMPPreference($servicio, $valor, $ref);
if (! $url) {
$this->send($chatId, "No se pudo generar el link de pago. Intenta de nuevo.");
return;
}
$this->sendWithKeyboard(
$chatId,
"🏦 *Pago con MercadoPago*\n\n{$servicio}\$" . number_format($valor) . "\nRef: `{$ref}`\n\n[👉 Pagar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
$state['data'] = [];
$this->setState($chatId, $state);
}
// ─── 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()))
->whereHas('tarifaPromo', fn ($q) => $q->where('rol_id', $rolId))
->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;
$buttons[] = [['text' => '💰 Recargar saldo', '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\nRecarga para continuar:",
$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]);
});
$text = "✅ *¡Promo activada!*\n\n";
$text .= "🎬 *{$promo->nombre}*\n";
$text .= "📧 Email: `{$cuenta->correo}`\n";
$text .= "🔑 Password: `{$cuenta->password}`\n";
$text .= "📅 Vence: " . now()->addDays($dias)->format('d/m/Y') . "\n";
$text .= "\nSaldo restante: *\$" . number_format($nuevoSaldo) . "*";
$this->send($chatId, $text);
$this->showMainMenu($chatId);
}
private function buyPromoMP(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) {
return;
}
$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);
$ref = 'PROMO-' . strtoupper(Str::random(10));
$url = $this->createMPPreference($promo->nombre ?? 'Promocion streaming', (int) $precio, $ref);
if (! $url) {
$this->send($chatId, "No se pudo generar el link de pago. Intenta de nuevo.");
return;
}
$this->sendWithKeyboard(
$chatId,
"🏦 *Pago con MercadoPago*\n\n{$promo->nombre}\$" . number_format($precio) . "\nRef: `{$ref}`\n\n[👉 Pagar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
}
// ─── 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', '');
$buttons = [
[['text' => '🏦 MercadoPago', 'callback_data' => 'rec_mp|' . $monto]],
];
if ($llave) {
$buttons[] = [['text' => '🔑 Bre-B', 'callback_data' => 'rec_breb|' . $monto]];
}
$buttons[] = [['text' => '🔙 Volver', 'callback_data' => 'rec_init']];
$this->sendWithKeyboard(
$chatId,
"Recarga de *\$" . number_format($monto) . "*\n\n¿Cómo quieres pagar?",
$buttons
);
}
private function rechargeMP(string $chatId, int $monto): void
{
$state = $this->getState($chatId);
if ($monto <= 0) {
$this->send($chatId, "Monto inválido.");
return;
}
$ref = 'REC-' . strtoupper(Str::random(12));
$url = $this->createMPPreference('Recarga de saldo', $monto, $ref);
if (! $url) {
$this->send($chatId, "No se pudo generar el link de recarga. Intenta de nuevo.");
return;
}
if ($state['user_id'] ?? null) {
$user = User::with('saldo')->find($state['user_id']);
$saldo = $user->saldo ?? Saldo::create(['usuario_id' => $state['user_id'], 'valor' => 0]);
recarga::create([
'monto' => $monto,
'reference' => $ref,
'status' => 'pendiente_mp',
'usuario_id' => $state['user_id'],
'saldo_id' => $saldo->id,
]);
}
$this->sendWithKeyboard(
$chatId,
"🏦 *Recarga con MercadoPago*\n\n\$" . number_format($monto) . "\nRef: `{$ref}`\n\n[👉 Recargar ahora]({$url})\n\nDespués de pagar, envía la foto del comprobante aquí.",
[[['text' => '🔙 Menú principal', 'callback_data' => 'menu']]]
);
}
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⚠️ 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._";
// Registrar solicitud de recarga pendiente
if ($userId = ($this->getState($chatId)['user_id'] ?? null)) {
$solicitud = SolicitudRecarga::crear($userId, $monto, 'telegram');
$state = $this->getState($chatId);
$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 sin parámetros
if ($texto) {
$accionLocal = $this->detectarPorKeywords($texto);
if ($accionLocal) {
$this->dispatchAction($chatId, $accionLocal, $state);
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;
}
private function createMPPreference(string $titulo, int $valor, string $ref): ?string
{
$accessToken = config('services.mercadopago.token');
if (! $accessToken) {
return null;
}
try {
$response = Http::withToken($accessToken)
->timeout(10)
->post('https://api.mercadopago.com/checkout/preferences', [
'items' => [[
'title' => $titulo,
'quantity' => 1,
'unit_price' => $valor,
'currency_id' => 'COP',
]],
'external_reference' => $ref,
'back_urls' => [
'success' => url('/chat'),
'failure' => url('/chat'),
'pending' => url('/chat'),
],
'auto_return' => 'approved',
'payment_methods' => [
'excluded_payment_types' => [['id' => 'ticket']],
],
]);
return $response->successful() ? $response->json('init_point') : null;
} catch (\Throwable $e) {
Log::warning('[TelegramMP] Error: ' . $e->getMessage());
return null;
}
}
}