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,39 +30,11 @@ export function getBotUsername(): string | null {
return botUsername; return botUsername;
} }
export async function startBot(): Promise<void> { function buildQuoteMessage(type: QuoteType, status: QuoteStatus): string {
if (!token) { const { label, icon } = QUOTE_COMMANDS[type];
logger.warn("BOT_TOKEN not set, Telegram bot not started");
return;
}
try {
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.",
);
});
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;
}
const status = await getBeloQuoteStatus();
if (!status) {
await ctx.reply("❌ No hay datos de BELO disponibles todavía.");
return;
}
const progress = Math.round(status.progressPercentage ?? 0); const progress = Math.round(status.progressPercentage ?? 0);
const message = [ return [
"💰 <b>BELO · USDC/ARS</b>", `${icon} <b>${label}</b>`,
"", "",
`Compra: <b>$${formatPrice(status.buy)}</b>`, `Compra: <b>$${formatPrice(status.buy)}</b>`,
`Venta: <b>$${formatPrice(status.sell)}</b>`, `Venta: <b>$${formatPrice(status.sell)}</b>`,
@@ -64,8 +45,81 @@ export async function startBot(): Promise<void> {
"", "",
`🕐 ${formatTime(status.timeStamp)}`, `🕐 ${formatTime(status.timeStamp)}`,
].join("\n"); ].join("\n");
}
await ctx.reply(message, { parse_mode: "HTML" }); 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");
return;
}
try {
const bot = new Bot(token);
bot.command("start", async (ctx) => {
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("menu", async (ctx) => {
await ctx.reply("📊 <b>Menú</b>\n\nElegí una cotización:", {
parse_mode: "HTML",
reply_markup: mainMenu(),
});
});
bot.command("belo", (ctx) => replyQuote(ctx, "BELO"));
bot.command("blu", (ctx) => replyQuote(ctx, "BLUE"));
bot.command("oficial", (ctx) => replyQuote(ctx, "BNA"));
bot.on("callback_query:data", async (ctx) => {
const [cmd, rawType] = ctx.callbackQuery.data.split(":");
if (cmd !== "quote") return;
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 },