mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
feat: complete remaining features — training, FTS, bot groups, repair, re-tag
All checks were successful
continuous-integration/drone/push Build is passing
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>
This commit is contained in:
@@ -10,7 +10,10 @@ import {
|
||||
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");
|
||||
@@ -78,6 +81,12 @@ export async function handleMessage(msg: IncomingMessage): Promise<void> {
|
||||
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,
|
||||
@@ -117,6 +126,8 @@ async function handleStart(
|
||||
`/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`,
|
||||
@@ -136,6 +147,8 @@ async function handleHelp(chatId: bigint): Promise<void> {
|
||||
`/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`,
|
||||
@@ -432,6 +445,168 @@ async function handleStatus(chatId: bigint, userId: bigint): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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, "&")
|
||||
|
||||
@@ -53,7 +53,52 @@ export async function createTelegramLink(
|
||||
// ── Package search ──
|
||||
|
||||
export async function searchPackages(query: string, limit = 10) {
|
||||
const packages = await db.package.findMany({
|
||||
// Try full-text search first
|
||||
if (query.length >= 3) {
|
||||
const tsQuery = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 2)
|
||||
.map((w) => w.replace(/[^a-zA-Z0-9]/g, ""))
|
||||
.filter(Boolean)
|
||||
.join(" & ");
|
||||
|
||||
if (tsQuery) {
|
||||
try {
|
||||
const ftsResults = await db.$queryRawUnsafe<{ id: string }[]>(
|
||||
`SELECT id FROM packages
|
||||
WHERE "searchVector" @@ to_tsquery('english', $1)
|
||||
ORDER BY ts_rank("searchVector", to_tsquery('english', $1)) DESC
|
||||
LIMIT $2`,
|
||||
tsQuery,
|
||||
limit
|
||||
);
|
||||
|
||||
if (ftsResults.length > 0) {
|
||||
return db.package.findMany({
|
||||
where: { id: { in: ftsResults.map((r) => r.id) } },
|
||||
orderBy: { indexedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
indexedAt: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// FTS failed — fall back to ILIKE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ILIKE search
|
||||
return db.package.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: query, mode: "insensitive" } },
|
||||
@@ -74,7 +119,44 @@ export async function searchPackages(query: string, limit = 10) {
|
||||
destMessageId: true,
|
||||
},
|
||||
});
|
||||
return packages;
|
||||
}
|
||||
|
||||
// ── Group queries ──
|
||||
|
||||
export async function getGroupById(groupId: string) {
|
||||
return db.packageGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: {
|
||||
packages: {
|
||||
orderBy: { indexedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchGroups(query: string, limit = 5) {
|
||||
return db.packageGroup.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
_count: { select: { packages: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestPackages(limit = 5) {
|
||||
|
||||
Reference in New Issue
Block a user