Files
sirpremiumv2/app/Services/TelegramBotService.php
T
LizandroandClaude Sonnet 4.6 3f2bdc0d47 Add menu/salir commands to Telegram bot
- Any message matching salir/menu/volver/cancelar/inicio resets flow and shows main menu
- Register /menu /salir /cancelar as official bot commands via setMyCommands
- Commands are registered automatically when saving the webhook from admin panel

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

1227 lines
47 KiB
PHP
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\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\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\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class TelegramBotService
{
private const STATE_TTL = 7200; // 2 hours
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()]);
if ($conv->estado === 'agente') {
return; // Human agent is handling this
}
}
}
$lower = strtolower(trim($text));
// Palabras que siempre regresan al menú, desde cualquier punto del flujo
$menuTriggers = [
'/start', '/menu', '/salir', '/inicio', '/cancelar',
'menu', 'menú', 'inicio', 'hola', 'salir', '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';
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),
'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->transferToAgent($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. Contacta a un asesor.");
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);
if (! $datosPago) {
$this->send($chatId, "No pude leer el comprobante. Intenta con una foto más clara.");
return;
}
$resultado = app(PagoValidadorService::class)->validar($datosPago);
$monto = number_format($datosPago['valor'] ?? 0);
$ref = $datosPago['referencia'] ?? 'N/A';
$texto = "📊 *Resultado del análisis:*\n\n";
$texto .= "Monto: *\$$monto*\n";
$texto .= "Referencia: `{$ref}`\n";
$texto .= "Estado: *" . ($resultado['estado'] === 'confirmado' ? '✅ Confirmado' : '⚠️ No confirmado') . "*";
if ($resultado['estado'] === 'confirmado') {
$buttons = [
[['text' => "✅ Aplicar recarga de \$$monto", 'callback_data' => 'pay_apply|' . ($datosPago['valor'] ?? 0) . '|' . $ref]],
[['text' => '🔙 Menú principal', 'callback_data' => 'menu']],
];
} else {
$buttons = [
[['text' => '🧑 Hablar con asesor', 'callback_data' => 'agent']],
[['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.");
}
}
// ─── 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
{
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->send($chatId, "❌ Eso no parece un correo válido. Escríbelo de nuevo:");
return;
}
$user = User::where('email', $email)->first();
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;
}
$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);
}
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));
try {
Mail::to($user->email)->send(new ChatOtpMail($codigo, $user->name));
} catch (\Throwable $e) {
Log::warning('[TelegramOTP] Error enviando correo: ' . $e->getMessage());
}
$this->setState($chatId, [
'step' => 'await_otp',
'contact_id' => $contact->id,
'data' => [],
]);
$masked = $this->maskEmail($user->email);
$this->send($chatId, "✉️ Te enviamos un código a *{$masked}*.\n\nEscribe el código de 6 dígitos:");
}
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 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' => '🧑 Hablar con asesor', 'callback_data' => 'agent'],
],
]);
}
// ─── Services ─────────────────────────────────────────────
private function listServices(string $chatId): void
{
$servicios = Servicio::where('estado', 'activo')->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 = $this->efectivePrice($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;
}
$disponibles = Cuentas::where('tarifa_id', $tarifaId)->where('estado', 'activo')->count();
if ($disponibles < 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 = $this->efectivePrice($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' => '🏦 MercadoPago', 'callback_data' => 'buy_mp']];
$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
);
}
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;
}
$saldo = $user->saldo;
if (! $saldo || $saldo->valor < $tarifa->valor) {
$this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). El plan cuesta \$" . number_format($tarifa->valor) . ".");
return;
}
$cuenta = Cuentas::where('tarifa_id', $tarifa->id)->where('estado', 'activo')->first();
if (! $cuenta) {
$this->send($chatId, "No hay cuentas disponibles en este momento. Intenta más tarde.");
return;
}
$historial = Historiale::create([
'fecha_inicio' => now(),
'fecha_final' => now()->addDays($tarifa->dias),
'valor' => $tarifa->valor,
'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']);
$nuevoSaldo = $saldo->valor - $tarifa->valor;
$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 .= "\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 o contacta a un asesor.");
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' => '🏦 MercadoPago', 'callback_data' => 'promo_mp|' . $promoId]];
$buttons[] = [['text' => '🔙 Promociones', 'callback_data' => 'promo_list']];
$this->sendWithKeyboard(
$chatId,
"*{$promo->nombre}*\n\$" . number_format($precio) . "\n\nElige cómo pagar:",
$buttons
);
}
private function buyPromoSaldo(string $chatId, int $promoId): void
{
$state = $this->getState($chatId);
$user = isset($state['user_id']) ? User::with('saldo')->find($state['user_id']) : null;
if (! $user) {
$this->send($chatId, "No se pudo procesar la compra.");
return;
}
$rolId = $user->rol_id;
$promo = Promociones::with(['tarifaPromo' => fn ($q) => $q->where('rol_id', $rolId)])->find($promoId);
if (! $promo) {
$this->send($chatId, "No se pudo procesar la compra.");
return;
}
$upric = Usuario_promo::where('usuario_id', $user->id)->where('promocion_id', $promoId)->first();
$tp = $promo->tarifaPromo->first();
$precio = $upric ? $upric->precio : ($tp ? $tp->precio : $promo->precio);
$utilidad = $upric ? $upric->utilidad : ($tp ? $tp->utilidad : 0);
$saldo = $user->saldo;
if (! $saldo || $saldo->valor < $precio) {
$this->send($chatId, "Saldo insuficiente (\$" . number_format($saldo?->valor ?? 0) . "). La promo cuesta \$" . number_format($precio) . ".");
return;
}
$cuenta = Cuentas::where('promocion_id', $promoId)->where('estado', 'activo')->first();
if (! $cuenta) {
$this->send($chatId, "No hay cuentas disponibles para esta promoción.");
return;
}
$historial = Historiale::create([
'fecha_inicio' => now(),
'fecha_final' => now()->addDays(30),
'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']);
$nuevoSaldo = $saldo->valor - $precio;
$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(30)->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 o contacta a un asesor.");
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 o contacta a un asesor.");
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. Usa MercadoPago o contacta a un asesor.");
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')
->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 handleFreeText(string $chatId, array $state): void
{
if (! ($state['user_id'] ?? null)) {
$this->askEmail($chatId);
return;
}
if ($state['conv_id'] ?? null) {
$conv = ChatConversation::find($state['conv_id']);
if ($conv && $conv->estado === 'agente') {
return;
}
}
$this->showMainMenu($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 efectivePrice(int $tarifaId, ?int $userId, float $base): float
{
if ($userId) {
$pref = Preferencial::where('usuario_id', $userId)->where('tarifa_id', $tarifaId)->first();
if ($pref) {
return (float) $pref->valor;
}
}
return (float) $base;
}
private function maskEmail(string $email): string
{
[$local, $domain] = explode('@', $email, 2);
$visible = mb_substr($local, 0, min(3, mb_strlen($local)));
return $visible . str_repeat('*', max(0, mb_strlen($local) - 3)) . '@' . $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;
}
}
}