mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-06-09 18:51:16 +00:00
All checks were successful
continuous-integration/drone/push Build is passing
Manual override training (GroupingRule): - Learn patterns from manual group creation (common filename prefix or creator) - Apply learned rules as first auto-grouping pass (highest confidence after albums) - GroupingRule model stores pattern, channel, signal type, confidence Hash verification after upload: - Re-hash upload files on disk before indexing to catch disk corruption - Creates HASH_MISMATCH notification on discrepancy Grouping conflict detection: - After all grouping passes, check if grouped packages match rules from different groups - Creates GROUPING_CONFLICT notification for manual review Per-channel grouping flags: - Add autoGroupEnabled boolean to TelegramChannel (default true) - Auto-grouping passes (all except album) gated behind this flag - Album grouping always runs as it reflects Telegram's native behavior Full-text search (tsvector): - Add searchVector tsvector column with GIN index and auto-update trigger - Backfill 1870 existing packages - FTS with ts_rank for ranked results, ILIKE fallback for short/failed queries - Applied to both web app and bot search Bot group awareness: - /group <query> — view group info or search groups by name - /sendgroup <id> — send all packages in a group to linked Telegram account Bulk repair: - repairPackageAction clears dest info and resets watermark for re-processing - Repair button in notification bell for MISSING_PART and HASH_MISMATCH alerts - /api/notifications/repair endpoint Retroactive category re-tagging: - When channel category changes, auto-update tags on all existing packages - Removes old category tag, adds new one Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
616 lines
17 KiB
TypeScript
616 lines
17 KiB
TypeScript
import { childLogger } from "./util/logger.js";
|
|
import {
|
|
searchPackages,
|
|
getLatestPackages,
|
|
getPackageById,
|
|
findLinkByTelegramUserId,
|
|
validateLinkCode,
|
|
deleteLinkCode,
|
|
createTelegramLink,
|
|
getSubscriptions,
|
|
addSubscription,
|
|
removeSubscription,
|
|
getGroupById,
|
|
searchGroups,
|
|
} from "./db/queries.js";
|
|
import { db } from "./db/client.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;
|
|
case "/group":
|
|
await handleGroup(chatId, args);
|
|
break;
|
|
case "/sendgroup":
|
|
await handleSendGroup(chatId, userId, args);
|
|
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`,
|
|
`/group <id or name> — View group info and package list`,
|
|
`/sendgroup <id> — Send all packages in a group to yourself`,
|
|
`/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`,
|
|
`/group <id or name> — View group info and package list`,
|
|
`/sendgroup <id> — Send all packages in a group to yourself`,
|
|
``,
|
|
`🔗 <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"
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleGroup(chatId: bigint, query: string): Promise<void> {
|
|
if (!query) {
|
|
await sendTextMessage(
|
|
chatId,
|
|
"Usage: /group <id or name>\n\nProvide a group ID (starts with 'c') or a name to search.",
|
|
"textParseModeHTML"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const trimmed = query.trim();
|
|
|
|
// If it looks like a cuid (starts with 'c', ~25 chars), look up by ID directly
|
|
if (/^c[a-z0-9]{20,}$/i.test(trimmed)) {
|
|
const group = await getGroupById(trimmed);
|
|
if (!group) {
|
|
await sendTextMessage(chatId, "Group not found.", "textParseModeHTML");
|
|
return;
|
|
}
|
|
|
|
const packageLines = group.packages.slice(0, 20).map((pkg, i) => {
|
|
const size = formatSize(pkg.fileSize);
|
|
return ` ${i + 1}. <b>${escapeHtml(pkg.fileName)}</b> (${size}, ${pkg.fileCount} files) — <code>${pkg.id}</code>`;
|
|
});
|
|
const more = group.packages.length > 20
|
|
? `\n ... and ${group.packages.length - 20} more`
|
|
: "";
|
|
|
|
const response = [
|
|
`📦 <b>Group: ${escapeHtml(group.name)}</b>`,
|
|
``,
|
|
`Packages: ${group.packages.length}`,
|
|
`ID: <code>${group.id}</code>`,
|
|
``,
|
|
`<b>Contents:</b>`,
|
|
...packageLines,
|
|
more,
|
|
``,
|
|
`Use /sendgroup ${group.id} to receive all packages.`,
|
|
]
|
|
.filter((l) => l !== "")
|
|
.join("\n");
|
|
|
|
await sendTextMessage(chatId, response, "textParseModeHTML");
|
|
return;
|
|
}
|
|
|
|
// Otherwise search by name
|
|
const groups = await searchGroups(trimmed, 5);
|
|
|
|
if (groups.length === 0) {
|
|
await sendTextMessage(
|
|
chatId,
|
|
`No groups found matching "<b>${escapeHtml(trimmed)}</b>".`,
|
|
"textParseModeHTML"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const lines = groups.map(
|
|
(g, i) =>
|
|
`${i + 1}. <b>${escapeHtml(g.name)}</b> — ${g._count.packages} package(s)\n ID: <code>${g.id}</code>`
|
|
);
|
|
|
|
const response = [
|
|
`🔍 <b>Groups matching "${escapeHtml(trimmed)}":</b>`,
|
|
``,
|
|
...lines,
|
|
``,
|
|
`Use /group <id> for full details.`,
|
|
].join("\n");
|
|
|
|
await sendTextMessage(chatId, response, "textParseModeHTML");
|
|
}
|
|
|
|
async function handleSendGroup(
|
|
chatId: bigint,
|
|
userId: bigint,
|
|
args: string
|
|
): Promise<void> {
|
|
if (!args) {
|
|
await sendTextMessage(
|
|
chatId,
|
|
"Usage: /sendgroup <group-id>",
|
|
"textParseModeHTML"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const groupId = args.trim();
|
|
const group = await getGroupById(groupId);
|
|
|
|
if (!group) {
|
|
await sendTextMessage(chatId, "Group not found.", "textParseModeHTML");
|
|
return;
|
|
}
|
|
|
|
// Require account linking
|
|
const link = await findLinkByTelegramUserId(userId);
|
|
if (!link) {
|
|
await sendTextMessage(
|
|
chatId,
|
|
"You must link your account before receiving packages.\nUse /link <code> to connect.",
|
|
"textParseModeHTML"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Only send packages that have been uploaded to the destination channel
|
|
const sendable = group.packages.filter(
|
|
(pkg) => pkg.destChannelId && pkg.destMessageId
|
|
);
|
|
|
|
if (sendable.length === 0) {
|
|
await sendTextMessage(
|
|
chatId,
|
|
`No packages in group "<b>${escapeHtml(group.name)}</b>" are ready to send yet.`,
|
|
"textParseModeHTML"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Create a BotSendRequest for each sendable package
|
|
const requests = await Promise.all(
|
|
sendable.map((pkg) =>
|
|
db.botSendRequest.create({
|
|
data: {
|
|
packageId: pkg.id,
|
|
telegramLinkId: link.id,
|
|
requestedByUserId: link.userId,
|
|
status: "PENDING",
|
|
},
|
|
})
|
|
)
|
|
);
|
|
|
|
// Fire pg_notify for each request so the send listener picks them up
|
|
for (const req of requests) {
|
|
await db.$queryRawUnsafe(
|
|
`SELECT pg_notify('bot_send', $1)`,
|
|
req.id
|
|
).catch(() => {
|
|
// Best-effort — the bot also processes PENDING requests on its send queue
|
|
});
|
|
}
|
|
|
|
await sendTextMessage(
|
|
chatId,
|
|
[
|
|
`✅ <b>Queued ${requests.length} package(s) from "${escapeHtml(group.name)}"</b>`,
|
|
``,
|
|
`You'll receive each archive shortly. Use /package <id> to check individual packages.`,
|
|
].join("\n"),
|
|
"textParseModeHTML"
|
|
);
|
|
|
|
log.info(
|
|
{ groupId, packageCount: requests.length, userId: userId.toString() },
|
|
"Group send queued"
|
|
);
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|