diff --git a/apps/backend/src/lib/telegram.ts b/apps/backend/src/lib/telegram.ts index 5129976..fc8b6fb 100644 --- a/apps/backend/src/lib/telegram.ts +++ b/apps/backend/src/lib/telegram.ts @@ -1,8 +1,11 @@ -import { Bot } from "grammy"; +import type { Context } from "grammy"; +import { Bot, InlineKeyboard } from "grammy"; +import type { QuoteType } from "../generated/prisma/client"; +import type { QuoteStatus } from "../modules/telegram/telegram.service"; import { formatPrice, formatTime, - getBeloQuoteStatus, + getQuoteStatus, isChatValidated, validateCode, } from "../modules/telegram/telegram.service"; @@ -10,6 +13,12 @@ import { logger } from "./logger"; const token = process.env.BOT_TOKEN; +const QUOTE_COMMANDS = { + BELO: { label: "BELO · USDC/ARS", icon: "💰" }, + BLUE: { label: "Dólar Blue", icon: "💵" }, + BNA: { label: "Dólar Oficial", icon: "🏦" }, +} as const satisfies Record; + let botInstance: Bot | null = null; let botUsername: string | null = null; @@ -21,6 +30,54 @@ export function getBotUsername(): string | null { return botUsername; } +function buildQuoteMessage(type: QuoteType, status: QuoteStatus): string { + const { label, icon } = QUOTE_COMMANDS[type]; + const progress = Math.round(status.progressPercentage ?? 0); + return [ + `${icon} ${label}`, + "", + `Compra: $${formatPrice(status.buy)}`, + `Venta: $${formatPrice(status.sell)}`, + "", + `Mínimo del día: $${formatPrice(status.minBuy)}`, + `Máximo del día: $${formatPrice(status.maxBuy)}`, + `Avance del día: ${progress}%`, + "", + `🕐 ${formatTime(status.timeStamp)}`, + ].join("\n"); +} + +async function replyQuote(ctx: Context, type: QuoteType): Promise { + const chatId = ctx.chat?.id; + if (!chatId) return; + + const validated = await isChatValidated(BigInt(chatId)); + if (!validated) { + await ctx.reply( + "🔒 Tu cuenta no está conectada. Enviá /menu para ver cómo conectarla.", + ); + return; + } + + const status = await getQuoteStatus(type); + if (!status) { + await ctx.reply( + `❌ No hay datos de ${QUOTE_COMMANDS[type].label} disponibles todavía.`, + ); + return; + } + + await ctx.reply(buildQuoteMessage(type, status), { parse_mode: "HTML" }); +} + +function mainMenu(): InlineKeyboard { + return new InlineKeyboard() + .text(`${QUOTE_COMMANDS.BELO.icon} BELO`, "quote:BELO") + .text(`${QUOTE_COMMANDS.BLUE.icon} Blue`, "quote:BLUE") + .row() + .text(`${QUOTE_COMMANDS.BNA.icon} Oficial`, "quote:BNA"); +} + export async function startBot(): Promise { if (!token) { logger.warn("BOT_TOKEN not set, Telegram bot not started"); @@ -31,41 +88,38 @@ export async function startBot(): Promise { const bot = new Bot(token); bot.command("start", async (ctx) => { - await ctx.reply( - "🔗 Enviá el código de verificación que aparece en la Configuración de la app para conectar tu cuenta de Telegram.\n\nUna vez conectado, recibirás notificaciones cuando BELO alcance el máximo del día.", - ); + const validated = await isChatValidated(BigInt(ctx.chat.id)); + if (validated) { + await ctx.reply("👋 ¡Hola! Seleccioná una cotización:", { + reply_markup: mainMenu(), + }); + } else { + await ctx.reply( + "🔗 Enviá el código de verificación que aparece en la Configuración de la app para conectar tu cuenta de Telegram.\n\nUna vez conectado, recibirás notificaciones cuando BELO alcance el máximo del día y podrás consultar las cotizaciones con /menu.", + ); + } }); - bot.command("belo", async (ctx) => { - const validated = await isChatValidated(BigInt(ctx.chat.id)); - if (!validated) { - await ctx.reply( - "🔒 Tu cuenta no está conectada. Enviá /start para ver cómo conectarla.", - ); - return; - } + bot.command("menu", async (ctx) => { + await ctx.reply("📊 Menú\n\nElegí una cotización:", { + parse_mode: "HTML", + reply_markup: mainMenu(), + }); + }); - const status = await getBeloQuoteStatus(); - if (!status) { - await ctx.reply("❌ No hay datos de BELO disponibles todavía."); - return; - } + bot.command("belo", (ctx) => replyQuote(ctx, "BELO")); + bot.command("blu", (ctx) => replyQuote(ctx, "BLUE")); + bot.command("oficial", (ctx) => replyQuote(ctx, "BNA")); - const progress = Math.round(status.progressPercentage ?? 0); - const message = [ - "💰 BELO · USDC/ARS", - "", - `Compra: $${formatPrice(status.buy)}`, - `Venta: $${formatPrice(status.sell)}`, - "", - `Mínimo del día: $${formatPrice(status.minBuy)}`, - `Máximo del día: $${formatPrice(status.maxBuy)}`, - `Avance del día: ${progress}%`, - "", - `🕐 ${formatTime(status.timeStamp)}`, - ].join("\n"); + bot.on("callback_query:data", async (ctx) => { + const [cmd, rawType] = ctx.callbackQuery.data.split(":"); + if (cmd !== "quote") return; - await ctx.reply(message, { parse_mode: "HTML" }); + const type = rawType as QuoteType; + if (!QUOTE_COMMANDS[type]) return; + + await replyQuote(ctx, type); + await ctx.answerCallbackQuery(); }); bot.on("message:text", async (ctx) => { @@ -74,7 +128,9 @@ export async function startBot(): Promise { const success = await validateCode(BigInt(ctx.chat.id), text); if (success) { - await ctx.reply("✅ ¡Conectado! Recibirás notificaciones de BELO aquí."); + await ctx.reply( + "✅ ¡Conectado! Recibirás notificaciones de BELO aquí.", + ); } else { await ctx.reply("❌ Código inválido o ya utilizado."); } @@ -83,6 +139,13 @@ export async function startBot(): Promise { const botInfo = await bot.api.getMe(); botUsername = botInfo.username; + await bot.api.setMyCommands([ + { command: "menu", description: "Mostrar menú principal" }, + { command: "belo", description: "Cotización de BELO (USDC/ARS)" }, + { command: "blu", description: "Cotización del dólar blue" }, + { command: "oficial", description: "Cotización del dólar oficial" }, + ]); + bot.start(); botInstance = bot; diff --git a/apps/backend/src/modules/telegram/telegram.service.ts b/apps/backend/src/modules/telegram/telegram.service.ts index 000ac4f..7c84719 100644 --- a/apps/backend/src/modules/telegram/telegram.service.ts +++ b/apps/backend/src/modules/telegram/telegram.service.ts @@ -1,4 +1,5 @@ import crypto from "crypto"; +import type { QuoteType } from "../../generated/prisma/client"; import { logger } from "../../lib/logger"; import { prisma } from "../../lib/prisma"; import { getBot, getBotUsername } from "../../lib/telegram"; @@ -209,7 +210,7 @@ export async function checkAndNotifyBelo(): Promise { }); } -export interface BeloQuoteStatus { +export interface QuoteStatus { buy: number; sell: number; timeStamp: Date; @@ -220,9 +221,11 @@ export interface BeloQuoteStatus { progressPercentage: number | null; } -export async function getBeloQuoteStatus(): Promise { +export async function getQuoteStatus( + type: QuoteType, +): Promise { const currentQuote = await prisma.quote.findFirst({ - where: { type: "BELO" }, + where: { type }, orderBy: { timeStamp: "desc" }, }); if (!currentQuote) return null; @@ -241,7 +244,7 @@ export async function getBeloQuoteStatus(): Promise { const agg = await prisma.quoteHistory.aggregate({ where: { - type: "BELO", + type, timeStamp: { gte: todayStart }, }, _min: { buy: true, sell: true },