Files
sirpremiumv2/app/Services/TelegramBotService.php
T
LizandroandClaude Sonnet 4.6 b7572a0fb9 fix: bot no improvisa soporte humano — rutas correctas para problemas de acceso
- Keywords: captura "no inicia sesión Netflix", "no abre", "error de acceso" → credenciales.listar
- GeminiAgent prompt: bot no pide pantallazos ni promete revisiones; problemas de acceso → credenciales.listar

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

1540 lines
61 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\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_apply' => $this->applyPayment($chatId, (int) ($parts[1] ?? 0), $parts[2] ?? null),
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;
}
$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, $state['user_id'] ?? null, '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;
}
$usuarioId = $state['user_id'] ?? null;
$nombreUsuario = $usuarioId ? (User::find($usuarioId)?->name ?? '') : '';
$resultado = app(PagoValidadorService::class)->validar(
$datosPago, $usuarioId, $nombreUsuario, 'telegram'
);
$monto = number_format($datosPago['valor'] ?? 0);
$ref = $datosPago['referencia'] ?? 'N/A';
$valor = (int) ($datosPago['valor'] ?? 0);
$texto = "📊 *Resultado del análisis:*\n\n";
$texto .= "Banco: " . ($datosPago['banco'] ?? 'N/A') . "\n";
$texto .= "Monto: *\$$monto*\n";
$texto .= "Referencia: `{$ref}`\n";
if ($datosPago['fecha'] ?? null) {
$texto .= "Fecha: " . $datosPago['fecha'];
if ($datosPago['hora'] ?? null) {
$texto .= " " . $datosPago['hora'];
}
$texto .= "\n";
}
if ($resultado['estado'] === 'confirmado') {
$texto .= "\nEstado: *✅ Confirmado por correo*";
$buttons = [
[['text' => "✅ Aplicar recarga de \$$monto", 'callback_data' => 'pay_apply|' . $valor . '|' . $ref]],
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
];
} elseif ($resultado['estado'] === 'ya_usado') {
$texto .= "\nEstado: *⛔ Comprobante ya utilizado*\nEste pago ya fue registrado anteriormente.";
$buttons = [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
];
} else {
$motivo = match ($resultado['motivo'] ?? '') {
'correo_deshabilitado' => '📧 Verificación por correo no configurada',
'error_imap' => '📧 Error al leer correos IMAP',
'sin_correos' => '📧 Sin correos recientes en bandeja',
'sin_coincidencia' => '📧 Ningún correo coincide con este monto/fecha',
'error_sistema' => '⚙️ Error interno, intenta de nuevo en unos minutos',
default => '📧 No se pudo verificar automáticamente',
};
$texto .= "\nEstado: *⚠️ No confirmado*\n_{$motivo}_";
$buttons = [
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
];
}
$this->sendWithKeyboard($chatId, $texto, $buttons);
} catch (\Throwable $e) {
Log::warning('[TelegramBot] Error procesando foto: ' . $e->getMessage());
$this->send($chatId, "Error analizando el comprobante. Por favor intenta de nuevo.");
}
}
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.";
$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;
}
}
}