From b8d8f16f4e5934d2db8b7b7e0ca5e674bd138564 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Wed, 17 Jun 2026 08:42:13 -0300 Subject: [PATCH] feat(telegram): integrate Telegram bot for notifications and add related models --- apps/backend/package.json | 3 +- .../20260617000000_add_telegram/migration.sql | 31 ++++ apps/backend/prisma/schema.prisma | 22 +++ apps/backend/src/index.ts | 4 + apps/backend/src/lib/telegram.ts | 55 +++++++ apps/backend/src/modules/quotes/quote.job.ts | 4 + .../src/modules/telegram/telegram.handler.ts | 20 +++ .../src/modules/telegram/telegram.router.ts | 9 ++ .../src/modules/telegram/telegram.service.ts | 151 ++++++++++++++++++ apps/frontend/src/features/belo/BeloPage.tsx | 30 +++- apps/frontend/src/lib/api.ts | 20 +++ apps/frontend/src/lib/queries.ts | 22 +++ .../src/routes/_authenticated/settings.tsx | 125 ++++++++++++++- bun.lockb | Bin 313072 -> 316080 bytes 14 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260617000000_add_telegram/migration.sql create mode 100644 apps/backend/src/lib/telegram.ts create mode 100644 apps/backend/src/modules/telegram/telegram.handler.ts create mode 100644 apps/backend/src/modules/telegram/telegram.router.ts create mode 100644 apps/backend/src/modules/telegram/telegram.service.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index fe15250..f0fd8a5 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -14,11 +14,12 @@ "lint:fix": "biome check --write src/" }, "dependencies": { - "better-auth": "^1.6.11", "@noble/ciphers": "^2.1.1", "@personal-admin/common": "workspace:*", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", + "better-auth": "^1.6.11", + "grammy": "^1.44.0", "hono": "^4.7.0", "node-cron": "^4.2.1", "pg": "^8.21.0", diff --git a/apps/backend/prisma/migrations/20260617000000_add_telegram/migration.sql b/apps/backend/prisma/migrations/20260617000000_add_telegram/migration.sql new file mode 100644 index 0000000..01b766b --- /dev/null +++ b/apps/backend/prisma/migrations/20260617000000_add_telegram/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "telegram_chats" ( + "id" TEXT NOT NULL, + "chatId" BIGINT, + "code" TEXT NOT NULL, + "validatedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "telegram_chats_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "telegram_daily_max_notifications" ( + "id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "quoteType" "QuoteType" NOT NULL, + "maxValue" MONEY NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "telegram_daily_max_notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_chats_chatId_key" ON "telegram_chats"("chatId"); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_chats_code_key" ON "telegram_chats"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_daily_max_notifications_date_quoteType_key" ON "telegram_daily_max_notifications"("date", "quoteType"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 117d7a5..a6d1320 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -69,6 +69,28 @@ model Verification { @@map("verification") } +model TelegramChat { + id String @id @default(cuid()) + chatId BigInt? @unique + code String @unique + validatedAt DateTime? + createdAt DateTime @default(now()) + + @@map("telegram_chats") +} + +model TelegramDailyMaxNotification { + id String @id @default(cuid()) + date DateTime + quoteType QuoteType + maxValue Decimal @db.Money + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([date, quoteType]) + @@map("telegram_daily_max_notifications") +} + enum QuoteType { BLUE BNA diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 3b38334..2822e17 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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"); diff --git a/apps/backend/src/lib/telegram.ts b/apps/backend/src/lib/telegram.ts new file mode 100644 index 0000000..edcfea2 --- /dev/null +++ b/apps/backend/src/lib/telegram.ts @@ -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 { + 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"); + } +} diff --git a/apps/backend/src/modules/quotes/quote.job.ts b/apps/backend/src/modules/quotes/quote.job.ts index 39d869a..2863871 100644 --- a/apps/backend/src/modules/quotes/quote.job.ts +++ b/apps/backend/src/modules/quotes/quote.job.ts @@ -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`); } diff --git a/apps/backend/src/modules/telegram/telegram.handler.ts b/apps/backend/src/modules/telegram/telegram.handler.ts new file mode 100644 index 0000000..2880e70 --- /dev/null +++ b/apps/backend/src/modules/telegram/telegram.handler.ts @@ -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); + } +} diff --git a/apps/backend/src/modules/telegram/telegram.router.ts b/apps/backend/src/modules/telegram/telegram.router.ts new file mode 100644 index 0000000..1d5234d --- /dev/null +++ b/apps/backend/src/modules/telegram/telegram.router.ts @@ -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; diff --git a/apps/backend/src/modules/telegram/telegram.service.ts b/apps/backend/src/modules/telegram/telegram.service.ts new file mode 100644 index 0000000..f7179ec --- /dev/null +++ b/apps/backend/src/modules/telegram/telegram.service.ts @@ -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 { + 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 { + 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"); + } +} diff --git a/apps/frontend/src/features/belo/BeloPage.tsx b/apps/frontend/src/features/belo/BeloPage.tsx index 591e2e9..f03ed0d 100644 --- a/apps/frontend/src/features/belo/BeloPage.tsx +++ b/apps/frontend/src/features/belo/BeloPage.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { BarChart3, RefreshCw } from "lucide-react"; +import { BarChart3, MessageCircle, RefreshCw } from "lucide-react"; import { useMemo, useState } from "react"; import { CartesianGrid, @@ -24,6 +24,7 @@ import { useFetchQuotes, useQuoteHistory, useQuotes, + useTelegramStatus, } from "@/lib/queries"; import { RelativeTime } from "@/lib/time"; import { cn } from "@/lib/utils"; @@ -57,6 +58,32 @@ function getGaugeColor(percentage: number | null): string { return "text-red-500"; } +function TelegramBadge() { + const { data: status } = useTelegramStatus(); + + if (status?.connected) { + return ( + + + Telegram conectado + + ); + } + + return ( + + + Conectar Telegram + + ); +} + function StatCard({ label, buy, @@ -231,6 +258,7 @@ export function BeloPage() { Últ. actualización: )} + { return r.json(); }); } + +// Telegram + +export type TelegramStatus = { + botUsername: string | null; + connected: boolean; +}; + +export type TelegramCode = { + code: string; + botUsername: string | null; +}; + +export function getTelegramStatus(): Promise { + return fetcher("/api/telegram/status"); +} + +export function generateTelegramCode(): Promise { + return mutator("/api/telegram/code", "POST"); +} diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 4586809..0d7747c 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -15,6 +15,7 @@ import { getDailyMinMax, getDailyQuotes, getExpenses, + generateTelegramCode, getHistoricalMinMax, getMonthlyPayedTotal, getMonthlyTotals, @@ -22,6 +23,7 @@ import { getPeriodicExpenses, getQuoteHistory, getQuotes, + getTelegramStatus, getTotalPending, importExpenses, importPeriodicExpenses, @@ -292,3 +294,23 @@ export function useUpdateExpense() { }, }); } + +// Telegram + +export function useTelegramStatus() { + return useQuery({ + queryKey: ["telegram", "status"], + queryFn: getTelegramStatus, + staleTime: 30_000, + }); +} + +export function useGenerateTelegramCode() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: generateTelegramCode, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["telegram", "status"] }); + }, + }); +} diff --git a/apps/frontend/src/routes/_authenticated/settings.tsx b/apps/frontend/src/routes/_authenticated/settings.tsx index d80ace3..ca471a3 100644 --- a/apps/frontend/src/routes/_authenticated/settings.tsx +++ b/apps/frontend/src/routes/_authenticated/settings.tsx @@ -1,8 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; +import { Check, Copy, RefreshCw } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/lib/auth-context"; +import { + useGenerateTelegramCode, + useTelegramStatus, +} from "@/lib/queries"; export const Route = createFileRoute("/_authenticated/settings")({ component: SettingsPage, @@ -48,7 +53,7 @@ function SettingsPage() { } return ( -
+

Configuración

Cambiar contraseña

@@ -118,6 +123,124 @@ function SettingsPage() { {loading ? "Guardando..." : "Cambiar contraseña"} + +
+ + +
+ ); +} + +function TelegramSection() { + const { data: status, isLoading } = useTelegramStatus(); + const { + mutate: generateCode, + data: newCode, + isPending: generating, + reset: resetCode, + } = useGenerateTelegramCode(); + const [copied, setCopied] = useState(false); + + const code = newCode?.code; + + async function handleCopy() { + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // fallback + const el = document.createElement("textarea"); + el.value = code; + document.body.appendChild(el); + el.select(); + document.execCommand("copy"); + document.body.removeChild(el); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } + + function handleGenerate() { + resetCode(); + generateCode(); + } + + return ( +
+
+

Telegram

+

+ Conectá tu cuenta de Telegram para recibir notificaciones +

+
+ + {isLoading ? ( +

Cargando...

+ ) : status?.connected ? ( +
+

+ ✅ Conectado a Telegram +

+

+ Recibirás notificaciones de BELO aquí. +

+
+ ) : ( +
+

+ {status?.botUsername ? ( + <> + Enviá el código a{" "} + + @{status.botUsername} + {" "} + en Telegram para conectar. + + ) : ( + "El bot no está disponible." + )} +

+ + {code ? ( +
+ + {code} + + +
+ ) : ( + + )} +
+ )}
); } diff --git a/bun.lockb b/bun.lockb index 6429d2ab3bdc45720ee682e9440eee0674f8e767..0cab4b05b55a2b0ffc52776229e2fbf2e7fc58ad 100755 GIT binary patch delta 3704 zcmd^CX5=`?EXBA`c~6J8#SM%5L-)~NsKW;(5ZyV}% z9^W|Alk*J7DipqU{O4G==#e179>aJ{zy z<@D3nEA=X+Sbsz*mbgHbh>G-krv*yXpe+QA*L(N`iV8qe5u*<$)i0i+k~Dxi&um23 z0NBO?b0D}nZ3Jx!+ALROhT$beC4kP-%TCS|d2}(%Qd~)rU^UB}#rR2}I-HEN=A1x%nY+B=3df(a3kRh_|Cm@ihH(Iy0ul)&?sy&5 zZ6ybIDz)pV+(7;Q1DVS7g@jvZq70hozma%fZG}8osGiCkn%7q12DjV!!MAzwT4I7- z5P%N3G!P{oNFLSm?2bQT-~Yewp6~0f;c7c{=ilm{AzjI)^rq_6nyrfU@uc%u=ep*v zvm13ruR}uJ&4V>#oeo*ysJYmF!kI3INQ=4#&`8clud zhEx}BtC($U%`RBkmihtx&W(-kXo4pxqftr~MmM+V&eRI#h>*EZ$- zU|U{Y)|lIY%-n-_CzR#4e`f4EFE#pNYQMj{I4-^AnkT+C_LfV-5LM9**D|N}&^wz= znW06c<$r9AD64SGG3B-|d_RdSQbKvY30n#u+r%;NS&y3$>T_!)H-lHk?Mt5N zr%S0gSCtr5)Oc%om&qkXTzjwHwB56%TUb_oDtvq6J%3$ye!~N)RKj<=-w3VnJRka{ zmrdek@=VT!u#ZWw0z?ZrfZ_>Zl^|NlG8Aow0wwoN0NJDB_7KN%!Ou`1#vKxU0Xl1= zThRZ__kH12A@7P_Ef0yXnryajB5QfM#0fl+J>zDEU4UbPCNQ&UPf^E{L+(SdOVkHL zt&hbDis6s`C4K>2rkDuUo9W~73MqNUNvB+;CQKQGzCe|LVM;OZa$uxtz~IA3fL9;@ zbd5MYhZNUo3Vdt82w$O_jOQK(LLq$7bxr1c9d;rln@T@1JSYByZ%JRo}I~*jk?TEdI_cW+P{6cPED7dcz#*TzShy-{G$>_tM zwJ&zG?8a|`0d}*?b$lCa^9e8>SPQf$4PvlUUxJSJfj{tL0Pb;QkSD(_2%c^XK9UcB z58gr#fPVyP1c`4Fe5y_W#+-P6;C<5x@k)@}AZ9YihzXV=w6%t6PyR%{t97`x4#gZB z>ZO=CQK2Fp2lzpT-4}U1ud+_k)(MzneH7k)3f1eG+d3;lxDUiGp34tUwa)9-IU8)= z3SW$}PVm;r8!VF)DzvC$R>ZE1j0xYEk+Y=!ldVP0Y^X}%g%<0SZ=KFLc0EKWd>F?1 ze}Hv+CPoS{kycj@V!2=dcV}bd@i)|3{q# zcCRuFspQX?d@X%a%9WOd+|ne4Hhk?g3$iL+sZEMg$A^6!tI^6=#Kvefu~AXvh_fIg zex3Z_0p`79z?z6K?V50Tf`)i?@iH%~76r*)nWE(8hM3*eA4jf?!i=lcn)t}r7_#@I z;5Oo?SUd**%<-!+!e|F<99U7vRb=`pVZ_v*)~MEi`AtX;#|VE=u6ngPMl08bX~NZ7 zd3;1M~r4ia;;n#X8)9ej@9IOxzP15t5eLq delta 1995 zcmeH|U2IfU5XaBC=ia*a(#p0gEtOzpi5g3wK)QS-T1!7Pv4NJ9fKdtf5rYYeU}8a} zjSofzwe4a?O(8XY@c>CH!HYZ~E89X0Znsr|7@H8aWho$Ax2Pn9_@C~S@T?EMINATs z%x|XCbI(2ZZr3NF^jo3UIrna;`>4xHT+SU_Up=Go!Q$MSorMcqcc+#QpS@C7xh++` zw%1l{BWHBT}kkNI8%|ZO{ z=W>+FgMS&GjlG87Ci%_Y0^ct6X7Mf3_YZutnuLKnWYA6-^lvlX>h7_%6vO zyi)$>Mz8pP7Y63*&DY5(w;t9*)KG4%qSQPqj}9_8M>+!V-h6-_6S^-hB?qN9<#6=~}O3!4l!SJ(h`^N8=Hl#kLy zDSZ#d7lAjCI>ws>`wku0b9gi56 zQZdj`=CE_ZZb#fPPLxW4;iINOd!PU`LLc&o3(`IvXez{ZQP>@bZ-aOXF9|C_d{S2S z8;$aa%Tk(wl8X{wP+C|R@p5S&6*d#BLD=t9X`9g987bX`5-*ZhGbXI*8Uk2dfc6AU zm^y6JNMp9?bfVbpHhSMN)0}q@zD-{`rfSL~xE+nqN@z9oI!Y}xJ;#*Hegh#NJAQ#b z4{d~AfHpy!p%^lNr_E9Cfxaw!D(<(RF-J0#-f9Q@j)c8Sd!6y@59^^gElJv8TAQ?|da5T2 i_x8}MNqg8kBYC=qo90kwza8`z$xm{9FCFf;|M(lf_j2(7