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 { import {
formatPrice, formatPrice,
formatTime, formatTime,
getBeloQuoteStatus, getQuoteStatus,
isChatValidated, isChatValidated,
validateCode, validateCode,
} from "../modules/telegram/telegram.service"; } from "../modules/telegram/telegram.service";
@@ -10,6 +13,12 @@ import { logger } from "./logger";
const token = process.env.BOT_TOKEN; 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 botInstance: Bot | null = null;
let botUsername: string | null = null; let botUsername: string | null = null;
@@ -21,6 +30,54 @@ export function getBotUsername(): string | null {
return botUsername; 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> { export async function startBot(): Promise<void> {
if (!token) { if (!token) {
logger.warn("BOT_TOKEN not set, Telegram bot not started"); 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); const bot = new Bot(token);
bot.command("start", async (ctx) => { bot.command("start", async (ctx) => {
await ctx.reply( const validated = await isChatValidated(BigInt(ctx.chat.id));
"🔗 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.", 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) => { bot.command("menu", async (ctx) => {
const validated = await isChatValidated(BigInt(ctx.chat.id)); await ctx.reply("📊 <b>Menú</b>\n\nElegí una cotización:", {
if (!validated) { parse_mode: "HTML",
await ctx.reply( reply_markup: mainMenu(),
"🔒 Tu cuenta no está conectada. Enviá /start para ver cómo conectarla.", });
); });
return;
}
const status = await getBeloQuoteStatus(); bot.command("belo", (ctx) => replyQuote(ctx, "BELO"));
if (!status) { bot.command("blu", (ctx) => replyQuote(ctx, "BLUE"));
await ctx.reply("❌ No hay datos de BELO disponibles todavía."); bot.command("oficial", (ctx) => replyQuote(ctx, "BNA"));
return;
}
const progress = Math.round(status.progressPercentage ?? 0); bot.on("callback_query:data", async (ctx) => {
const message = [ const [cmd, rawType] = ctx.callbackQuery.data.split(":");
"💰 <b>BELO · USDC/ARS</b>", if (cmd !== "quote") return;
"",
`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");
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) => { 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); const success = await validateCode(BigInt(ctx.chat.id), text);
if (success) { if (success) {
await ctx.reply("✅ ¡Conectado! Recibirás notificaciones de BELO aquí."); await ctx.reply(
"✅ ¡Conectado! Recibirás notificaciones de BELO aquí.",
);
} else { } else {
await ctx.reply("❌ Código inválido o ya utilizado."); 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(); const botInfo = await bot.api.getMe();
botUsername = botInfo.username; 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(); bot.start();
botInstance = bot; botInstance = bot;

View File

@@ -1,4 +1,5 @@
import crypto from "crypto"; import crypto from "crypto";
import type { QuoteType } from "../../generated/prisma/client";
import { logger } from "../../lib/logger"; import { logger } from "../../lib/logger";
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { getBot, getBotUsername } from "../../lib/telegram"; import { getBot, getBotUsername } from "../../lib/telegram";
@@ -209,7 +210,7 @@ export async function checkAndNotifyBelo(): Promise<void> {
}); });
} }
export interface BeloQuoteStatus { export interface QuoteStatus {
buy: number; buy: number;
sell: number; sell: number;
timeStamp: Date; timeStamp: Date;
@@ -220,9 +221,11 @@ export interface BeloQuoteStatus {
progressPercentage: number | null; 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({ const currentQuote = await prisma.quote.findFirst({
where: { type: "BELO" }, where: { type },
orderBy: { timeStamp: "desc" }, orderBy: { timeStamp: "desc" },
}); });
if (!currentQuote) return null; if (!currentQuote) return null;
@@ -241,7 +244,7 @@ export async function getBeloQuoteStatus(): Promise<BeloQuoteStatus | null> {
const agg = await prisma.quoteHistory.aggregate({ const agg = await prisma.quoteHistory.aggregate({
where: { where: {
type: "BELO", type,
timeStamp: { gte: todayStart }, timeStamp: { gte: todayStart },
}, },
_min: { buy: true, sell: true }, _min: { buy: true, sell: true },