feat(telegram): integrate Telegram bot for notifications and add related models

This commit is contained in:
Jose Selesan
2026-06-17 08:42:13 -03:00
parent c0c8bf0945
commit b8d8f16f4e
14 changed files with 493 additions and 3 deletions

View File

@@ -6,10 +6,12 @@ import { cache } from "./lib/cache";
import { eventBus } from "./lib/event-bus";
import { logger } from "./lib/logger";
import { requestLogger } from "./lib/request-logger";
import { startBot } from "./lib/telegram";
import { startExpenseJob } from "./modules/expenses/expense.job";
import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes";
import telegramRouter from "./modules/telegram/telegram.router";
const app = new Hono();
@@ -30,6 +32,7 @@ app.use("/api/*", async (c, next) => {
app.route("/api/quotes", quotesRouter);
app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter);
app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" }));
@@ -40,6 +43,7 @@ eventBus.on("quotes-updated", () => {
startQuoteJob();
startExpenseJob();
startBot();
logger.info("Backend started");

View File

@@ -0,0 +1,55 @@
import { Bot } from "grammy";
import { logger } from "./logger";
import { validateCode } from "../modules/telegram/telegram.service";
const token = process.env.BOT_TOKEN;
let botInstance: Bot | null = null;
let botUsername: string | null = null;
export function getBot(): Bot | null {
return botInstance;
}
export function getBotUsername(): string | null {
return botUsername;
}
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) => {
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.on("message:text", async (ctx) => {
const text = ctx.message.text.trim();
if (text.startsWith("/")) return;
const success = await validateCode(BigInt(ctx.chat.id), text);
if (success) {
await ctx.reply("✅ ¡Conectado! Recibirás notificaciones de BELO aquí.");
} else {
await ctx.reply("❌ Código inválido o ya utilizado.");
}
});
const botInfo = await bot.api.getMe();
botUsername = botInfo.username;
bot.start();
botInstance = bot;
logger.info({ botUsername }, "Telegram bot started");
} catch (error) {
logger.error(error, "Failed to start Telegram bot");
}
}

View File

@@ -1,6 +1,7 @@
import cron from "node-cron";
import { eventBus } from "../../lib/event-bus";
import { logger } from "../../lib/logger";
import { checkAndNotifyBeloMax } from "../telegram/telegram.service";
import { processBeloQuote } from "./belo.service";
import { processBlueQuote } from "./blue.service";
import { processBnaQuote } from "./bna.service";
@@ -12,6 +13,9 @@ async function safeProcess(
try {
await fn();
logger.info(`${name} quote processed successfully`);
if (name === "BELO") {
await checkAndNotifyBeloMax();
}
} catch (error) {
logger.error(error, `Error processing ${name} quote`);
}

View File

@@ -0,0 +1,20 @@
import type { Context } from "hono";
import { generateCode, getTelegramStatus } from "./telegram.service";
export async function generateCodeHandler(c: Context) {
try {
const result = await generateCode();
return c.json(result);
} catch (error) {
return c.json({ error: "Failed to generate code" }, 500);
}
}
export async function telegramStatusHandler(c: Context) {
try {
const status = await getTelegramStatus();
return c.json(status);
} catch (error) {
return c.json({ connected: false, botUsername: null }, 500);
}
}

View File

@@ -0,0 +1,9 @@
import { Hono } from "hono";
import { generateCodeHandler, telegramStatusHandler } from "./telegram.handler";
const app = new Hono();
app.post("/code", generateCodeHandler);
app.get("/status", telegramStatusHandler);
export default app;

View File

@@ -0,0 +1,151 @@
import crypto from "crypto";
import { prisma } from "../../lib/prisma";
import { logger } from "../../lib/logger";
import { getBot, getBotUsername } from "../../lib/telegram";
function generateRandomCode(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const bytes = crypto.randomBytes(8);
let code = "";
for (let i = 0; i < 8; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
}
function formatPrice(price: number): string {
return price.toLocaleString("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export async function generateCode(): Promise<{
code: string;
botUsername: string | null;
}> {
const code = generateRandomCode();
await prisma.telegramChat.create({
data: { code },
});
return { code, botUsername: getBotUsername() };
}
export async function validateCode(
chatId: bigint,
code: string,
): Promise<boolean> {
const record = await prisma.telegramChat.findUnique({
where: { code },
});
if (!record || record.validatedAt !== null) return false;
await prisma.telegramChat.deleteMany({
where: { chatId, validatedAt: { not: null } },
});
await prisma.telegramChat.update({
where: { id: record.id },
data: {
chatId,
validatedAt: new Date(),
},
});
return true;
}
export async function getTelegramStatus(): Promise<{
botUsername: string | null;
connected: boolean;
}> {
const validated = await prisma.telegramChat.findFirst({
where: { validatedAt: { not: null } },
});
return {
botUsername: getBotUsername(),
connected: validated !== null,
};
}
export async function checkAndNotifyBeloMax(): Promise<void> {
const chat = await prisma.telegramChat.findFirst({
where: { validatedAt: { not: null } },
orderBy: { validatedAt: "desc" },
});
if (!chat?.chatId) return;
const bot = getBot();
if (!bot) return;
const now = new Date();
const artHour = ((now.getUTCHours() - 3) % 24 + 24) % 24;
if (artHour < 8 || artHour >= 20) return;
const currentQuote = await prisma.quote.findFirst({
where: { type: "BELO" },
orderBy: { timeStamp: "desc" },
});
if (!currentQuote) return;
const currentBuy = Number(currentQuote.buy);
const artOffset = -3;
const artMs = now.getTime() + artOffset * 3600000;
const artDate = new Date(artMs);
const todayStart = new Date(
Date.UTC(
artDate.getUTCFullYear(),
artDate.getUTCMonth(),
artDate.getUTCDate(),
),
);
const lastNotified = await prisma.telegramDailyMaxNotification.findUnique({
where: {
date_quoteType: {
date: todayStart,
quoteType: "BELO",
},
},
});
const lastNotifiedValue = lastNotified ? Number(lastNotified.maxValue) : 0;
if (currentBuy <= lastNotifiedValue) return;
const formattedPrice = formatPrice(currentBuy);
const message = `BELO alcanzó el máximo del día, la cotización actual es de $${formattedPrice}`;
try {
await bot.api.sendMessage(Number(chat.chatId), message);
await prisma.telegramDailyMaxNotification.upsert({
where: {
date_quoteType: {
date: todayStart,
quoteType: "BELO",
},
},
create: {
date: todayStart,
quoteType: "BELO",
maxValue: currentBuy,
},
update: {
maxValue: currentBuy,
},
});
logger.info(
`Telegram BELO max notification sent: $${formattedPrice}`,
);
} catch (error) {
logger.error(error, "Failed to send Telegram BELO max notification");
}
}