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

@@ -14,11 +14,12 @@
"lint:fix": "biome check --write src/" "lint:fix": "biome check --write src/"
}, },
"dependencies": { "dependencies": {
"better-auth": "^1.6.11",
"@noble/ciphers": "^2.1.1", "@noble/ciphers": "^2.1.1",
"@personal-admin/common": "workspace:*", "@personal-admin/common": "workspace:*",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"better-auth": "^1.6.11",
"grammy": "^1.44.0",
"hono": "^4.7.0", "hono": "^4.7.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.21.0", "pg": "^8.21.0",

View File

@@ -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");

View File

@@ -69,6 +69,28 @@ model Verification {
@@map("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 { enum QuoteType {
BLUE BLUE
BNA BNA

View File

@@ -6,10 +6,12 @@ import { cache } from "./lib/cache";
import { eventBus } from "./lib/event-bus"; import { eventBus } from "./lib/event-bus";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
import { requestLogger } from "./lib/request-logger"; import { requestLogger } from "./lib/request-logger";
import { startBot } from "./lib/telegram";
import { startExpenseJob } from "./modules/expenses/expense.job"; import { startExpenseJob } from "./modules/expenses/expense.job";
import expensesRouter from "./modules/expenses/expenses.routes"; import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job"; import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes"; import quotesRouter from "./modules/quotes/quotes.routes";
import telegramRouter from "./modules/telegram/telegram.router";
const app = new Hono(); const app = new Hono();
@@ -30,6 +32,7 @@ app.use("/api/*", async (c, next) => {
app.route("/api/quotes", quotesRouter); app.route("/api/quotes", quotesRouter);
app.route("/api", expensesRouter); app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter);
app.use("/assets/*", serveStatic({ root: "./web" })); app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" })); app.get("*", serveStatic({ path: "./web/index.html" }));
@@ -40,6 +43,7 @@ eventBus.on("quotes-updated", () => {
startQuoteJob(); startQuoteJob();
startExpenseJob(); startExpenseJob();
startBot();
logger.info("Backend started"); 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 cron from "node-cron";
import { eventBus } from "../../lib/event-bus"; import { eventBus } from "../../lib/event-bus";
import { logger } from "../../lib/logger"; import { logger } from "../../lib/logger";
import { checkAndNotifyBeloMax } from "../telegram/telegram.service";
import { processBeloQuote } from "./belo.service"; import { processBeloQuote } from "./belo.service";
import { processBlueQuote } from "./blue.service"; import { processBlueQuote } from "./blue.service";
import { processBnaQuote } from "./bna.service"; import { processBnaQuote } from "./bna.service";
@@ -12,6 +13,9 @@ async function safeProcess(
try { try {
await fn(); await fn();
logger.info(`${name} quote processed successfully`); logger.info(`${name} quote processed successfully`);
if (name === "BELO") {
await checkAndNotifyBeloMax();
}
} catch (error) { } catch (error) {
logger.error(error, `Error processing ${name} quote`); 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");
}
}

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"; 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 { useMemo, useState } from "react";
import { import {
CartesianGrid, CartesianGrid,
@@ -24,6 +24,7 @@ import {
useFetchQuotes, useFetchQuotes,
useQuoteHistory, useQuoteHistory,
useQuotes, useQuotes,
useTelegramStatus,
} from "@/lib/queries"; } from "@/lib/queries";
import { RelativeTime } from "@/lib/time"; import { RelativeTime } from "@/lib/time";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -57,6 +58,32 @@ function getGaugeColor(percentage: number | null): string {
return "text-red-500"; return "text-red-500";
} }
function TelegramBadge() {
const { data: status } = useTelegramStatus();
if (status?.connected) {
return (
<Link
to="/settings"
className="inline-flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
>
<MessageCircle className="h-3.5 w-3.5" />
Telegram conectado
</Link>
);
}
return (
<Link
to="/settings"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-3.5 w-3.5" />
Conectar Telegram
</Link>
);
}
function StatCard({ function StatCard({
label, label,
buy, buy,
@@ -231,6 +258,7 @@ export function BeloPage() {
Últ. actualización: <RelativeTime date={belo.timeStamp} /> Últ. actualización: <RelativeTime date={belo.timeStamp} />
</span> </span>
)} )}
<TelegramBadge />
<Link <Link
to="/quotes/analysis/$currency" to="/quotes/analysis/$currency"
params={{ currency: "BELO" }} params={{ currency: "BELO" }}

View File

@@ -319,3 +319,23 @@ export function importPeriodicExpenses(file: File): Promise<ImportResult> {
return r.json(); return r.json();
}); });
} }
// Telegram
export type TelegramStatus = {
botUsername: string | null;
connected: boolean;
};
export type TelegramCode = {
code: string;
botUsername: string | null;
};
export function getTelegramStatus(): Promise<TelegramStatus> {
return fetcher<TelegramStatus>("/api/telegram/status");
}
export function generateTelegramCode(): Promise<TelegramCode> {
return mutator<TelegramCode>("/api/telegram/code", "POST");
}

View File

@@ -15,6 +15,7 @@ import {
getDailyMinMax, getDailyMinMax,
getDailyQuotes, getDailyQuotes,
getExpenses, getExpenses,
generateTelegramCode,
getHistoricalMinMax, getHistoricalMinMax,
getMonthlyPayedTotal, getMonthlyPayedTotal,
getMonthlyTotals, getMonthlyTotals,
@@ -22,6 +23,7 @@ import {
getPeriodicExpenses, getPeriodicExpenses,
getQuoteHistory, getQuoteHistory,
getQuotes, getQuotes,
getTelegramStatus,
getTotalPending, getTotalPending,
importExpenses, importExpenses,
importPeriodicExpenses, 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"] });
},
});
}

View File

@@ -1,8 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Check, Copy, RefreshCw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import {
useGenerateTelegramCode,
useTelegramStatus,
} from "@/lib/queries";
export const Route = createFileRoute("/_authenticated/settings")({ export const Route = createFileRoute("/_authenticated/settings")({
component: SettingsPage, component: SettingsPage,
@@ -48,7 +53,7 @@ function SettingsPage() {
} }
return ( return (
<div className="mx-auto max-w-md space-y-6"> <div className="mx-auto max-w-md space-y-8">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Configuración</h1> <h1 className="text-2xl font-semibold tracking-tight">Configuración</h1>
<p className="text-sm text-muted-foreground">Cambiar contraseña</p> <p className="text-sm text-muted-foreground">Cambiar contraseña</p>
@@ -118,6 +123,124 @@ function SettingsPage() {
{loading ? "Guardando..." : "Cambiar contraseña"} {loading ? "Guardando..." : "Cambiar contraseña"}
</Button> </Button>
</form> </form>
<hr className="border-border" />
<TelegramSection />
</div>
);
}
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 (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">Telegram</h2>
<p className="text-sm text-muted-foreground">
Conectá tu cuenta de Telegram para recibir notificaciones
</p>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : status?.connected ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-950/30">
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
Conectado a Telegram
</p>
<p className="mt-1 text-xs text-emerald-600 dark:text-emerald-500">
Recibirás notificaciones de BELO aquí.
</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{status?.botUsername ? (
<>
Enviá el código a{" "}
<strong className="font-medium text-foreground">
@{status.botUsername}
</strong>{" "}
en Telegram para conectar.
</>
) : (
"El bot no está disponible."
)}
</p>
{code ? (
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted px-3 py-2 text-sm font-mono tracking-widest">
{code}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
) : (
<Button
type="button"
onClick={handleGenerate}
disabled={generating || !status?.botUsername}
className="w-full"
>
{generating ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Generando...
</>
) : (
"Generar código"
)}
</Button>
)}
</div>
)}
</div> </div>
); );
} }

BIN
bun.lockb

Binary file not shown.