feat(telegram): add /blu and /oficial commands and main menu

This commit is contained in:
Jose Selesan
2026-08-20 14:22:56 -03:00
parent 96f6b26a76
commit 31dae446af
2 changed files with 103 additions and 37 deletions

View File

@@ -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<QuoteType, { label: string; icon: string }>;
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} <b>${label}</b>`,
"",
`Compra: <b>$${formatPrice(status.buy)}</b>`,
`Venta: <b>$${formatPrice(status.sell)}</b>`,
"",
`Mínimo del día: $${formatPrice(status.minBuy)}`,
`Máximo del día: $${formatPrice(status.maxBuy)}`,
`Avance del día: <b>${progress}%</b>`,
"",
`🕐 ${formatTime(status.timeStamp)}`,
].join("\n");
}
async function replyQuote(ctx: Context, type: QuoteType): Promise<void> {
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<void> {
if (!token) {
logger.warn("BOT_TOKEN not set, Telegram bot not started");
@@ -31,41 +88,38 @@ export async function startBot(): Promise<void> {
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("📊 <b>Menú</b>\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 = [
"💰 <b>BELO · USDC/ARS</b>",
"",
`Compra: <b>$${formatPrice(status.buy)}</b>`,
`Venta: <b>$${formatPrice(status.sell)}</b>`,
"",
`Mínimo del día: $${formatPrice(status.minBuy)}`,
`Máximo del día: $${formatPrice(status.maxBuy)}`,
`Avance del día: <b>${progress}%</b>`,
"",
`🕐 ${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<void> {
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<void> {
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;

View File

@@ -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<void> {
});
}
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<BeloQuoteStatus | null> {
export async function getQuoteStatus(
type: QuoteType,
): Promise<QuoteStatus | null> {
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<BeloQuoteStatus | null> {
const agg = await prisma.quoteHistory.aggregate({
where: {
type: "BELO",
type,
timeStamp: { gte: todayStart },
},
_min: { buy: true, sell: true },