mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
feat: Docker audit + Telegram bot service + send UI
Docker:
- Harden docker-compose.yml: parameterized DB creds, required AUTH_SECRET,
health checks, resource limits, network isolation, removed exposed DB port
- Add profiles (telegram/bot/full) so base 'docker compose up' needs only AUTH_SECRET
- Fix docker-entrypoint.sh: AUTH_SECRET startup guard
- Fix Dockerfile: copy prisma.config.ts + dotenv into production image
- Update .env.example with all new variables
- Update .dockerignore
Telegram Bot Service (bot/):
- TDLib-based bot using bot token auth (not HTTP Bot API)
- Commands: /search, /latest, /package, /link, /unlink, /subscribe, /unsubscribe
- pg_notify listener for send requests (bot_send) and new packages (new_package)
- Subscription-based notifications when matching packages arrive
- Dockerfile with multi-stage build (bookworm-slim for glibc/TDLib)
API & Database:
- Prisma: TelegramLink, BotSendRequest, BotSubscription models + migration
- POST /api/telegram/bot/send - queue package delivery to linked TG account
- GET /api/telegram/bot/send/[id] - poll send request status
- Server actions: generateTelegramLinkCode, unlinkTelegram, getBotSendHistory
- Worker: emit pg_notify('new_package') after creating packages
Frontend:
- Settings: TelegramLinkCard for account linking via one-time code
- STL table + drawer: SendToTelegramButton with send dialog and status polling
- Telegram admin: Bot Sends tab with delivery history table
- Shared SendHistoryRow type
README: Updated with bot docs, profiles, config vars, project structure
This commit is contained in:
440
bot/src/commands.ts
Normal file
440
bot/src/commands.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { childLogger } from "./util/logger.js";
|
||||
import {
|
||||
searchPackages,
|
||||
getLatestPackages,
|
||||
getPackageById,
|
||||
findLinkByTelegramUserId,
|
||||
validateLinkCode,
|
||||
deleteLinkCode,
|
||||
createTelegramLink,
|
||||
getSubscriptions,
|
||||
addSubscription,
|
||||
removeSubscription,
|
||||
} from "./db/queries.js";
|
||||
import { sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
|
||||
|
||||
const log = childLogger("commands");
|
||||
|
||||
interface IncomingMessage {
|
||||
chatId: bigint;
|
||||
userId: bigint;
|
||||
text: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
function formatSize(bytes: bigint): string {
|
||||
const mb = Number(bytes) / (1024 * 1024);
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function handleMessage(msg: IncomingMessage): Promise<void> {
|
||||
const { chatId, userId, text } = msg;
|
||||
|
||||
// Parse command and args
|
||||
const trimmed = text.trim();
|
||||
const spaceIdx = trimmed.indexOf(" ");
|
||||
const command = (spaceIdx > 0 ? trimmed.slice(0, spaceIdx) : trimmed).toLowerCase();
|
||||
const args = spaceIdx > 0 ? trimmed.slice(spaceIdx + 1).trim() : "";
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case "/start":
|
||||
await handleStart(chatId, userId, args, msg);
|
||||
break;
|
||||
case "/help":
|
||||
await handleHelp(chatId);
|
||||
break;
|
||||
case "/search":
|
||||
await handleSearch(chatId, args);
|
||||
break;
|
||||
case "/latest":
|
||||
await handleLatest(chatId, args);
|
||||
break;
|
||||
case "/package":
|
||||
await handlePackage(chatId, args);
|
||||
break;
|
||||
case "/link":
|
||||
await handleLink(chatId, userId, args, msg);
|
||||
break;
|
||||
case "/unlink":
|
||||
await handleUnlink(chatId, userId);
|
||||
break;
|
||||
case "/subscribe":
|
||||
await handleSubscribe(chatId, userId, args);
|
||||
break;
|
||||
case "/unsubscribe":
|
||||
await handleUnsubscribe(chatId, userId, args);
|
||||
break;
|
||||
case "/subscriptions":
|
||||
await handleListSubscriptions(chatId, userId);
|
||||
break;
|
||||
case "/status":
|
||||
await handleStatus(chatId, userId);
|
||||
break;
|
||||
default:
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Unknown command. Use /help to see available commands.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err, command, userId: userId.toString() }, "Command handler error");
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"An error occurred processing your command. Please try again.",
|
||||
"textParseModeHTML"
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(
|
||||
chatId: bigint,
|
||||
userId: bigint,
|
||||
args: string,
|
||||
msg: IncomingMessage
|
||||
): Promise<void> {
|
||||
// Deep link: /start link_<code>
|
||||
if (args.startsWith("link_")) {
|
||||
const code = args.slice(5);
|
||||
await handleLink(chatId, userId, code, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const welcome = [
|
||||
`🐉 <b>Dragon's Stash Bot</b>`,
|
||||
``,
|
||||
`I can help you search and receive indexed archive packages.`,
|
||||
``,
|
||||
`<b>Commands:</b>`,
|
||||
`/search <query> — Search packages`,
|
||||
`/latest [n] — Show latest packages`,
|
||||
`/package <id> — Package details`,
|
||||
`/link <code> — Link your Telegram to your web account`,
|
||||
`/subscribe <keyword> — Get notified for new packages`,
|
||||
`/subscriptions — View your subscriptions`,
|
||||
`/unsubscribe <keyword> — Remove a subscription`,
|
||||
`/status — Check your link status`,
|
||||
`/help — Show this help message`,
|
||||
].join("\n");
|
||||
|
||||
await sendTextMessage(chatId, welcome, "textParseModeHTML");
|
||||
}
|
||||
|
||||
async function handleHelp(chatId: bigint): Promise<void> {
|
||||
const help = [
|
||||
`<b>Available Commands:</b>`,
|
||||
``,
|
||||
`🔍 <b>Search & Browse</b>`,
|
||||
`/search <query> — Search by filename or creator`,
|
||||
`/latest [n] — Show n most recent packages (default: 5)`,
|
||||
`/package <id> — View package details and file list`,
|
||||
``,
|
||||
`🔗 <b>Account Linking</b>`,
|
||||
`/link <code> — Link Telegram to your web account`,
|
||||
`/unlink — Unlink your Telegram account`,
|
||||
`/status — Check link status`,
|
||||
``,
|
||||
`🔔 <b>Notifications</b>`,
|
||||
`/subscribe <keyword> — Get alerts for matching packages`,
|
||||
`/unsubscribe <keyword> — Remove a subscription`,
|
||||
`/subscriptions — List your subscriptions`,
|
||||
].join("\n");
|
||||
|
||||
await sendTextMessage(chatId, help, "textParseModeHTML");
|
||||
}
|
||||
|
||||
async function handleSearch(chatId: bigint, query: string): Promise<void> {
|
||||
if (!query) {
|
||||
await sendTextMessage(chatId, "Usage: /search <query>", "textParseModeHTML");
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await searchPackages(query, 10);
|
||||
|
||||
if (results.length === 0) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`No packages found for "<b>${escapeHtml(query)}</b>".`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = results.map((pkg, i) => {
|
||||
const creator = pkg.creator ? ` by ${pkg.creator}` : "";
|
||||
return `${i + 1}. <b>${escapeHtml(pkg.fileName)}</b>${creator}\n 📦 ${pkg.fileCount} files · ${formatSize(pkg.fileSize)} · ${formatDate(pkg.indexedAt)}\n ID: <code>${pkg.id}</code>`;
|
||||
});
|
||||
|
||||
const response = [
|
||||
`🔍 <b>Search results for "${escapeHtml(query)}":</b>`,
|
||||
``,
|
||||
...lines,
|
||||
``,
|
||||
`Use /package <id> for details.`,
|
||||
].join("\n");
|
||||
|
||||
await sendTextMessage(chatId, response, "textParseModeHTML");
|
||||
}
|
||||
|
||||
async function handleLatest(chatId: bigint, args: string): Promise<void> {
|
||||
const limit = Math.min(Math.max(parseInt(args) || 5, 1), 20);
|
||||
const results = await getLatestPackages(limit);
|
||||
|
||||
if (results.length === 0) {
|
||||
await sendTextMessage(chatId, "No packages indexed yet.", "textParseModeHTML");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = results.map((pkg, i) => {
|
||||
const creator = pkg.creator ? ` by ${pkg.creator}` : "";
|
||||
return `${i + 1}. <b>${escapeHtml(pkg.fileName)}</b>${creator}\n 📦 ${pkg.fileCount} files · ${formatSize(pkg.fileSize)} · ${formatDate(pkg.indexedAt)}\n ID: <code>${pkg.id}</code>`;
|
||||
});
|
||||
|
||||
const response = [
|
||||
`📋 <b>Latest ${results.length} packages:</b>`,
|
||||
``,
|
||||
...lines,
|
||||
``,
|
||||
`Use /package <id> for details.`,
|
||||
].join("\n");
|
||||
|
||||
await sendTextMessage(chatId, response, "textParseModeHTML");
|
||||
}
|
||||
|
||||
async function handlePackage(chatId: bigint, id: string): Promise<void> {
|
||||
if (!id) {
|
||||
await sendTextMessage(chatId, "Usage: /package <id>", "textParseModeHTML");
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = await getPackageById(id.trim());
|
||||
if (!pkg) {
|
||||
await sendTextMessage(chatId, "Package not found.", "textParseModeHTML");
|
||||
return;
|
||||
}
|
||||
|
||||
const fileList = pkg.files
|
||||
.slice(0, 15)
|
||||
.map((f) => ` ${escapeHtml(f.path)}`)
|
||||
.join("\n");
|
||||
const moreFiles = pkg.files.length > 15 ? `\n ... and ${pkg.fileCount - 15} more` : "";
|
||||
|
||||
const details = [
|
||||
`📦 <b>${escapeHtml(pkg.fileName)}</b>`,
|
||||
``,
|
||||
`Type: ${pkg.archiveType}`,
|
||||
`Size: ${formatSize(pkg.fileSize)}`,
|
||||
`Files: ${pkg.fileCount}`,
|
||||
pkg.creator ? `Creator: ${escapeHtml(pkg.creator)}` : null,
|
||||
`Source: ${escapeHtml(pkg.sourceChannel.title)}`,
|
||||
`Indexed: ${formatDate(pkg.indexedAt)}`,
|
||||
pkg.isMultipart ? `Parts: ${pkg.partCount}` : null,
|
||||
``,
|
||||
`<b>File listing:</b>`,
|
||||
`<code>${fileList}${moreFiles}</code>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
// Send preview if available
|
||||
if (pkg.previewData) {
|
||||
await sendPhotoMessage(
|
||||
chatId,
|
||||
Buffer.from(pkg.previewData),
|
||||
details
|
||||
);
|
||||
} else {
|
||||
await sendTextMessage(chatId, details, "textParseModeHTML");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLink(
|
||||
chatId: bigint,
|
||||
userId: bigint,
|
||||
code: string,
|
||||
msg: IncomingMessage
|
||||
): Promise<void> {
|
||||
if (!code) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Usage: /link <code>\n\nGet your link code from Settings → Telegram in the web app.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already linked
|
||||
const existing = await findLinkByTelegramUserId(userId);
|
||||
if (existing) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Your Telegram account is already linked to a web account. Use /unlink first if you want to re-link.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the code
|
||||
const webUserId = await validateLinkCode(code.trim());
|
||||
if (!webUserId) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Invalid or expired link code. Please generate a new one from Settings → Telegram.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the link
|
||||
const displayName = [msg.firstName, msg.lastName].filter(Boolean).join(" ");
|
||||
await createTelegramLink(webUserId, userId, displayName || msg.username || null);
|
||||
await deleteLinkCode(code.trim());
|
||||
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`✅ <b>Account linked successfully!</b>\n\nYou can now receive packages sent from the web app. Use /status to verify.`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
|
||||
log.info({ userId: userId.toString(), webUserId }, "Telegram account linked");
|
||||
}
|
||||
|
||||
async function handleUnlink(chatId: bigint, userId: bigint): Promise<void> {
|
||||
const existing = await findLinkByTelegramUserId(userId);
|
||||
if (!existing) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Your Telegram account is not linked to any web account.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { db } = await import("./db/client.js");
|
||||
await db.telegramLink.delete({ where: { telegramUserId: userId } });
|
||||
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"🔓 Account unlinked. You will no longer receive packages from the web app.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
|
||||
log.info({ userId: userId.toString() }, "Telegram account unlinked");
|
||||
}
|
||||
|
||||
async function handleSubscribe(
|
||||
chatId: bigint,
|
||||
userId: bigint,
|
||||
pattern: string
|
||||
): Promise<void> {
|
||||
if (!pattern) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Usage: /subscribe <keyword>\n\nYou'll be notified when new packages matching this keyword are indexed.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await addSubscription(userId, pattern.toLowerCase().trim());
|
||||
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`🔔 Subscribed to "<b>${escapeHtml(pattern.trim())}</b>".\n\nYou'll be notified when matching packages are indexed.`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUnsubscribe(
|
||||
chatId: bigint,
|
||||
userId: bigint,
|
||||
pattern: string
|
||||
): Promise<void> {
|
||||
if (!pattern) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Usage: /unsubscribe <keyword>",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await removeSubscription(userId, pattern.toLowerCase().trim());
|
||||
|
||||
if (result.count === 0) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`No subscription found for "<b>${escapeHtml(pattern.trim())}</b>".`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
} else {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`🔕 Unsubscribed from "<b>${escapeHtml(pattern.trim())}</b>".`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListSubscriptions(
|
||||
chatId: bigint,
|
||||
userId: bigint
|
||||
): Promise<void> {
|
||||
const subs = await getSubscriptions(userId);
|
||||
|
||||
if (subs.length === 0) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"You have no active subscriptions. Use /subscribe <keyword> to add one.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = subs.map(
|
||||
(s, i) => `${i + 1}. <b>${escapeHtml(s.pattern)}</b> (since ${formatDate(s.createdAt)})`
|
||||
);
|
||||
|
||||
const response = [
|
||||
`🔔 <b>Your subscriptions:</b>`,
|
||||
``,
|
||||
...lines,
|
||||
``,
|
||||
`Use /unsubscribe <keyword> to remove one.`,
|
||||
].join("\n");
|
||||
|
||||
await sendTextMessage(chatId, response, "textParseModeHTML");
|
||||
}
|
||||
|
||||
async function handleStatus(chatId: bigint, userId: bigint): Promise<void> {
|
||||
const link = await findLinkByTelegramUserId(userId);
|
||||
|
||||
if (link) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`✅ <b>Linked</b>\n\nYour Telegram account is linked to a web account.\nLinked since: ${formatDate(link.createdAt)}`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
} else {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`❌ <b>Not linked</b>\n\nUse /link <code> to connect your web account.`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
13
bot/src/db/client.ts
Normal file
13
bot/src/db/client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import pg from "pg";
|
||||
import { config } from "../util/config.js";
|
||||
|
||||
const pool = new pg.Pool({
|
||||
connectionString: config.databaseUrl,
|
||||
max: 5,
|
||||
});
|
||||
|
||||
const adapter = new PrismaPg(pool);
|
||||
export const db = new PrismaClient({ adapter });
|
||||
export { pool };
|
||||
180
bot/src/db/queries.ts
Normal file
180
bot/src/db/queries.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { db } from "./client.js";
|
||||
|
||||
// ── Link management ──
|
||||
|
||||
export async function findLinkByTelegramUserId(telegramUserId: bigint) {
|
||||
return db.telegramLink.findUnique({
|
||||
where: { telegramUserId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function findLinkByUserId(userId: string) {
|
||||
return db.telegramLink.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a link code stored in global_settings as `link_code:<code>`.
|
||||
* Returns the userId if the code is valid, null otherwise.
|
||||
*/
|
||||
export async function validateLinkCode(code: string): Promise<string | null> {
|
||||
const key = `link_code:${code}`;
|
||||
const setting = await db.globalSetting.findUnique({ where: { key } });
|
||||
return setting?.value ?? null;
|
||||
}
|
||||
|
||||
export async function deleteLinkCode(code: string): Promise<void> {
|
||||
const key = `link_code:${code}`;
|
||||
await db.globalSetting.delete({ where: { key } }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function createTelegramLink(
|
||||
userId: string,
|
||||
telegramUserId: bigint,
|
||||
telegramName: string | null
|
||||
) {
|
||||
return db.telegramLink.upsert({
|
||||
where: { userId },
|
||||
create: { userId, telegramUserId, telegramName },
|
||||
update: { telegramUserId, telegramName },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Package search ──
|
||||
|
||||
export async function searchPackages(query: string, limit = 10) {
|
||||
const packages = await db.package.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: query, mode: "insensitive" } },
|
||||
{ creator: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
orderBy: { indexedAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
indexedAt: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
},
|
||||
});
|
||||
return packages;
|
||||
}
|
||||
|
||||
export async function getLatestPackages(limit = 5) {
|
||||
return db.package.findMany({
|
||||
orderBy: { indexedAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
indexedAt: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPackageById(id: string) {
|
||||
return db.package.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: { take: 20, orderBy: { path: "asc" } },
|
||||
sourceChannel: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Send requests ──
|
||||
|
||||
export async function getPendingSendRequest(requestId: string) {
|
||||
return db.botSendRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
package: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
previewData: true,
|
||||
},
|
||||
},
|
||||
telegramLink: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSendRequest(
|
||||
requestId: string,
|
||||
status: "SENDING" | "SENT" | "FAILED",
|
||||
error?: string
|
||||
) {
|
||||
return db.botSendRequest.update({
|
||||
where: { id: requestId },
|
||||
data: {
|
||||
status,
|
||||
error: error ?? undefined,
|
||||
completedAt: status === "SENT" || status === "FAILED" ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Subscriptions ──
|
||||
|
||||
export async function getSubscriptions(telegramUserId: bigint) {
|
||||
return db.botSubscription.findMany({
|
||||
where: { telegramUserId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function addSubscription(telegramUserId: bigint, pattern: string) {
|
||||
return db.botSubscription.upsert({
|
||||
where: {
|
||||
telegramUserId_pattern: { telegramUserId, pattern },
|
||||
},
|
||||
create: { telegramUserId, pattern },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeSubscription(telegramUserId: bigint, pattern: string) {
|
||||
return db.botSubscription.deleteMany({
|
||||
where: { telegramUserId, pattern },
|
||||
});
|
||||
}
|
||||
|
||||
export async function findMatchingSubscriptions(fileName: string, creator: string | null) {
|
||||
// Get all subscriptions and filter in-memory (simpler for pattern matching)
|
||||
const subs = await db.botSubscription.findMany();
|
||||
return subs.filter((sub) => {
|
||||
const p = sub.pattern.toLowerCase();
|
||||
if (fileName.toLowerCase().includes(p)) return true;
|
||||
if (creator && creator.toLowerCase().includes(p)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Destination channel ──
|
||||
|
||||
export async function getGlobalDestinationChannel() {
|
||||
const setting = await db.globalSetting.findUnique({
|
||||
where: { key: "destination_channel_id" },
|
||||
});
|
||||
if (!setting) return null;
|
||||
return db.telegramChannel.findFirst({
|
||||
where: { id: setting.value, type: "DESTINATION", isActive: true },
|
||||
});
|
||||
}
|
||||
92
bot/src/index.ts
Normal file
92
bot/src/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { config } from "./util/config.js";
|
||||
import { logger } from "./util/logger.js";
|
||||
import { db, pool } from "./db/client.js";
|
||||
import { createBotClient, closeBotClient, onBotUpdate } from "./tdlib/client.js";
|
||||
import { startSendListener, stopSendListener } from "./send-listener.js";
|
||||
import { handleMessage } from "./commands.js";
|
||||
import { mkdir } from "fs/promises";
|
||||
|
||||
const log = logger.child({ module: "main" });
|
||||
|
||||
async function main(): Promise<void> {
|
||||
log.info("DragonsStash Telegram Bot starting");
|
||||
|
||||
if (!config.botToken) {
|
||||
log.fatal("BOT_TOKEN environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!config.telegramApiId || !config.telegramApiHash) {
|
||||
log.fatal("TELEGRAM_API_ID and TELEGRAM_API_HASH are required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure TDLib state directory exists
|
||||
await mkdir(config.tdlibStateDir, { recursive: true });
|
||||
await mkdir(`${config.tdlibStateDir}/bot`, { recursive: true });
|
||||
await mkdir(`${config.tdlibStateDir}/bot_files`, { recursive: true });
|
||||
|
||||
// Initialize TDLib bot client
|
||||
await createBotClient();
|
||||
|
||||
// Start pg_notify listener for send requests and new package notifications
|
||||
await startSendListener();
|
||||
|
||||
// Listen for incoming messages from Telegram users
|
||||
onBotUpdate((update) => {
|
||||
if (update._ === "updateNewMessage") {
|
||||
const message = update.message as Record<string, unknown>;
|
||||
const content = message.content as Record<string, unknown>;
|
||||
const chatId = message.chat_id as number;
|
||||
const senderId = message.sender_id as Record<string, unknown> | undefined;
|
||||
|
||||
// Only handle text messages from users (not channels or service messages)
|
||||
if (
|
||||
content?._ === "messageText" &&
|
||||
senderId?._ === "messageSenderUser"
|
||||
) {
|
||||
const text = (content.text as Record<string, unknown>)?.text as string;
|
||||
const userId = senderId.user_id as number;
|
||||
|
||||
if (text && userId) {
|
||||
// Get user info for display name (async but fire-and-forget for perf)
|
||||
handleMessage({
|
||||
chatId: BigInt(chatId),
|
||||
userId: BigInt(userId),
|
||||
text,
|
||||
firstName: "User", // TDLib provides this via a separate getUser call
|
||||
username: undefined,
|
||||
}).catch((err) => {
|
||||
log.error({ err, chatId, userId }, "Failed to handle message");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log.info("Bot is running and listening for messages");
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal: string): void {
|
||||
log.info({ signal }, "Shutdown signal received");
|
||||
stopSendListener();
|
||||
|
||||
Promise.all([closeBotClient(), db.$disconnect(), pool.end()])
|
||||
.then(() => {
|
||||
log.info("Shutdown complete");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, "Error during shutdown");
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
main().catch((err) => {
|
||||
log.fatal({ err }, "Bot failed to start");
|
||||
process.exit(1);
|
||||
});
|
||||
162
bot/src/send-listener.ts
Normal file
162
bot/src/send-listener.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type pg from "pg";
|
||||
import { pool } from "./db/client.js";
|
||||
import { childLogger } from "./util/logger.js";
|
||||
import {
|
||||
getPendingSendRequest,
|
||||
updateSendRequest,
|
||||
findMatchingSubscriptions,
|
||||
getGlobalDestinationChannel,
|
||||
} from "./db/queries.js";
|
||||
import { copyMessageToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
|
||||
|
||||
const log = childLogger("send-listener");
|
||||
|
||||
let pgClient: pg.PoolClient | null = null;
|
||||
|
||||
/**
|
||||
* Start listening for pg_notify signals:
|
||||
* - `bot_send` — payload = requestId → send a package to a user
|
||||
* - `new_package` — payload = JSON { packageId, fileName, creator } → notify subscribers
|
||||
*/
|
||||
export async function startSendListener(): Promise<void> {
|
||||
pgClient = await pool.connect();
|
||||
await pgClient.query("LISTEN bot_send");
|
||||
await pgClient.query("LISTEN new_package");
|
||||
|
||||
pgClient.on("notification", (msg) => {
|
||||
if (msg.channel === "bot_send" && msg.payload) {
|
||||
handleBotSend(msg.payload);
|
||||
} else if (msg.channel === "new_package" && msg.payload) {
|
||||
handleNewPackage(msg.payload);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("Send listener started (bot_send, new_package)");
|
||||
}
|
||||
|
||||
export function stopSendListener(): void {
|
||||
if (pgClient) {
|
||||
pgClient.release();
|
||||
pgClient = null;
|
||||
}
|
||||
log.info("Send listener stopped");
|
||||
}
|
||||
|
||||
// ── bot_send handler ──
|
||||
|
||||
let sendQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function handleBotSend(requestId: string): void {
|
||||
sendQueue = sendQueue.then(() => processSendRequest(requestId)).catch((err) => {
|
||||
log.error({ err, requestId }, "Send request processing failed");
|
||||
});
|
||||
}
|
||||
|
||||
async function processSendRequest(requestId: string): Promise<void> {
|
||||
const request = await getPendingSendRequest(requestId);
|
||||
if (!request || request.status !== "PENDING") {
|
||||
log.warn({ requestId }, "Send request not found or not pending");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
requestId,
|
||||
packageId: request.packageId,
|
||||
targetTgId: request.telegramLink.telegramUserId.toString(),
|
||||
},
|
||||
"Processing send request"
|
||||
);
|
||||
|
||||
await updateSendRequest(requestId, "SENDING");
|
||||
|
||||
try {
|
||||
const pkg = request.package;
|
||||
const targetUserId = request.telegramLink.telegramUserId;
|
||||
|
||||
if (!pkg.destChannelId || !pkg.destMessageId) {
|
||||
throw new Error("Package has no destination message — cannot forward");
|
||||
}
|
||||
|
||||
// Get the destination channel's Telegram ID
|
||||
const destChannel = await getGlobalDestinationChannel();
|
||||
if (!destChannel) {
|
||||
throw new Error("No global destination channel configured");
|
||||
}
|
||||
|
||||
// Send preview if available
|
||||
if (pkg.previewData) {
|
||||
const caption = `📦 *${pkg.fileName}*\n\nSent from Dragon's Stash`;
|
||||
await sendPhotoMessage(targetUserId, Buffer.from(pkg.previewData), caption);
|
||||
}
|
||||
|
||||
// Forward the actual archive file(s) from destination channel
|
||||
await copyMessageToUser(
|
||||
destChannel.telegramId,
|
||||
pkg.destMessageId,
|
||||
targetUserId
|
||||
);
|
||||
|
||||
await updateSendRequest(requestId, "SENT");
|
||||
log.info({ requestId }, "Send request completed successfully");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.error({ err, requestId }, "Send request failed");
|
||||
await updateSendRequest(requestId, "FAILED", message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── new_package handler ──
|
||||
|
||||
async function handleNewPackage(payload: string): Promise<void> {
|
||||
try {
|
||||
const data = JSON.parse(payload) as {
|
||||
packageId: string;
|
||||
fileName: string;
|
||||
creator: string | null;
|
||||
};
|
||||
|
||||
const subs = await findMatchingSubscriptions(data.fileName, data.creator);
|
||||
if (subs.length === 0) return;
|
||||
|
||||
log.info(
|
||||
{ packageId: data.packageId, matchedSubscriptions: subs.length },
|
||||
"Notifying subscribers of new package"
|
||||
);
|
||||
|
||||
// Group by user to send one notification per user
|
||||
const userSubs = new Map<string, string[]>();
|
||||
for (const sub of subs) {
|
||||
const key = sub.telegramUserId.toString();
|
||||
const patterns = userSubs.get(key) ?? [];
|
||||
patterns.push(sub.pattern);
|
||||
userSubs.set(key, patterns);
|
||||
}
|
||||
|
||||
const creator = data.creator ? ` by ${data.creator}` : "";
|
||||
for (const [telegramUserId, patterns] of userSubs) {
|
||||
const msg = [
|
||||
`🔔 <b>New package matching your subscriptions:</b>`,
|
||||
``,
|
||||
`📦 <b>${escapeHtml(data.fileName)}</b>${creator}`,
|
||||
``,
|
||||
`Matched: ${patterns.map((p) => `"${escapeHtml(p)}"`).join(", ")}`,
|
||||
``,
|
||||
`Use /package ${data.packageId} for details.`,
|
||||
].join("\n");
|
||||
|
||||
await sendTextMessage(BigInt(telegramUserId), msg, "textParseModeHTML").catch((err) => {
|
||||
log.warn(
|
||||
{ err, telegramUserId, packageId: data.packageId },
|
||||
"Failed to notify subscriber"
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err, payload }, "Failed to process new_package notification");
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
155
bot/src/tdlib/client.ts
Normal file
155
bot/src/tdlib/client.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import tdl from "tdl";
|
||||
import { getTdjson } from "prebuilt-tdlib";
|
||||
import { config } from "../util/config.js";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
|
||||
const log = childLogger("tdlib-bot");
|
||||
|
||||
tdl.configure({ tdjson: getTdjson() });
|
||||
|
||||
let client: tdl.Client | null = null;
|
||||
|
||||
/**
|
||||
* Create and authenticate a TDLib client using the bot token.
|
||||
* Bot accounts have different capabilities from user accounts —
|
||||
* they can't read channel history but can send/forward/copy messages
|
||||
* to users who have interacted with them.
|
||||
*/
|
||||
export async function createBotClient(): Promise<tdl.Client> {
|
||||
if (client) return client;
|
||||
|
||||
log.info("Creating TDLib bot client");
|
||||
|
||||
client = tdl.createClient({
|
||||
apiId: config.telegramApiId,
|
||||
apiHash: config.telegramApiHash,
|
||||
databaseDirectory: `${config.tdlibStateDir}/bot`,
|
||||
filesDirectory: `${config.tdlibStateDir}/bot_files`,
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
log.error({ err }, "TDLib client error");
|
||||
});
|
||||
|
||||
await client.login(() => ({
|
||||
type: "bot",
|
||||
token: config.botToken,
|
||||
}));
|
||||
|
||||
log.info("Bot client authenticated successfully");
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function closeBotClient(): Promise<void> {
|
||||
if (client) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
client = null;
|
||||
log.info("Bot client closed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a message from a channel to a user's DM.
|
||||
* Uses copyMessage to make it appear as sent by the bot.
|
||||
*/
|
||||
export async function copyMessageToUser(
|
||||
fromChatId: bigint,
|
||||
messageId: bigint,
|
||||
toUserId: bigint
|
||||
): Promise<void> {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
|
||||
// TDLib uses negative chat IDs for channels/supergroups
|
||||
// The telegramId from the DB is the raw Telegram ID; for channels it needs -100 prefix
|
||||
const fromChatIdNum = Number(-100n * 1n) + Number(fromChatId);
|
||||
|
||||
await client.invoke({
|
||||
_: "forwardMessages",
|
||||
chat_id: Number(toUserId),
|
||||
from_chat_id: Number(fromChatId) > 0 ? -Number(fromChatId) : Number(fromChatId),
|
||||
message_ids: [Number(messageId)],
|
||||
send_copy: true,
|
||||
remove_caption: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message to a user.
|
||||
*/
|
||||
export async function sendTextMessage(
|
||||
chatId: bigint,
|
||||
text: string,
|
||||
parseMode: "textParseModeMarkdown" | "textParseModeHTML" = "textParseModeMarkdown"
|
||||
): Promise<void> {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
|
||||
// Parse the text first
|
||||
const parsed = await client.invoke({
|
||||
_: "parseTextEntities",
|
||||
text,
|
||||
parse_mode: { _: parseMode, version: parseMode === "textParseModeMarkdown" ? 2 : 0 },
|
||||
});
|
||||
|
||||
await client.invoke({
|
||||
_: "sendMessage",
|
||||
chat_id: Number(chatId),
|
||||
input_message_content: {
|
||||
_: "inputMessageText",
|
||||
text: parsed,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a photo with caption to a user (for preview images).
|
||||
*/
|
||||
export async function sendPhotoMessage(
|
||||
chatId: bigint,
|
||||
photoData: Buffer,
|
||||
caption: string
|
||||
): Promise<void> {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
|
||||
// Write the photo to a temp file
|
||||
const { writeFile, unlink } = await import("fs/promises");
|
||||
const path = await import("path");
|
||||
const tempPath = path.join(config.tdlibStateDir, `preview_${Date.now()}.jpg`);
|
||||
|
||||
try {
|
||||
await writeFile(tempPath, photoData);
|
||||
|
||||
const parsedCaption = await client.invoke({
|
||||
_: "parseTextEntities",
|
||||
text: caption,
|
||||
parse_mode: { _: "textParseModeMarkdown", version: 2 },
|
||||
});
|
||||
|
||||
await client.invoke({
|
||||
_: "sendMessage",
|
||||
chat_id: Number(chatId),
|
||||
input_message_content: {
|
||||
_: "inputMessagePhoto",
|
||||
photo: { _: "inputFileLocal", path: tempPath },
|
||||
caption: parsedCaption,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await unlink(tempPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updates from TDLib. The bot listens for new messages this way.
|
||||
*/
|
||||
export function onBotUpdate(
|
||||
handler: (update: Record<string, unknown>) => void
|
||||
): void {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
client.on("update", handler);
|
||||
}
|
||||
8
bot/src/util/config.ts
Normal file
8
bot/src/util/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const config = {
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
botToken: process.env.BOT_TOKEN ?? "",
|
||||
telegramApiId: parseInt(process.env.TELEGRAM_API_ID ?? "0", 10),
|
||||
telegramApiHash: process.env.TELEGRAM_API_HASH ?? "",
|
||||
logLevel: (process.env.LOG_LEVEL ?? "info") as "debug" | "info" | "warn" | "error",
|
||||
tdlibStateDir: process.env.TDLIB_STATE_DIR ?? "/data/tdlib",
|
||||
} as const;
|
||||
14
bot/src/util/logger.ts
Normal file
14
bot/src/util/logger.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import pino from "pino";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: config.logLevel,
|
||||
transport:
|
||||
config.logLevel === "debug"
|
||||
? { target: "pino/file", options: { destination: 1 } }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export function childLogger(module: string, extra?: Record<string, unknown>) {
|
||||
return logger.child({ module, ...extra });
|
||||
}
|
||||
Reference in New Issue
Block a user