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:
2026-03-03 21:36:57 +01:00
parent 4d0df6b1a4
commit 575ffdbc31
36 changed files with 4516 additions and 37 deletions

155
bot/src/tdlib/client.ts Normal file
View 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);
}