feat(telegram): integrate Telegram bot for notifications and add related models
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
55
apps/backend/src/lib/telegram.ts
Normal file
55
apps/backend/src/lib/telegram.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
20
apps/backend/src/modules/telegram/telegram.handler.ts
Normal file
20
apps/backend/src/modules/telegram/telegram.handler.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/backend/src/modules/telegram/telegram.router.ts
Normal file
9
apps/backend/src/modules/telegram/telegram.router.ts
Normal 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;
|
||||||
151
apps/backend/src/modules/telegram/telegram.service.ts
Normal file
151
apps/backend/src/modules/telegram/telegram.service.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user