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 { 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 { // Deep link: /start link_ if (args.startsWith("link_")) { const code = args.slice(5); await handleLink(chatId, userId, code, msg); return; } const welcome = [ `๐Ÿ‰ Dragon's Stash Bot`, ``, `I can help you search and receive indexed archive packages.`, ``, `Commands:`, `/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 { const help = [ `Available Commands:`, ``, `๐Ÿ” Search & Browse`, `/search <query> โ€” Search by filename or creator`, `/latest [n] โ€” Show n most recent packages (default: 5)`, `/package <id> โ€” View package details and file list`, ``, `๐Ÿ”— Account Linking`, `/link <code> โ€” Link Telegram to your web account`, `/unlink โ€” Unlink your Telegram account`, `/status โ€” Check link status`, ``, `๐Ÿ”” Notifications`, `/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 { 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 "${escapeHtml(query)}".`, "textParseModeHTML" ); return; } const lines = results.map((pkg, i) => { const creator = pkg.creator ? ` by ${pkg.creator}` : ""; return `${i + 1}. ${escapeHtml(pkg.fileName)}${creator}\n ๐Ÿ“ฆ ${pkg.fileCount} files ยท ${formatSize(pkg.fileSize)} ยท ${formatDate(pkg.indexedAt)}\n ID: ${pkg.id}`; }); const response = [ `๐Ÿ” Search results for "${escapeHtml(query)}":`, ``, ...lines, ``, `Use /package <id> for details.`, ].join("\n"); await sendTextMessage(chatId, response, "textParseModeHTML"); } async function handleLatest(chatId: bigint, args: string): Promise { 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}. ${escapeHtml(pkg.fileName)}${creator}\n ๐Ÿ“ฆ ${pkg.fileCount} files ยท ${formatSize(pkg.fileSize)} ยท ${formatDate(pkg.indexedAt)}\n ID: ${pkg.id}`; }); const response = [ `๐Ÿ“‹ Latest ${results.length} packages:`, ``, ...lines, ``, `Use /package <id> for details.`, ].join("\n"); await sendTextMessage(chatId, response, "textParseModeHTML"); } async function handlePackage(chatId: bigint, id: string): Promise { 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 = [ `๐Ÿ“ฆ ${escapeHtml(pkg.fileName)}`, ``, `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, ``, `File listing:`, `${fileList}${moreFiles}`, ] .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 { 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, `โœ… Account linked successfully!\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 { 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 { 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 "${escapeHtml(pattern.trim())}".\n\nYou'll be notified when matching packages are indexed.`, "textParseModeHTML" ); } async function handleUnsubscribe( chatId: bigint, userId: bigint, pattern: string ): Promise { 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 "${escapeHtml(pattern.trim())}".`, "textParseModeHTML" ); } else { await sendTextMessage( chatId, `๐Ÿ”• Unsubscribed from "${escapeHtml(pattern.trim())}".`, "textParseModeHTML" ); } } async function handleListSubscriptions( chatId: bigint, userId: bigint ): Promise { 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}. ${escapeHtml(s.pattern)} (since ${formatDate(s.createdAt)})` ); const response = [ `๐Ÿ”” Your subscriptions:`, ``, ...lines, ``, `Use /unsubscribe <keyword> to remove one.`, ].join("\n"); await sendTextMessage(chatId, response, "textParseModeHTML"); } async function handleStatus(chatId: bigint, userId: bigint): Promise { const link = await findLinkByTelegramUserId(userId); if (link) { await sendTextMessage( chatId, `โœ… Linked\n\nYour Telegram account is linked to a web account.\nLinked since: ${formatDate(link.createdAt)}`, "textParseModeHTML" ); } else { await sendTextMessage( chatId, `โŒ Not linked\n\nUse /link <code> to connect your web account.`, "textParseModeHTML" ); } } function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">"); }