mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-07-22 23:12:02 +00:00
Compare commits
54 Commits
59038889ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6178ff3b08 | |||
| 57842c6d95 | |||
| b3c49c3794 | |||
| 20e60bc6af | |||
| 50e89719bb | |||
| 1cae855c26 | |||
| b0baf72f0a | |||
| 0cf5fcd3a7 | |||
| 3b7202a662 | |||
| 23f8e91c50 | |||
| c749d03376 | |||
| d7771887a1 | |||
| 26bc43299a | |||
| 8b500a1610 | |||
| f0a9d3b4da | |||
| b90317c007 | |||
| 6f8ddcca81 | |||
| 25ff067ea0 | |||
| 7e58cc29fa | |||
| 5a4e358eee | |||
| c31afc5b92 | |||
| 6324d64870 | |||
| 974769350b | |||
| f9b82f1654 | |||
| 7146a5cf0d | |||
| 4f6a6f0f75 | |||
| 1a4bc6f9f3 | |||
| c6b23715e8 | |||
| 3111d658f8 | |||
| 6652fb8bc4 | |||
| ff846b8e8e | |||
| 3be3509151 | |||
| 6223c47549 | |||
| 13b261c0c8 | |||
| 25a6196262 | |||
| 166dc556c9 | |||
| e8daabd28d | |||
| 106700b13f | |||
| 04effed825 | |||
| c4d9be83bd | |||
| 7d39a13310 | |||
| 18a0efb3d4 | |||
| 2ccc9820cd | |||
| c72b5a4b48 | |||
| 0bdd4ba0cc | |||
| 901f32ff41 | |||
| ff4e150544 | |||
| 77aeb4cc00 | |||
| 3b327eb3f3 | |||
| 379bf246cd | |||
| 7a79b52baf | |||
| 26e2cba69d | |||
| 84cc8d995b | |||
| d99a506b10 |
94
bot/package-lock.json
generated
94
bot/package-lock.json
generated
@@ -12,8 +12,8 @@
|
|||||||
"@prisma/client": "^7.4.0",
|
"@prisma/client": "^7.4.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"prebuilt-tdlib": "^0.1008050.0",
|
"prebuilt-tdlib": "^0.1008064.0",
|
||||||
"tdl": "^8.0.0"
|
"tdl": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -566,9 +566,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/darwin-arm64": {
|
"node_modules/@prebuilt-tdlib/darwin-arm64": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-XrWN7M1gfvnzOBRX0YdXVfhSxIDSs/ZJ16QJ0ILDKe+grOFl/cfl7lwB/hK/MlHC6Rev56f5X7xaWnjMh0vktQ==",
|
"integrity": "sha512-Oq5us+o0g68Jag74RIV3LdLkZxQxJMcOdrVbgmyE7Unk+WcifqTb/gZw1rS6BrW+2SX2LNeGY4zQqqBTNDr17Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -579,9 +579,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/darwin-x64": {
|
"node_modules/@prebuilt-tdlib/darwin-x64": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-a1UfBW0lYx4tUy5viMPtsbqBfBncCAgDu3FPjljfYTHjP8wfkKFxpp5+8wdxhyqdy3QriWaipVtUXQgOeEWMJg==",
|
"integrity": "sha512-Pz11xjET2Y3uUJKxkWKBc0dmOtlykmBdZ9D6Ahh+EsoLDLIWHm7M91p6nZT396YZ4n2BL+FtDYK65Ae3LDIA5g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -592,9 +592,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/linux-arm64-glibc": {
|
"node_modules/@prebuilt-tdlib/linux-arm64-glibc": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-HRGspdQYzaBkU+W2M8uY5OgOkmgfTkyHkTYan/dn7EE/38QdIFW0YTvmGrl3DoFV2PA+SeJQw0xqK8tMSyHKaA==",
|
"integrity": "sha512-1kML9+RCfTOTWLzxq2klCN962/XwYhd+SGd4BxOwcmvPniYDNRUXtgMi3qRyV/Flola8dchGFrqZJU4kNZNLuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@prebuilt-tdlib/linux-arm64-musl": {
|
||||||
|
"version": "0.1008064.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-musl/-/linux-arm64-musl-0.1008064.0.tgz",
|
||||||
|
"integrity": "sha512-tN9FJOR8VDfmOoTHMivAqBfQ/d9Bry9T/9cGSTcms3H4ORun/WO5U5zT8VqadAsqjuiQ8Y9HaUqqz65xBDtcgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -605,9 +618,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/linux-x64-glibc": {
|
"node_modules/@prebuilt-tdlib/linux-x64-glibc": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-Yf6ve3Dzxc66kV1cijFLn7EXKhPN5YHTjtJABEaCR5euetCI2wZp/1uBsXvyYTuFXqQbMfjO3xUCXUIBhLoChw==",
|
"integrity": "sha512-7fyCp2uk0BdeHKJ9PyQOCditC9vBXeeIjYPAKKBcrkum5bi1e9txy2g5kkGjqwUkN0ntIniS5QfHEyr17Idr9g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -617,10 +630,30 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@prebuilt-tdlib/linux-x64-musl": {
|
||||||
|
"version": "0.1008064.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-musl/-/linux-x64-musl-0.1008064.0.tgz",
|
||||||
|
"integrity": "sha512-e2zRucrRrrK6M04iQWMfwtrts+VvVtyUwtTP1hF2g3a6jW+AHMzFoB9Wu8fWr+vuJflLIQ6sG9r3lI07Q8NenQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@prebuilt-tdlib/types": {
|
||||||
|
"version": "0.1008064.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/types/-/types-0.1008064.0.tgz",
|
||||||
|
"integrity": "sha512-eqr1+fiHZ+Gj4lwcITzMp6FwPg8UrxlxxaFjhiJRHL9BlbmD2QkCRHac4wW1Sx8Dzwzd7f+xO21Pgi7TBRSwmw==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@prebuilt-tdlib/win32-x64": {
|
"node_modules/@prebuilt-tdlib/win32-x64": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-4v8tU5bodMcLhzrWWXzIzqdHBIpq0wim+7sDmQWQIMy3kDeIzVtpuM+vQjxrGoeH9oWr2WXSRKuj93ld7G5NbQ==",
|
"integrity": "sha512-rkacZWexQw52/EUaLAmbsu2+P3C1/AtinlCjfiX07oQAEg3327BCEZqrcY0ER83D8+MMf2pfwMPCDJKytr4hcg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1669,16 +1702,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuilt-tdlib": {
|
"node_modules/prebuilt-tdlib": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-CfeQE1rG51d2iC6m72fzrbCW4mqI17ugil9pVurWHtfUJi1Fcn7zadpTzDoUl4oc1dEtKgM7S24DVP67gcl4SQ==",
|
"integrity": "sha512-jJLowKZoH4slXYrkTkKlEgyGsIGv61AWjDZcxxVxJYu21X3kmukGwbCpk4ML99cJp2CwRsD41GCEQBkKJAwCUg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@prebuilt-tdlib/darwin-arm64": "0.1008050.0",
|
"@prebuilt-tdlib/darwin-arm64": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/darwin-x64": "0.1008050.0",
|
"@prebuilt-tdlib/darwin-x64": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/linux-arm64-glibc": "0.1008050.0",
|
"@prebuilt-tdlib/linux-arm64-glibc": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/linux-x64-glibc": "0.1008050.0",
|
"@prebuilt-tdlib/linux-arm64-musl": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/win32-x64": "0.1008050.0"
|
"@prebuilt-tdlib/linux-x64-glibc": "0.1008064.0",
|
||||||
|
"@prebuilt-tdlib/linux-x64-musl": "0.1008064.0",
|
||||||
|
"@prebuilt-tdlib/types": "0.1008064.0",
|
||||||
|
"@prebuilt-tdlib/win32-x64": "0.1008064.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
@@ -1971,13 +2007,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tdl": {
|
"node_modules/tdl": {
|
||||||
"version": "8.0.2",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tdl/-/tdl-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tdl/-/tdl-8.1.0.tgz",
|
||||||
"integrity": "sha512-KYxlJ4eao7FUu91U1dCDkaHmK70JAyZ1KqitkKqpPC7rxAiXWhaYxddWvt84UxIYoWbgdd0B70FYJ4p/YqpFCA==",
|
"integrity": "sha512-idpw60gjJdiJALQg0+6UbxtJTMxVhzZAgCO6QzL81gqBYCkEFjm9zM9HwTTQGeOaAavw4yRHymR68yUUiCoKrA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.3",
|
||||||
"node-addon-api": "^7.1.1",
|
"node-addon-api": "^7.1.1",
|
||||||
"node-gyp-build": "^4.8.4"
|
"node-gyp-build": "^4.8.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
"@prisma/client": "^7.4.0",
|
"@prisma/client": "^7.4.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"prebuilt-tdlib": "^0.1008050.0",
|
"prebuilt-tdlib": "^0.1008064.0",
|
||||||
"tdl": "^8.0.0"
|
"tdl": "^8.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
1134
docs/superpowers/plans/2026-05-26-channel-scan-skip.md
Normal file
1134
docs/superpowers/plans/2026-05-26-channel-scan-skip.md
Normal file
File diff suppressed because it is too large
Load Diff
454
docs/superpowers/plans/2026-07-04-send-all-from-creator.md
Normal file
454
docs/superpowers/plans/2026-07-04-send-all-from-creator.md
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
# Send All From Creator Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a toolbar button on the STL packages page that queues every sendable package from the currently-filtered creator for delivery to the user's Telegram.
|
||||||
|
|
||||||
|
**Architecture:** A new server action `sendAllFromCreatorAction(creatorName)` mirrors the existing `sendAllInGroupAction`: it fetches sendable packages for the creator, dedups against live `BotSendRequest`s, creates a `BotSendRequest` per package, and fires `pg_notify('bot_send', id)`. The bot's existing `send-listener` consumes these unchanged. The client table renders a "Send all from [Creator]" button in the packages-tab toolbar only when `?creator=<name>` is active, wired to the action with a confirm dialog and a count toast.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router, TypeScript, Prisma v7, server actions, `pg_notify`, sonner toasts, lucide-react icons.
|
||||||
|
|
||||||
|
**Testing note:** This repo has no test framework (`CLAUDE.md`: "Testing is manual"). Verification for each task is `npm run lint` + `npm run build`, plus manual UI checks at the end. Do not scaffold a test harness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `sendAllFromCreatorAction` server action
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/(app)/stls/actions.ts` (append new action after `sendAllInGroupAction`, which ends at line 591)
|
||||||
|
|
||||||
|
Reference implementation to mirror: `sendAllInGroupAction` at `src/app/(app)/stls/actions.ts:515-591`.
|
||||||
|
Existing imports already present in the file (do not re-add): `auth` from `@/lib/auth`, `prisma` from `@/lib/prisma`, `ActionResult` from `@/types/api.types`, `revalidatePath` from `next/cache`.
|
||||||
|
`ActionResult` is generic: `ActionResult<T = void>` (`src/types/api.types.ts`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Append the new action**
|
||||||
|
|
||||||
|
Add this to the end of `src/app/(app)/stls/actions.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function sendAllFromCreatorAction(
|
||||||
|
creatorName: string
|
||||||
|
): Promise<ActionResult<{ queued: number; skipped: number }>> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||||
|
|
||||||
|
const creator = creatorName.trim();
|
||||||
|
if (!creator) {
|
||||||
|
return { success: false, error: "No creator specified" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const telegramLink = await prisma.telegramLink.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!telegramLink) {
|
||||||
|
return { success: false, error: "No linked Telegram account. Link one in Settings." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendablePackages = await prisma.package.findMany({
|
||||||
|
where: {
|
||||||
|
creator,
|
||||||
|
destChannelId: { not: null },
|
||||||
|
destMessageId: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sendablePackages.length === 0) {
|
||||||
|
return { success: false, error: "No uploaded packages found for this creator" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let queued = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const pkg of sendablePackages) {
|
||||||
|
// Only create if no existing PENDING/SENDING request for this package+link combo
|
||||||
|
const existing = await prisma.botSendRequest.findFirst({
|
||||||
|
where: {
|
||||||
|
packageId: pkg.id,
|
||||||
|
telegramLinkId: telegramLink.id,
|
||||||
|
status: { in: ["PENDING", "SENDING"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRequest = await prisma.botSendRequest.create({
|
||||||
|
data: {
|
||||||
|
packageId: pkg.id,
|
||||||
|
telegramLinkId: telegramLink.id,
|
||||||
|
requestedByUserId: session.user.id,
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the bot via pg_notify
|
||||||
|
try {
|
||||||
|
await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT pg_notify('bot_send', $1)`,
|
||||||
|
sendRequest.id
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort — the bot also polls periodically
|
||||||
|
}
|
||||||
|
|
||||||
|
queued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/stls");
|
||||||
|
return { success: true, data: { queued, skipped } };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to send creator packages" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify lint + typecheck pass**
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: no new errors in `src/app/(app)/stls/actions.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/\(app\)/stls/actions.ts
|
||||||
|
git commit -m "feat(stls): add sendAllFromCreatorAction server action"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Wire up the toolbar button in the STL table
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/(app)/stls/_components/stl-table.tsx`
|
||||||
|
- lucide import (line 6)
|
||||||
|
- actions import block (lines 44-54)
|
||||||
|
- handler (after `handleSendAllInGroup`, which ends at line 278)
|
||||||
|
- `activeCreator` derivation (near `activeTag` at line 445)
|
||||||
|
- toolbar JSX (packages-tab toolbar row starting at line 478)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the `Send` icon to the lucide import**
|
||||||
|
|
||||||
|
Change line 6 from:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Search, Layers, Upload } from "lucide-react";
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Search, Layers, Upload, Send } from "lucide-react";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Import the new action**
|
||||||
|
|
||||||
|
In the actions import block (lines 44-54), add `sendAllFromCreatorAction` next to `sendAllInGroupAction`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
updatePackageCreator,
|
||||||
|
updatePackageTags,
|
||||||
|
renameGroupAction,
|
||||||
|
dissolveGroupAction,
|
||||||
|
createGroupAction,
|
||||||
|
removeFromGroupAction,
|
||||||
|
sendAllInGroupAction,
|
||||||
|
sendAllFromCreatorAction,
|
||||||
|
updateGroupPreviewAction,
|
||||||
|
mergeGroupsAction,
|
||||||
|
} from "../actions";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Derive the active creator from the URL**
|
||||||
|
|
||||||
|
Immediately after the `activeTag` line (`src/app/(app)/stls/_components/stl-table.tsx:445`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const activeTag = searchParams.get("tag") ?? "";
|
||||||
|
```
|
||||||
|
|
||||||
|
add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const activeCreator = searchParams.get("creator") ?? "";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the click handler**
|
||||||
|
|
||||||
|
After `handleSendAllInGroup` (which ends at line 278), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const handleSendAllFromCreator = useCallback(() => {
|
||||||
|
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await sendAllFromCreatorAction(activeCreator);
|
||||||
|
if (result.success) {
|
||||||
|
const { queued, skipped } = result.data;
|
||||||
|
toast.success(
|
||||||
|
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
|
||||||
|
(skipped ? ` (${skipped} already queued)` : "")
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [activeCreator, router]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Render the toolbar button**
|
||||||
|
|
||||||
|
In the packages-tab toolbar (`flex flex-wrap items-center gap-2` row at line 478), add the button right after the "Upload Files" button (which closes at line 507, before the `selectedPackages.size >= 2` block at line 508):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{activeCreator && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 gap-1.5"
|
||||||
|
onClick={handleSendAllFromCreator}
|
||||||
|
>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
Send all from {activeCreator}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify lint + build pass**
|
||||||
|
|
||||||
|
Run: `npm run lint && npm run build`
|
||||||
|
Expected: no new errors; build completes.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual verification**
|
||||||
|
|
||||||
|
1. `npm run dev`, open `/stls`.
|
||||||
|
2. Click a creator name in the Creator column to apply `?creator=<name>` (or navigate to `/stls?creator=<known creator>`).
|
||||||
|
3. Confirm the "Send all from [Creator]" button appears in the toolbar.
|
||||||
|
4. Remove the creator filter → confirm the button disappears.
|
||||||
|
5. Click the button → confirm the dialog appears; on confirm, a toast reports the queued count.
|
||||||
|
6. (If a linked Telegram account + uploaded packages exist) confirm packages arrive via the bot.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/\(app\)/stls/_components/stl-table.tsx
|
||||||
|
git commit -m "feat(stls): add 'send all from creator' toolbar button"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Searchable creator filter combobox (makes the filter reachable)
|
||||||
|
|
||||||
|
**Why:** Discovered during execution — the `?creator=` filter that reveals the Task 2
|
||||||
|
button had no UI trigger. The creator table cell click only opens the edit prompt
|
||||||
|
(`package-columns.tsx:339` → `onSetCreator`). This task adds a searchable
|
||||||
|
"All Creators" combobox to the packages-tab toolbar (like the existing Tags select,
|
||||||
|
but type-to-filter since there can be many creators). The creator cell stays
|
||||||
|
edit-only.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/telegram/queries.ts` — add `getAllPackageCreators()`.
|
||||||
|
- Modify: `src/app/(app)/stls/page.tsx` — fetch + pass `availableCreators`.
|
||||||
|
- Create: `src/app/(app)/stls/_components/creator-filter.tsx` — the combobox.
|
||||||
|
- Modify: `src/app/(app)/stls/_components/stl-table.tsx` — new prop, handler, render.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the distinct-creators query**
|
||||||
|
|
||||||
|
Append to `src/lib/telegram/queries.ts` (mirrors `getAllPackageTags` at line 530):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export async function getAllPackageCreators(): Promise<string[]> {
|
||||||
|
const result = await prisma.$queryRaw<{ creator: string }[]>`
|
||||||
|
SELECT DISTINCT creator FROM packages
|
||||||
|
WHERE creator IS NOT NULL AND creator <> ''
|
||||||
|
ORDER BY creator
|
||||||
|
`;
|
||||||
|
return result.map((r) => r.creator);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the combobox component**
|
||||||
|
|
||||||
|
Create `src/app/(app)/stls/_components/creator-filter.tsx` (mirrors the
|
||||||
|
Popover+Command pattern in `src/components/shared/data-table-faceted-filter.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
|
interface CreatorFilterProps {
|
||||||
|
creators: string[];
|
||||||
|
value: string; // active creator, "" when none
|
||||||
|
onChange: (creator: string) => void; // "" clears the filter
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-9 w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
<span className="truncate">{value || "All Creators"}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search creators..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No creators found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="__all__"
|
||||||
|
onSelect={() => {
|
||||||
|
onChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
All Creators
|
||||||
|
</CommandItem>
|
||||||
|
{creators.map((creator) => (
|
||||||
|
<CommandItem
|
||||||
|
key={creator}
|
||||||
|
value={creator}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(creator);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === creator ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{creator}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the query through the page**
|
||||||
|
|
||||||
|
In `src/app/(app)/stls/page.tsx`:
|
||||||
|
- Add `getAllPackageCreators` to the import from `@/lib/telegram/queries` (line 3).
|
||||||
|
- Add `getAllPackageCreators()` to the `Promise.all` (line 27) and destructure
|
||||||
|
`availableCreators`:
|
||||||
|
```ts
|
||||||
|
const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] =
|
||||||
|
await Promise.all([
|
||||||
|
// ...existing entries unchanged...
|
||||||
|
getIngestionStatus(),
|
||||||
|
getAllPackageTags(),
|
||||||
|
getAllPackageCreators(),
|
||||||
|
countSkippedPackages(),
|
||||||
|
countUngroupedPackages(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
(Insert `getAllPackageCreators()` immediately after `getAllPackageTags()`, and add
|
||||||
|
`availableCreators` in the matching position of the destructure.)
|
||||||
|
- Pass the prop to `<StlTable>`: `availableCreators={availableCreators}` (next to
|
||||||
|
`availableTags={availableTags}`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add prop + handler + render in the table**
|
||||||
|
|
||||||
|
In `src/app/(app)/stls/_components/stl-table.tsx`:
|
||||||
|
- Import the component: `import { CreatorFilter } from "./creator-filter";`
|
||||||
|
- Add to `StlTableProps` (next to `availableTags: string[];`): `availableCreators: string[];`
|
||||||
|
- Add to the destructured params (next to `availableTags,`): `availableCreators,`
|
||||||
|
- Add the handler after `updateTagFilter` (ends ~line 210):
|
||||||
|
```ts
|
||||||
|
const updateCreatorFilter = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set("creator", value);
|
||||||
|
params.set("page", "1");
|
||||||
|
} else {
|
||||||
|
params.delete("creator");
|
||||||
|
}
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[router, pathname, searchParams]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Render it in the toolbar right after the Tags `Select` block (the
|
||||||
|
`{availableTags.length > 0 && (...)}` block, ~lines 507-522):
|
||||||
|
```tsx
|
||||||
|
{availableCreators.length > 0 && (
|
||||||
|
<CreatorFilter
|
||||||
|
creators={availableCreators}
|
||||||
|
value={activeCreator}
|
||||||
|
onChange={updateCreatorFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify + manual test**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit` (filter for the touched files — expect none new). Note: full
|
||||||
|
`npm run build` fails on a pre-existing stale Prisma client issue in
|
||||||
|
`src/lib/telegram/*` unrelated to this change; do not attempt to fix it.
|
||||||
|
Manual: open `/stls`, use the "All Creators" combobox, type to filter, pick a
|
||||||
|
creator → list filters and the "Send all from [Creator]" button appears; pick
|
||||||
|
"All Creators" → filter clears and button disappears.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add "src/lib/telegram/queries.ts" "src/app/(app)/stls/page.tsx" \
|
||||||
|
"src/app/(app)/stls/_components/creator-filter.tsx" \
|
||||||
|
"src/app/(app)/stls/_components/stl-table.tsx"
|
||||||
|
git commit -m "feat(stls): add searchable creator filter combobox to toolbar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Req 1 (button when filtered by creator) → Task 2 Step 5 (`{activeCreator && ...}`).
|
||||||
|
- Req 2 (hidden when no filter) → Task 2 Step 5 conditional.
|
||||||
|
- Req 3 (confirm dialog) → Task 2 Step 4 `confirm(...)`.
|
||||||
|
- Req 4 (all pages, not current page) → Task 1 queries `prisma.package.findMany` by `creator`, unpaginated.
|
||||||
|
- Req 5 (skip not-uploaded + dedup live requests) → Task 1 `where` filter on `destChannelId`/`destMessageId` + `existing` check.
|
||||||
|
- Req 6 (report counts) → Task 1 returns `{ queued, skipped }`; Task 2 Step 4 toast.
|
||||||
|
- Req 7 (no polling) → Task 2 handler queues + `router.refresh()`, no poll loop.
|
||||||
|
- Blank-creator guard → Task 1 `creator.trim()` check.
|
||||||
|
|
||||||
|
**Placeholder scan:** No TBD/TODO/placeholder steps; all code shown in full.
|
||||||
|
|
||||||
|
**Type consistency:** Action returns `ActionResult<{ queued: number; skipped: number }>`; handler destructures `result.data.{queued,skipped}` inside the `result.success` branch (where `data` is typed). `sendAllFromCreatorAction` name matches between Task 1 definition, Task 2 import, and Task 2 call site. `activeCreator` defined once (Step 3) and used in Steps 4-5.
|
||||||
353
docs/superpowers/specs/2026-05-26-channel-scan-skip-design.md
Normal file
353
docs/superpowers/specs/2026-05-26-channel-scan-skip-design.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# Channel-Scan Skip Optimization — Design
|
||||||
|
|
||||||
|
**Goal:** stop the worker from re-scanning channels and forum topics that haven't changed since the last scan, especially on restart. Reduce the per-cycle API call count for the Model Printing Emporium channel (1,086 forum topics) from ~1,000+ to ~50.
|
||||||
|
|
||||||
|
**Non-goals:**
|
||||||
|
- Replacing polling with event-driven ingestion (`updateNewMessage`). That's a separate, larger design (Phase 2 in the original brainstorm).
|
||||||
|
- Surfacing per-channel scan history in the UI (also a separate, observability-only design).
|
||||||
|
|
||||||
|
**Architecture sketch:**
|
||||||
|
Add three persisted columns to `AccountChannelMap` and `TopicProgress`, plus one runtime `getChat`/`getForumTopicInfo` lookup before each scan. The new state survives restarts because it's in PostgreSQL; the lookup is a cheap TDLib local-cache call. Failure-retry semantics (`d99a506` + `901f32f`) must be preserved — a channel sitting on retryable `SkippedPackage` rows is never considered idle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem statement
|
||||||
|
|
||||||
|
### Today's behavior
|
||||||
|
|
||||||
|
Every ingestion cycle the worker walks every linked source channel for every authenticated account. For each channel/topic it calls TDLib's `searchChatMessages` paginated from `lastProcessedMessageId`. Even when nothing has changed since the previous scan:
|
||||||
|
|
||||||
|
- One `searchChatMessages` call (sometimes paginated) is still made
|
||||||
|
- For Model Printing Emporium, that's ~1,086 calls per cycle (one per forum topic)
|
||||||
|
- The 1-second `apiDelayMs` between pages multiplies the cost
|
||||||
|
- Most calls return zero new messages — the work is wasted
|
||||||
|
|
||||||
|
The cost is most acute right after a restart: the worker boots, runs recovery, then issues 1,000+ effectively-empty calls before any productive work happens.
|
||||||
|
|
||||||
|
### What we already track
|
||||||
|
|
||||||
|
- `AccountChannelMap.lastProcessedMessageId` — highest processed message ID (per non-forum channel, per account)
|
||||||
|
- `TopicProgress.lastProcessedMessageId` — same per forum topic
|
||||||
|
- Both are advanced incrementally per archive set (`77aeb4c`)
|
||||||
|
- Both are pulled back below failed messages by the `SkippedPackage` retry pass (`901f32f`)
|
||||||
|
|
||||||
|
### What we don't track and want to add
|
||||||
|
|
||||||
|
- When was the last scan?
|
||||||
|
- Did the last scan find any archives, OR is there outstanding retry work?
|
||||||
|
- How many cycles in a row have been totally idle?
|
||||||
|
|
||||||
|
These let us skip the scan entirely when nothing has changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-level approach
|
||||||
|
|
||||||
|
Three guards at the top of the per-channel and per-topic processing loops:
|
||||||
|
|
||||||
|
1. **DB-persistent "skip if recently scanned and truly idle"** — checks `lastScannedAt`, `lastScanFoundArchives`, and a `retryableSkippedCount` query. If all three say "nothing new, nothing failing", skip without any TDLib call.
|
||||||
|
|
||||||
|
2. **Adaptive backoff for cold channels** — `consecutiveEmptyScans` counter. After it crosses a threshold, scan only every Nth cycle. Reset to 0 whenever the channel is "not idle".
|
||||||
|
|
||||||
|
3. **`chat.last_message.id` short-circuit** — if (1) and (2) don't skip but the channel's last server-side message ID matches our watermark, skip the `searchChatMessages` paginated call. This runs after the existing `SkippedPackage` retry pass, which pulls the watermark back below failures, so it correctly forces a scan when retries are pending.
|
||||||
|
|
||||||
|
The retry pass from `901f32f` is preserved untouched — it runs in front of these guards and adjusts the watermark, so retries always happen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema changes
|
||||||
|
|
||||||
|
### `AccountChannelMap` (worker/src/db/schema.prisma)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model AccountChannelMap {
|
||||||
|
// ... existing fields ...
|
||||||
|
lastScannedAt DateTime?
|
||||||
|
lastScanFoundArchives Boolean @default(false)
|
||||||
|
consecutiveEmptyScans Int @default(0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `TopicProgress`
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model TopicProgress {
|
||||||
|
// ... existing fields ...
|
||||||
|
lastScannedAt DateTime?
|
||||||
|
lastScanFoundArchives Boolean @default(false)
|
||||||
|
consecutiveEmptyScans Int @default(0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Both tables get the same three columns. Existing rows get defaults:
|
||||||
|
-- lastScannedAt = NULL (next scan will populate)
|
||||||
|
-- lastScanFoundArchives = false (safe default — will be overwritten by next scan)
|
||||||
|
-- consecutiveEmptyScans = 0 (resets backoff for existing channels)
|
||||||
|
|
||||||
|
ALTER TABLE "account_channel_map"
|
||||||
|
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE "topic_progress"
|
||||||
|
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
NULL `lastScannedAt` means "never scanned" — every channel will be scanned the first cycle after deploy. Subsequent cycles benefit from the new fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Two new env vars in `worker/src/util/config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Window in which a recent successful empty scan lets us skip. Default 5 min. */
|
||||||
|
skipRecentScanWindowMs:
|
||||||
|
parseInt(process.env.WORKER_SKIP_RECENT_SCAN_WINDOW_MS ?? "300000", 10),
|
||||||
|
|
||||||
|
/** After this many consecutive empty scans, channel enters backoff mode. */
|
||||||
|
emptyScanBackoffThreshold:
|
||||||
|
parseInt(process.env.WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD ?? "5", 10),
|
||||||
|
|
||||||
|
/** Backoff factor — N means "scan every Nth cycle once in backoff". */
|
||||||
|
emptyScanBackoffEveryNth:
|
||||||
|
parseInt(process.env.WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH ?? "5", 10),
|
||||||
|
```
|
||||||
|
|
||||||
|
All three are tunable per deployment without code changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision logic per channel / topic
|
||||||
|
|
||||||
|
The skip decision sits at the top of each channel/topic iteration in `runWorkerForAccount`. It runs BEFORE the existing `SkippedPackage` retry pass.
|
||||||
|
|
||||||
|
```text
|
||||||
|
For each channel (or topic):
|
||||||
|
|
||||||
|
1. Query retryableSkippedCount for this scope (already a query we do elsewhere)
|
||||||
|
|
||||||
|
2. If retryableSkippedCount > 0:
|
||||||
|
Force scan (don't skip — failures need retry)
|
||||||
|
Proceed to existing flow (retry pass → scan)
|
||||||
|
|
||||||
|
3. Else if lastScannedAt is NULL:
|
||||||
|
Force scan (we've never touched this)
|
||||||
|
Proceed to existing flow
|
||||||
|
|
||||||
|
4. Else if Date.now() - lastScannedAt.getTime() < skipRecentScanWindowMs
|
||||||
|
AND lastScanFoundArchives === false:
|
||||||
|
Skip — recently scanned and truly idle
|
||||||
|
|
||||||
|
5. Else if consecutiveEmptyScans >= emptyScanBackoffThreshold
|
||||||
|
AND (cycleCount % emptyScanBackoffEveryNth !== 0):
|
||||||
|
Skip — channel is cold, not its turn to scan
|
||||||
|
|
||||||
|
6. Else:
|
||||||
|
Run the existing flow:
|
||||||
|
a. SkippedPackage retry pass (901f32f) — may pull watermark back
|
||||||
|
b. NEW: getChat (or getForumTopicInfo) — if last_message.id <= watermark, skip
|
||||||
|
c. searchChatMessages scan
|
||||||
|
```
|
||||||
|
|
||||||
|
`cycleCount` is the global ingestion-cycle counter from `scheduler.ts`. It already increments per cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## End-of-scan bookkeeping
|
||||||
|
|
||||||
|
After every scan (whether it found archives or not), update the three new fields atomically with the existing watermark write:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// "Truly idle" means: nothing new this scan AND nothing failed AND no leftover
|
||||||
|
// retryable failures. The retry-pending check is critical — without it, a
|
||||||
|
// scan that found no new archives but left SkippedPackage retries pending
|
||||||
|
// would be marked idle and incorrectly skipped next cycle.
|
||||||
|
const retryablePending = await getRetryableSkippedMessageIds({
|
||||||
|
accountId, sourceChannelId, topicId, cap: maxSkipAttempts,
|
||||||
|
});
|
||||||
|
const trulyIdle =
|
||||||
|
scanResult.archives.length === 0
|
||||||
|
&& minFailedId === null
|
||||||
|
&& retryablePending.length === 0;
|
||||||
|
|
||||||
|
const newConsecutiveEmpty = trulyIdle
|
||||||
|
? (prev.consecutiveEmptyScans ?? 0) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await upsertChannelOrTopicScanState({
|
||||||
|
// ... existing watermark fields ...
|
||||||
|
lastScannedAt: new Date(),
|
||||||
|
lastScanFoundArchives: !trulyIdle,
|
||||||
|
consecutiveEmptyScans: newConsecutiveEmpty,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The `consecutiveEmptyScans` counter resets to 0 the moment *anything* happens — archives found, archives failed, or unresolved retries pending. A channel with a chronically-failing archive (whose attemptCount is still below the cap) keeps the counter at 0 and never enters backoff.
|
||||||
|
|
||||||
|
If a SkippedPackage hits `attemptCount === maxSkipAttempts`, it's no longer "retryable pending" (it's been given up on), so the counter increments correctly. Same for SkippedPackages that get deleted via the UI's "retry" button — the counter behaves correctly without special-casing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `getChat` / `getForumTopicInfo` short-circuit
|
||||||
|
|
||||||
|
After the retry pass has finalized the effective watermark, but before `searchChatMessages`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// For non-forum channels:
|
||||||
|
const chat = await client.invoke({ _: "getChat", chat_id: Number(channel.telegramId) });
|
||||||
|
const channelLastMessageId = chat.last_message?.id;
|
||||||
|
|
||||||
|
if (channelLastMessageId && BigInt(channelLastMessageId) <= effectiveWatermark) {
|
||||||
|
// Nothing new server-side — skip the paginated search entirely.
|
||||||
|
// Still update lastScannedAt / consecutiveEmptyScans so the recent-scan
|
||||||
|
// skip kicks in next cycle.
|
||||||
|
await persistScanState({ trulyIdle: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For forum topics:
|
||||||
|
const topicInfo = await client.invoke({
|
||||||
|
_: "getForumTopicInfo",
|
||||||
|
chat_id: Number(channel.telegramId),
|
||||||
|
message_thread_id: Number(topic.topicId),
|
||||||
|
});
|
||||||
|
const topicLastMessageId = topicInfo.info?.last_message_id;
|
||||||
|
|
||||||
|
if (topicLastMessageId && BigInt(topicLastMessageId) <= effectiveWatermark) {
|
||||||
|
await persistScanState({ trulyIdle: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`getChat` is served from TDLib's local cache (no network) for chats we've already loaded, which we do up front via `loadChats`. `getForumTopicInfo` is a single round-trip but much cheaper than a paginated `searchChatMessages` call.
|
||||||
|
|
||||||
|
The comparison is `<=` because the watermark is the highest message we've fully processed — if the server's last is the same, we're caught up.
|
||||||
|
|
||||||
|
This step is correct in the failure-retry case because the retry pass runs FIRST: if there were retryable failures, the retry pass pulled the watermark back below them, and `channelLastMessageId > effectiveWatermark` (since the failed message exists in TG), so we don't skip — we scan and re-pick-up the failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restart behavior
|
||||||
|
|
||||||
|
The improvements compose for restart safety:
|
||||||
|
|
||||||
|
| Scenario | Today | After this change |
|
||||||
|
|---|---|---|
|
||||||
|
| Restart 5 min after a clean cycle | ~2,000 API calls for MPE | ~10 calls (only retryable + truly-active topics) |
|
||||||
|
| Restart 1 hour later (one missed cycle) | ~2,000 API calls | `getChat` per channel + scan only those where `last_message.id > watermark` (≈ 50 for MPE) |
|
||||||
|
| Restart after long downtime (12h) | ~2,000 calls + lots of new content | `getChat` per channel, scan everything with new activity |
|
||||||
|
|
||||||
|
The three new columns are in PostgreSQL — they survive container restarts directly. `consecutiveEmptyScans = 47` for a cold topic stays at 47 across restart, so backoff continues to apply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge cases and their handling
|
||||||
|
|
||||||
|
### 1. Manual SkippedPackage retry via UI between cycles
|
||||||
|
The UI's `retrySkippedPackageAction` lowers the watermark and deletes the SkippedPackage. Next cycle: `retryableSkippedCount === 0` (the row is gone), but the watermark is lower than `chat.last_message.id` (the retried message exists in TG). So step 6 in the decision tree triggers a scan via the `getChat` check. ✓
|
||||||
|
|
||||||
|
### 2. SkippedPackage hits the attempt cap mid-cycle
|
||||||
|
Once `attemptCount === maxSkipAttempts`, the row is no longer in `getRetryableSkippedMessageIds` results. The channel correctly becomes idle-eligible. The capped SkippedPackage stays in the table as "permanently failed (manual retry only)" — that's the existing behavior. ✓
|
||||||
|
|
||||||
|
### 3. New SkippedPackage is created mid-cycle (e.g., an upload fails)
|
||||||
|
At the end of that scan, `retryablePending` includes the new row → `trulyIdle = false` → `lastScanFoundArchives = true` → next cycle does NOT skip. ✓
|
||||||
|
|
||||||
|
### 4. Channel/topic added after deploy
|
||||||
|
New rows in `AccountChannelMap` / `TopicProgress` have `lastScannedAt = NULL`, so step 3 in the decision tree always triggers a scan. After the first scan, the fields are populated normally. ✓
|
||||||
|
|
||||||
|
### 5. Clock skew / drift
|
||||||
|
The `lastScannedAt < 5 min ago` check uses `Date.now() - lastScannedAt.getTime()`. Both are application-side clocks (Node.js + PostgreSQL `NOW()` at write). A few seconds of drift doesn't matter; an hour of clock jump (rare but possible) just means one cycle either skips or re-scans — recoverable.
|
||||||
|
|
||||||
|
### 6. TDLib `getChat` returns stale data
|
||||||
|
TDLib's local cache could theoretically be stale (e.g., the account hasn't received the latest update yet). If `channelLastMessageId` is stale (lower than server reality), we'd skip a scan that should have happened. Mitigation: the next cycle's `getChat` likely has fresh data; the watermark guards correctness (we don't lose data, we just process it one cycle later). Acceptable.
|
||||||
|
|
||||||
|
### 7. `getForumTopicInfo` rate limit
|
||||||
|
Calling it per-topic could add up for channels with 1000+ topics. Mitigation: skip-on-recent-scan (step 4) eliminates the call for most topics; only "stale-but-was-active" topics get the call. Worst case is ~50 calls per cycle for MPE, comfortably under the 30 req/sec global limit.
|
||||||
|
|
||||||
|
### 8. Channel becomes a forum (or vice versa) between cycles
|
||||||
|
Existing code handles this — `isChatForum` is rechecked each cycle and `setChannelForum` updates the DB. The new fields live on the same rows, so no extra handling needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File-level changes
|
||||||
|
|
||||||
|
### New / modified
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` — add the six new columns
|
||||||
|
- `prisma/migrations/<timestamp>_channel_scan_state/migration.sql` — the ALTER TABLE
|
||||||
|
- `worker/src/util/config.ts` — three new env vars
|
||||||
|
- `worker/src/db/queries.ts` — new helpers:
|
||||||
|
- `getChannelScanState(mappingId)` and `getTopicScanState(topicProgressId)`
|
||||||
|
- `upsertChannelScanState(...)` and `upsertTopicScanState(...)`
|
||||||
|
- Both wrap the existing `updateLastProcessedMessage` / `upsertTopicProgress` so callers don't need to remember to update the new fields too.
|
||||||
|
- `worker/src/worker.ts` — top-of-loop skip checks in both the forum and non-forum branches, plus end-of-scan state writes
|
||||||
|
- `worker/src/tdlib/chats.ts` — small helper `getChatLastMessageId(client, chatId)` and `getForumTopicLastMessageId(client, chatId, topicId)` wrapping the TDLib calls with the existing `invokeWithTimeout` pattern
|
||||||
|
|
||||||
|
### Untouched
|
||||||
|
|
||||||
|
- `recovery.ts` — recovery is per-startup and one-shot; not affected
|
||||||
|
- `scheduler.ts` — `cycleCount` is already there; just expose it where needed
|
||||||
|
- The existing `SkippedPackage` retry pass logic in `runWorkerForAccount` is unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing plan
|
||||||
|
|
||||||
|
The project has no automated tests, so verification is manual via Docker logs after deploy:
|
||||||
|
|
||||||
|
1. **Build cleanly:** `docker compose up -d --build worker` — no migration errors
|
||||||
|
2. **First cycle after deploy:** all channels scan (NULL `lastScannedAt`), all fields populated at end of cycle. Log lines confirm normal scan flow.
|
||||||
|
3. **Second cycle 5 min later:**
|
||||||
|
- Check logs for `"Skipping recently-scanned idle channel"` — should appear for any channel/topic that was empty last cycle
|
||||||
|
- Total `searchChatMessages` calls per cycle should drop dramatically (compare to first cycle)
|
||||||
|
4. **Failure-retry preservation:**
|
||||||
|
- Find a SkippedPackage with `attemptCount < cap`
|
||||||
|
- Run a cycle — confirm the channel/topic is NOT skipped (log says it's scanned)
|
||||||
|
- Confirm the SkippedPackage gets re-tried
|
||||||
|
5. **Backoff:**
|
||||||
|
- Pick a cold channel, wait for it to scan 5+ cycles cleanly
|
||||||
|
- Confirm `consecutiveEmptyScans` climbs to 5+
|
||||||
|
- Confirm subsequent cycles skip it (only scan every 5th)
|
||||||
|
6. **`getChat` short-circuit:**
|
||||||
|
- Pick an active channel
|
||||||
|
- Trigger an immediate cycle (UI button)
|
||||||
|
- If `last_message.id <= watermark`, expect log `"Channel caught up via getChat — skipping searchChatMessages"`
|
||||||
|
7. **Restart safety:**
|
||||||
|
- Push the change, restart worker
|
||||||
|
- First cycle after restart should log multiple "Skipping recently-scanned idle channel" lines (because the DB state survived)
|
||||||
|
- Total cycle time should be a fraction of a baseline restart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Skip incorrectly applied → real failures never retried | Rule 1 (truly-idle includes `retryablePending === 0`) + dedicated test step 4 |
|
||||||
|
| `getChat` returns stale data | Next cycle's `getChat` corrects it; watermark guards correctness (no data loss) |
|
||||||
|
| `getForumTopicInfo` not available in TDLib 1.8.64 | Verify the method exists in the schema; fall back to scan if it throws |
|
||||||
|
| Backoff applies during legitimate activity bursts | Counter resets to 0 the moment any archive is found OR any retry is pending |
|
||||||
|
| Migration takes too long on the live DB | Both columns have NOT NULL defaults — Postgres can add them as fast metadata changes (no table rewrite) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's explicitly NOT in this design
|
||||||
|
|
||||||
|
To keep scope tight:
|
||||||
|
- **Event-driven ingestion via `updateNewMessage`.** Bigger design, addressed separately. This design is compatible with it — when (D) lands, polling becomes a 4-hour safety net using these same skip rules.
|
||||||
|
- **Per-channel scan history UI.** Observability layer; separate design.
|
||||||
|
- **Surfacing the new counters in the admin dashboard.** Can come after the worker-side change is verified.
|
||||||
|
- **Backfilling `consecutiveEmptyScans` from historical `IngestionRun` data.** Not worth it — it'll converge to the correct value within ~6 cycles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None — the failure-retry interaction was the main risk and is handled by Rule 1 + the existing retry pass.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Send All From Creator — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-04
|
||||||
|
**Status:** Approved for planning
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a "Send all from [Creator]" button to the STL packages page that queues every
|
||||||
|
sendable package from a given creator for delivery to the user's Telegram. The
|
||||||
|
button appears in the packages-tab toolbar only when the list is filtered by a
|
||||||
|
single creator (`?creator=<name>`).
|
||||||
|
|
||||||
|
This reuses the existing single-send infrastructure (`BotSendRequest` +
|
||||||
|
`pg_notify('bot_send', id)` + the bot's `send-listener`) and closely mirrors the
|
||||||
|
existing `sendAllInGroupAction`. No bot, DB schema, or send-listener changes are
|
||||||
|
required.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Relevant existing pieces:
|
||||||
|
|
||||||
|
- **Creator field:** `Package.creator` is a nullable, indexed `String` on the
|
||||||
|
`Package` model (`prisma/schema.prisma:476-524`). Filtering by exact creator is
|
||||||
|
cheap.
|
||||||
|
- **Creator filter:** The packages list already supports `?creator=<name>`
|
||||||
|
(`src/app/(app)/stls/page.tsx:22,38`), rendered by the packages column as a
|
||||||
|
clickable value (`package-columns.tsx`).
|
||||||
|
- **Single send:** `send-to-telegram-button.tsx` → `POST /api/telegram/bot/send`
|
||||||
|
creates a `BotSendRequest` (status `PENDING`) and fires
|
||||||
|
`pg_notify('bot_send', requestId)`; the button then polls the request to
|
||||||
|
completion.
|
||||||
|
- **Existing bulk send (group):** `sendAllInGroupAction(groupId)` in
|
||||||
|
`src/app/(app)/stls/actions.ts:515-591` fetches the group's packages, filters to
|
||||||
|
those with `destChannelId` + `destMessageId`, skips any with an existing
|
||||||
|
`PENDING`/`SENDING` request for the same package + telegram link, creates a
|
||||||
|
`BotSendRequest` per remaining package, and fires `pg_notify` per package. It is
|
||||||
|
wired into the table via `handleSendAllInGroup` (`stl-table.tsx:264-278`) with a
|
||||||
|
`confirm()` dialog and a success toast — no polling.
|
||||||
|
- **Bot side:** `bot/src/send-listener.ts` processes every `BotSendRequest`
|
||||||
|
uniformly; nothing there needs to change.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. When the packages tab is filtered by a single creator (`?creator=<name>` is
|
||||||
|
present), show a toolbar button labeled **"Send all from [Creator]"**.
|
||||||
|
2. When no creator filter is active, the button is not rendered.
|
||||||
|
3. Clicking the button shows a confirmation dialog before sending (consistent with
|
||||||
|
the single-send and group-send flows, since this pushes files to Telegram).
|
||||||
|
4. On confirm, queue **all** sendable packages from that creator across all pages —
|
||||||
|
not just the current page.
|
||||||
|
5. Skip packages that are not yet uploaded (missing `destChannelId` /
|
||||||
|
`destMessageId`) and packages that already have a `PENDING`/`SENDING` request for
|
||||||
|
the same package + telegram link (dedup).
|
||||||
|
6. Report real counts back to the user via toast, e.g.
|
||||||
|
"Queued 12 packages from [Creator]" and, when applicable, a skipped count.
|
||||||
|
7. No polling — the bulk action queues and returns; sends process in the background
|
||||||
|
via the bot listener (same behavior as group send).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Server action — `sendAllFromCreatorAction(creatorName: string)`
|
||||||
|
|
||||||
|
New action in `src/app/(app)/stls/actions.ts`, mirroring `sendAllInGroupAction`:
|
||||||
|
|
||||||
|
1. Auth check (`auth()`), return `Unauthorized` if no session.
|
||||||
|
2. Load the user's `TelegramLink`; if none, return
|
||||||
|
"No linked Telegram account. Link one in Settings."
|
||||||
|
3. Reject an empty/blank `creatorName` (guard against sending the entire "no
|
||||||
|
creator" set — see Edge cases).
|
||||||
|
4. Fetch sendable packages directly with a `where` filter (no in-memory filtering):
|
||||||
|
```ts
|
||||||
|
prisma.package.findMany({
|
||||||
|
where: {
|
||||||
|
creator: creatorName,
|
||||||
|
destChannelId: { not: null },
|
||||||
|
destMessageId: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
5. If none, return "No uploaded packages found for this creator."
|
||||||
|
6. For each package, skip if an existing `PENDING`/`SENDING` `BotSendRequest`
|
||||||
|
exists for that package + telegram link; otherwise create a `BotSendRequest`
|
||||||
|
(status `PENDING`) and fire `pg_notify('bot_send', id)` (best-effort, wrapped in
|
||||||
|
try/catch — the bot also polls).
|
||||||
|
7. `revalidatePath("/stls")`.
|
||||||
|
8. Return counts so the UI can show them. This is a small improvement over
|
||||||
|
`sendAllInGroupAction`, which currently returns `{ success: true, data: undefined }`.
|
||||||
|
Shape:
|
||||||
|
```ts
|
||||||
|
{ success: true, data: { queued: number, skipped: number } }
|
||||||
|
```
|
||||||
|
where `skipped` counts packages that already had a live request. (Packages not
|
||||||
|
yet uploaded are excluded by the query and not counted as skipped.)
|
||||||
|
|
||||||
|
### UI — toolbar button in `stl-table.tsx`
|
||||||
|
|
||||||
|
- Derive the active creator from the URL: `const activeCreator =
|
||||||
|
searchParams.get("creator") ?? "";` (parallel to the existing
|
||||||
|
`activeTag` at `stl-table.tsx:445`).
|
||||||
|
- In the packages-tab toolbar (`stl-table.tsx:478` `flex flex-wrap items-center
|
||||||
|
gap-2` row), render the button only when `activeCreator` is non-empty:
|
||||||
|
```tsx
|
||||||
|
{activeCreator && (
|
||||||
|
<Button variant="outline" size="sm" className="h-9 gap-1.5"
|
||||||
|
onClick={handleSendAllFromCreator}>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
Send all from {activeCreator}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
(Reuse the icon already used by the send action; place near the Upload / Group
|
||||||
|
buttons.)
|
||||||
|
- Add `handleSendAllFromCreator`, modeled on `handleSendAllInGroup`
|
||||||
|
(`stl-table.tsx:264-278`):
|
||||||
|
```ts
|
||||||
|
const handleSendAllFromCreator = useCallback(() => {
|
||||||
|
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await sendAllFromCreatorAction(activeCreator);
|
||||||
|
if (result.success) {
|
||||||
|
const { queued, skipped } = result.data;
|
||||||
|
toast.success(
|
||||||
|
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
|
||||||
|
(skipped ? ` (${skipped} already queued)` : "")
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [activeCreator, router]);
|
||||||
|
```
|
||||||
|
- Import `sendAllFromCreatorAction` alongside the existing `sendAllInGroupAction`
|
||||||
|
import (`stl-table.tsx:51`).
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User (filtered by creator) → clicks button → confirm dialog
|
||||||
|
→ sendAllFromCreatorAction(creatorName)
|
||||||
|
→ for each sendable, un-queued package: create BotSendRequest + pg_notify
|
||||||
|
→ returns { queued, skipped } → toast + router.refresh()
|
||||||
|
|
||||||
|
(background) bot send-listener consumes each BotSendRequest → delivers to user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Blank creator:** The action rejects an empty/whitespace `creatorName` so a
|
||||||
|
stray `?creator=` never queues every package. The UI already gates the button on
|
||||||
|
a non-empty `activeCreator`, so this is defense-in-depth.
|
||||||
|
- **No linked Telegram account:** Returns an error toast, queues nothing.
|
||||||
|
- **No uploaded packages for creator:** Returns an error toast, queues nothing.
|
||||||
|
- **All packages already queued:** `queued: 0`, `skipped: N` → toast reflects it.
|
||||||
|
- **pg_notify failure:** Swallowed per-package (best-effort); the bot's periodic
|
||||||
|
poll picks the request up. Matches existing behavior.
|
||||||
|
|
||||||
|
## Out of scope / non-goals
|
||||||
|
|
||||||
|
- No per-row "send all from this creator" action (toolbar-only, per decision).
|
||||||
|
- No progress polling / live completion tracking for the bulk send.
|
||||||
|
- No changes to the bot, `send-listener`, `BotSendRequest` schema, or API routes.
|
||||||
|
- No batching/throttling beyond what the bot listener already does.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `src/app/(app)/stls/actions.ts` — add `sendAllFromCreatorAction`.
|
||||||
|
- `src/app/(app)/stls/_components/stl-table.tsx` — derive `activeCreator`, add
|
||||||
|
`handleSendAllFromCreator`, render the toolbar button, import the new action.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable: track how many times the worker has tried each skipped source message.
|
||||||
|
-- Existing rows default to 1 (they represent a single past attempt that the worker
|
||||||
|
-- chose to record). Future failures increment via upsertSkippedPackage.
|
||||||
|
ALTER TABLE "skipped_packages" ADD COLUMN "attemptCount" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- AlterEnum: add CHANNEL_ACCESS_LOST so the worker can surface a notification
|
||||||
|
-- when a source channel becomes inaccessible (account removed, channel deleted, etc.)
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'CHANNEL_ACCESS_LOST';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- AlterTable: capture TDLib's stable per-content identifier for new packages.
|
||||||
|
-- Existing rows are NULL; they fall through to the other dedup checks until
|
||||||
|
-- they're re-encountered organically.
|
||||||
|
ALTER TABLE "packages" ADD COLUMN "remoteUniqueId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex: scoped to source channel because we want to dedup
|
||||||
|
-- per-channel (the same file appearing in two different channels is still
|
||||||
|
-- worth indexing twice — they're different ingestion sources).
|
||||||
|
CREATE INDEX "packages_sourceChannelId_remoteUniqueId_idx"
|
||||||
|
ON "packages"("sourceChannelId", "remoteUniqueId");
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterTable: per-channel scan-state columns
|
||||||
|
ALTER TABLE "account_channel_map"
|
||||||
|
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- AlterTable: per-topic scan-state columns (forum channels)
|
||||||
|
ALTER TABLE "topic_progress"
|
||||||
|
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable: per-topic user-controlled fetch toggle (forum channels)
|
||||||
|
-- Additive, safe default (true) so existing rows backfill to "enabled" and
|
||||||
|
-- current behaviour is unchanged. Disabling is non-destructive.
|
||||||
|
ALTER TABLE "topic_progress"
|
||||||
|
ADD COLUMN "fetchEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable: track the forum topic currently being processed on the live run
|
||||||
|
-- so the worker status panel can offer a "skip & disable topic" action.
|
||||||
|
-- Additive, nullable — no data change for existing rows.
|
||||||
|
ALTER TABLE "ingestion_runs"
|
||||||
|
ADD COLUMN "currentTopicId" BIGINT,
|
||||||
|
ADD COLUMN "currentAccountChannelMapId" TEXT;
|
||||||
@@ -450,6 +450,17 @@ model AccountChannelMap {
|
|||||||
channelId String
|
channelId String
|
||||||
role ChannelRole @default(READER)
|
role ChannelRole @default(READER)
|
||||||
lastProcessedMessageId BigInt?
|
lastProcessedMessageId BigInt?
|
||||||
|
/// When this channel was last scanned (any reason, including skipped scans
|
||||||
|
/// that bumped the timestamp). Used by the recency-skip guard.
|
||||||
|
lastScannedAt DateTime?
|
||||||
|
/// True if the last scan found archives OR left retryable SkippedPackages
|
||||||
|
/// pending. Tracks "this channel has work I might need to revisit" — not
|
||||||
|
/// just "I uploaded something this cycle".
|
||||||
|
lastScanFoundArchives Boolean @default(false)
|
||||||
|
/// Number of consecutive cycles where this channel was trulyIdle (no
|
||||||
|
/// archives, no failures, no retryables). Drives the backoff that lets
|
||||||
|
/// cold channels skip cycles entirely.
|
||||||
|
consecutiveEmptyScans Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
@@ -472,6 +483,11 @@ model Package {
|
|||||||
sourceChannelId String
|
sourceChannelId String
|
||||||
sourceMessageId BigInt
|
sourceMessageId BigInt
|
||||||
sourceTopicId BigInt?
|
sourceTopicId BigInt?
|
||||||
|
/// TDLib's `remote.unique_id` for the FIRST part's file. Stable across
|
||||||
|
/// reposts of identical content in the same channel — used as the
|
||||||
|
/// strongest pre-download dedup signal (no false positives unlike
|
||||||
|
/// fileName + size matching).
|
||||||
|
remoteUniqueId String?
|
||||||
destChannelId String?
|
destChannelId String?
|
||||||
destMessageId BigInt?
|
destMessageId BigInt?
|
||||||
destMessageIds BigInt[] @default([])
|
destMessageIds BigInt[] @default([])
|
||||||
@@ -503,6 +519,7 @@ model Package {
|
|||||||
@@index([archiveType])
|
@@index([archiveType])
|
||||||
@@index([creator])
|
@@index([creator])
|
||||||
@@index([packageGroupId])
|
@@index([packageGroupId])
|
||||||
|
@@index([sourceChannelId, remoteUniqueId])
|
||||||
@@map("packages")
|
@@map("packages")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +582,8 @@ model IngestionRun {
|
|||||||
totalBytes BigInt? // Total size of current download
|
totalBytes BigInt? // Total size of current download
|
||||||
downloadPercent Int? // 0-100
|
downloadPercent Int? // 0-100
|
||||||
lastActivityAt DateTime? // When activity was last updated
|
lastActivityAt DateTime? // When activity was last updated
|
||||||
|
currentTopicId BigInt? // Forum topic currently being processed (live "skip topic")
|
||||||
|
currentAccountChannelMapId String? // AccountChannelMap owning the topic being processed
|
||||||
|
|
||||||
account TelegramAccount @relation(fields: [accountId], references: [id])
|
account TelegramAccount @relation(fields: [accountId], references: [id])
|
||||||
packages Package[]
|
packages Package[]
|
||||||
@@ -580,7 +599,20 @@ model TopicProgress {
|
|||||||
accountChannelMapId String
|
accountChannelMapId String
|
||||||
topicId BigInt
|
topicId BigInt
|
||||||
topicName String?
|
topicName String?
|
||||||
|
/// User-controlled fetch toggle. When false, the worker skips this topic
|
||||||
|
/// (no scanning, no fetching/transfer). Defaults true so every existing and
|
||||||
|
/// newly-discovered topic is fetched unless explicitly disabled. Disabling
|
||||||
|
/// is non-destructive — already-fetched packages are kept.
|
||||||
|
fetchEnabled Boolean @default(true)
|
||||||
lastProcessedMessageId BigInt?
|
lastProcessedMessageId BigInt?
|
||||||
|
/// When this topic was last scanned (any reason). Used by recency-skip.
|
||||||
|
lastScannedAt DateTime?
|
||||||
|
/// True if the last scan found archives OR has retryable SkippedPackages
|
||||||
|
/// pending for this topic. See AccountChannelMap doc for details.
|
||||||
|
lastScanFoundArchives Boolean @default(false)
|
||||||
|
/// Number of consecutive cycles where this topic was trulyIdle. Drives
|
||||||
|
/// backoff for cold topics.
|
||||||
|
consecutiveEmptyScans Int @default(0)
|
||||||
|
|
||||||
accountChannelMap AccountChannelMap @relation(fields: [accountChannelMapId], references: [id], onDelete: Cascade)
|
accountChannelMap AccountChannelMap @relation(fields: [accountChannelMapId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -741,6 +773,12 @@ model SkippedPackage {
|
|||||||
sourceTopicId BigInt?
|
sourceTopicId BigInt?
|
||||||
isMultipart Boolean @default(false)
|
isMultipart Boolean @default(false)
|
||||||
partCount Int @default(1)
|
partCount Int @default(1)
|
||||||
|
/// How many times the worker has tried to process this source message.
|
||||||
|
/// The worker auto-retries failures across cycles up to a configurable cap
|
||||||
|
/// (WORKER_MAX_SKIP_ATTEMPTS, default 5). After the cap, the watermark is
|
||||||
|
/// allowed to advance past the failure so cycles aren't pinned forever;
|
||||||
|
/// the user can manually retry via the UI to reset and try again.
|
||||||
|
attemptCount Int @default(1)
|
||||||
accountId String
|
accountId String
|
||||||
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -830,6 +868,7 @@ enum NotificationType {
|
|||||||
DOWNLOAD_FAILED
|
DOWNLOAD_FAILED
|
||||||
GROUPING_CONFLICT
|
GROUPING_CONFLICT
|
||||||
INTEGRITY_AUDIT
|
INTEGRITY_AUDIT
|
||||||
|
CHANNEL_ACCESS_LOST
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationSeverity {
|
enum NotificationSeverity {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ImageOff,
|
ImageOff,
|
||||||
|
Maximize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { setPreviewFromExtract } from "../actions";
|
import { setPreviewFromExtract } from "../actions";
|
||||||
|
import { ImageLightbox } from "./image-lightbox";
|
||||||
|
|
||||||
interface ArchiveImage {
|
interface ArchiveImage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,6 +67,7 @@ export function ArchivePreviewPicker({
|
|||||||
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
|
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
||||||
// Track which paths have already been requested to avoid re-requesting
|
// Track which paths have already been requested to avoid re-requesting
|
||||||
const requestedPaths = useRef<Set<string>>(new Set());
|
const requestedPaths = useRef<Set<string>>(new Set());
|
||||||
@@ -290,12 +293,25 @@ export function ArchivePreviewPicker({
|
|||||||
const isFailed = thumbState?.status === "failed";
|
const isFailed = thumbState?.status === "failed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={img.id} className="group relative">
|
||||||
|
{isLoaded && thumbState?.imageUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-1.5 left-1.5 z-10 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setLightboxSrc(thumbState.imageUrl!);
|
||||||
|
}}
|
||||||
|
title="Enlarge"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
key={img.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all",
|
"relative aspect-square w-full rounded-lg overflow-hidden border-2 transition-all",
|
||||||
"hover:border-primary/50 cursor-pointer group",
|
"hover:border-primary/50 cursor-pointer",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-primary ring-2 ring-primary/30"
|
? "border-primary ring-2 ring-primary/30"
|
||||||
: "border-border",
|
: "border-border",
|
||||||
@@ -355,6 +371,7 @@ export function ArchivePreviewPicker({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -394,6 +411,13 @@ export function ArchivePreviewPicker({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<ImageLightbox
|
||||||
|
src={lightboxSrc}
|
||||||
|
open={!!lightboxSrc}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setLightboxSrc(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/app/(app)/stls/_components/creator-filter.tsx
Normal file
82
src/app/(app)/stls/_components/creator-filter.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
|
interface CreatorFilterProps {
|
||||||
|
creators: string[];
|
||||||
|
value: string; // active creator, "" when none
|
||||||
|
onChange: (creator: string) => void; // "" clears the filter
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-9 w-[200px] justify-between"
|
||||||
|
>
|
||||||
|
<span className="truncate">{value || "All Creators"}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search creators..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No creators found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="__all__"
|
||||||
|
onSelect={() => {
|
||||||
|
onChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
All Creators
|
||||||
|
</CommandItem>
|
||||||
|
{creators.map((creator) => (
|
||||||
|
<CommandItem
|
||||||
|
key={creator}
|
||||||
|
value={creator}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(creator);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === creator ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{creator}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/(app)/stls/_components/image-lightbox.tsx
Normal file
41
src/app/(app)/stls/_components/image-lightbox.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface ImageLightboxProps {
|
||||||
|
src: string | null;
|
||||||
|
alt?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-size, in-page preview viewer. Renders the image at native size capped
|
||||||
|
* to the viewport (object-contain). Dismiss via the close button, Esc, or by
|
||||||
|
* clicking the overlay.
|
||||||
|
*/
|
||||||
|
export function ImageLightbox({
|
||||||
|
src,
|
||||||
|
alt = "",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ImageLightboxProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-auto max-w-[95vw] border-0 bg-transparent p-0 shadow-none sm:max-w-[90vw]">
|
||||||
|
<DialogTitle className="sr-only">Enlarged preview image</DialogTitle>
|
||||||
|
{src && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="mx-auto max-h-[88vh] w-auto max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge } from "lucide-react";
|
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge, Maximize2 } from "lucide-react";
|
||||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { SendToTelegramButton } from "./send-to-telegram-button";
|
import { SendToTelegramButton } from "./send-to-telegram-button";
|
||||||
|
import { ImageLightbox } from "./image-lightbox";
|
||||||
|
|
||||||
export interface PackageRow {
|
export interface PackageRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,14 +86,30 @@ export function formatBytes(bytesStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
if (pkg.hasPreview) {
|
if (pkg.hasPreview) {
|
||||||
|
const src = `/api/zips/${pkg.id}/preview`;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group/preview relative h-9 w-9 overflow-hidden rounded-md bg-muted"
|
||||||
|
onClick={() => setLightboxOpen(true)}
|
||||||
|
title="Click to enlarge"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={`/api/zips/${pkg.id}/preview`}
|
src={src}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-9 w-9 rounded-md object-cover bg-muted"
|
className="h-9 w-9 object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-black/50 opacity-0 transition-opacity group-hover/preview:opacity-100">
|
||||||
|
<Maximize2 className="h-3.5 w-3.5 text-white" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<ImageLightbox src={src} open={lightboxOpen} onOpenChange={setLightboxOpen} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Images,
|
Images,
|
||||||
|
Maximize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,6 +31,7 @@ import type { PackageRow } from "./package-columns";
|
|||||||
import { SendToTelegramButton } from "./send-to-telegram-button";
|
import { SendToTelegramButton } from "./send-to-telegram-button";
|
||||||
import { uploadPackagePreview } from "../actions";
|
import { uploadPackagePreview } from "../actions";
|
||||||
import { ArchivePreviewPicker } from "./archive-preview-picker";
|
import { ArchivePreviewPicker } from "./archive-preview-picker";
|
||||||
|
import { ImageLightbox } from "./image-lightbox";
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -264,6 +266,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||||
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
|
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
|
||||||
|
const [previewLightboxOpen, setPreviewLightboxOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handlePreviewUpload = useCallback(
|
const handlePreviewUpload = useCallback(
|
||||||
@@ -384,9 +387,8 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
|
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => setPreviewLightboxOpen(true)}
|
||||||
disabled={uploading}
|
title="Click to enlarge"
|
||||||
title="Click to replace preview image"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
|
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
|
||||||
@@ -394,11 +396,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
{uploading ? (
|
<Maximize2 className="h-5 w-5 text-white" />
|
||||||
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="h-5 w-5 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -582,6 +580,11 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ImageLightbox
|
||||||
|
src={pkg ? (localPreviewUrl ?? `/api/zips/${pkg.id}/preview`) : null}
|
||||||
|
open={previewLightboxOpen}
|
||||||
|
onOpenChange={setPreviewLightboxOpen}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface SkippedRow {
|
|||||||
sourceChannel: { id: string; title: string };
|
sourceChannel: { id: string; title: string };
|
||||||
isMultipart: boolean;
|
isMultipart: boolean;
|
||||||
partCount: number;
|
partCount: number;
|
||||||
|
attemptCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +108,22 @@ export function getSkippedColumns({
|
|||||||
),
|
),
|
||||||
accessorFn: (row) => row.sourceChannel.title,
|
accessorFn: (row) => row.sourceChannel.title,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "attemptCount",
|
||||||
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Attempts" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const count = row.original.attemptCount;
|
||||||
|
const variant = count >= 5 ? "destructive" : count > 1 ? "secondary" : "outline";
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} className="text-[10px]">
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Skipped" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Last Skipped" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback, useTransition, useMemo, useRef } from "react";
|
import { useState, useCallback, useTransition, useMemo, useRef } from "react";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Search, Layers, Upload } from "lucide-react";
|
import { Search, Layers, Upload, Send } from "lucide-react";
|
||||||
import { UploadDialog } from "./upload-dialog";
|
import { UploadDialog } from "./upload-dialog";
|
||||||
import { useDataTable } from "@/hooks/use-data-table";
|
import { useDataTable } from "@/hooks/use-data-table";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { PackageFilesDrawer } from "./package-files-drawer";
|
import { PackageFilesDrawer } from "./package-files-drawer";
|
||||||
import { IngestionStatus } from "./ingestion-status";
|
import { IngestionStatus } from "./ingestion-status";
|
||||||
import { SkippedPackagesTab } from "./skipped-packages-tab";
|
import { SkippedPackagesTab } from "./skipped-packages-tab";
|
||||||
|
import { CreatorFilter } from "./creator-filter";
|
||||||
import { DataTable } from "@/components/shared/data-table";
|
import { DataTable } from "@/components/shared/data-table";
|
||||||
import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||||
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
createGroupAction,
|
createGroupAction,
|
||||||
removeFromGroupAction,
|
removeFromGroupAction,
|
||||||
sendAllInGroupAction,
|
sendAllInGroupAction,
|
||||||
|
sendAllFromCreatorAction,
|
||||||
updateGroupPreviewAction,
|
updateGroupPreviewAction,
|
||||||
mergeGroupsAction,
|
mergeGroupsAction,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
@@ -59,6 +61,7 @@ interface StlTableProps {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
ingestionStatus: IngestionAccountStatus[];
|
ingestionStatus: IngestionAccountStatus[];
|
||||||
availableTags: string[];
|
availableTags: string[];
|
||||||
|
availableCreators: string[];
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
skippedData: SkippedRow[];
|
skippedData: SkippedRow[];
|
||||||
skippedPageCount: number;
|
skippedPageCount: number;
|
||||||
@@ -74,6 +77,7 @@ export function StlTable({
|
|||||||
totalCount,
|
totalCount,
|
||||||
ingestionStatus,
|
ingestionStatus,
|
||||||
availableTags,
|
availableTags,
|
||||||
|
availableCreators,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
skippedData,
|
skippedData,
|
||||||
skippedPageCount,
|
skippedPageCount,
|
||||||
@@ -85,6 +89,7 @@ export function StlTable({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const activeCreator = searchParams.get("creator") ?? "";
|
||||||
|
|
||||||
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
|
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
|
||||||
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
|
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
|
||||||
@@ -207,6 +212,20 @@ export function StlTable({
|
|||||||
[router, pathname, searchParams]
|
[router, pathname, searchParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateCreatorFilter = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) {
|
||||||
|
params.set("creator", value);
|
||||||
|
params.set("page", "1");
|
||||||
|
} else {
|
||||||
|
params.delete("creator");
|
||||||
|
}
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
},
|
||||||
|
[router, pathname, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
const activeTab = searchParams.get("tab") ?? "packages";
|
const activeTab = searchParams.get("tab") ?? "packages";
|
||||||
|
|
||||||
const updateTab = useCallback(
|
const updateTab = useCallback(
|
||||||
@@ -277,6 +296,23 @@ export function StlTable({
|
|||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSendAllFromCreator = useCallback(() => {
|
||||||
|
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await sendAllFromCreatorAction(activeCreator);
|
||||||
|
if (result.success) {
|
||||||
|
const { queued, skipped } = result.data;
|
||||||
|
toast.success(
|
||||||
|
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
|
||||||
|
(skipped ? ` (${skipped} already queued)` : "")
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [activeCreator, router]);
|
||||||
|
|
||||||
const handleRemoveFromGroup = useCallback(
|
const handleRemoveFromGroup = useCallback(
|
||||||
(packageId: string) => {
|
(packageId: string) => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -500,11 +536,29 @@ export function StlTable({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
{availableCreators.length > 0 && (
|
||||||
|
<CreatorFilter
|
||||||
|
creators={availableCreators}
|
||||||
|
value={activeCreator}
|
||||||
|
onChange={updateCreatorFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<DataTableViewOptions table={table} />
|
<DataTableViewOptions table={table} />
|
||||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}>
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
Upload Files
|
Upload Files
|
||||||
</Button>
|
</Button>
|
||||||
|
{activeCreator && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 gap-1.5"
|
||||||
|
onClick={handleSendAllFromCreator}
|
||||||
|
>
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
Send all from {activeCreator}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{selectedPackages.size >= 2 && (
|
{selectedPackages.size >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -589,3 +589,82 @@ export async function sendAllInGroupAction(
|
|||||||
return { success: false, error: "Failed to send group packages" };
|
return { success: false, error: "Failed to send group packages" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendAllFromCreatorAction(
|
||||||
|
creatorName: string
|
||||||
|
): Promise<ActionResult<{ queued: number; skipped: number }>> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||||
|
|
||||||
|
const creator = creatorName.trim();
|
||||||
|
if (!creator) {
|
||||||
|
return { success: false, error: "No creator specified" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const telegramLink = await prisma.telegramLink.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!telegramLink) {
|
||||||
|
return { success: false, error: "No linked Telegram account. Link one in Settings." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendablePackages = await prisma.package.findMany({
|
||||||
|
where: {
|
||||||
|
creator,
|
||||||
|
destChannelId: { not: null },
|
||||||
|
destMessageId: { not: null },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sendablePackages.length === 0) {
|
||||||
|
return { success: false, error: "No uploaded packages found for this creator" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let queued = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const pkg of sendablePackages) {
|
||||||
|
// Only create if no existing PENDING/SENDING request for this package+link combo
|
||||||
|
const existing = await prisma.botSendRequest.findFirst({
|
||||||
|
where: {
|
||||||
|
packageId: pkg.id,
|
||||||
|
telegramLinkId: telegramLink.id,
|
||||||
|
status: { in: ["PENDING", "SENDING"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRequest = await prisma.botSendRequest.create({
|
||||||
|
data: {
|
||||||
|
packageId: pkg.id,
|
||||||
|
telegramLinkId: telegramLink.id,
|
||||||
|
requestedByUserId: session.user.id,
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the bot via pg_notify
|
||||||
|
try {
|
||||||
|
await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT pg_notify('bot_send', $1)`,
|
||||||
|
sendRequest.id
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort — the bot also polls periodically
|
||||||
|
}
|
||||||
|
|
||||||
|
queued++;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/stls");
|
||||||
|
return { success: true, data: { queued, skipped } };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to send creator packages" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
|
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, getAllPackageCreators, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
|
||||||
import { StlTable } from "./_components/stl-table";
|
import { StlTable } from "./_components/stl-table";
|
||||||
import type { DisplayItem, PackageListItem } from "@/lib/telegram/types";
|
import type { DisplayItem, PackageListItem } from "@/lib/telegram/types";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
|||||||
const tab = (params.tab as string) ?? "packages";
|
const tab = (params.tab as string) ?? "packages";
|
||||||
|
|
||||||
// Fetch packages, ingestion status, tags, and skipped count in parallel
|
// Fetch packages, ingestion status, tags, and skipped count in parallel
|
||||||
const [result, ingestionStatus, availableTags, skippedCount, ungroupedCount] = await Promise.all([
|
const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] = await Promise.all([
|
||||||
search
|
search
|
||||||
? searchPackages({
|
? searchPackages({
|
||||||
query: search,
|
query: search,
|
||||||
@@ -42,6 +42,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
|||||||
}),
|
}),
|
||||||
getIngestionStatus(),
|
getIngestionStatus(),
|
||||||
getAllPackageTags(),
|
getAllPackageTags(),
|
||||||
|
getAllPackageCreators(),
|
||||||
countSkippedPackages(),
|
countSkippedPackages(),
|
||||||
countUngroupedPackages(),
|
countUngroupedPackages(),
|
||||||
]);
|
]);
|
||||||
@@ -68,6 +69,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
|||||||
totalCount={result.pagination.total}
|
totalCount={result.pagination.total}
|
||||||
ingestionStatus={ingestionStatus}
|
ingestionStatus={ingestionStatus}
|
||||||
availableTags={availableTags}
|
availableTags={availableTags}
|
||||||
|
availableCreators={availableCreators}
|
||||||
searchTerm={search}
|
searchTerm={search}
|
||||||
skippedData={skippedResult?.items ?? []}
|
skippedData={skippedResult?.items ?? []}
|
||||||
skippedPageCount={skippedResult?.pagination.totalPages ?? 0}
|
skippedPageCount={skippedResult?.pagination.totalPages ?? 0}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Tag,
|
Tag,
|
||||||
|
MessagesSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -27,6 +28,7 @@ interface ChannelColumnsProps {
|
|||||||
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
||||||
onRescan: (id: string) => void;
|
onRescan: (id: string) => void;
|
||||||
onSetCategory: (id: string, category: string | null) => void;
|
onSetCategory: (id: string, category: string | null) => void;
|
||||||
|
onManageTopics: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannelColumns({
|
export function getChannelColumns({
|
||||||
@@ -35,6 +37,7 @@ export function getChannelColumns({
|
|||||||
onSetType,
|
onSetType,
|
||||||
onRescan,
|
onRescan,
|
||||||
onSetCategory,
|
onSetCategory,
|
||||||
|
onManageTopics,
|
||||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -147,6 +150,14 @@ export function getChannelColumns({
|
|||||||
Rescan Channel
|
Rescan Channel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{row.original.type === "SOURCE" && row.original.isForum && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onManageTopics(row.original.id)}
|
||||||
|
>
|
||||||
|
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Topics
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");
|
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getChannelColumns } from "./channel-columns";
|
|||||||
import { DestinationCard } from "./destination-card";
|
import { DestinationCard } from "./destination-card";
|
||||||
import { ChannelPickerDialog } from "./channel-picker-dialog";
|
import { ChannelPickerDialog } from "./channel-picker-dialog";
|
||||||
import { JoinChannelDialog } from "./join-channel-dialog";
|
import { JoinChannelDialog } from "./join-channel-dialog";
|
||||||
|
import { TopicsDrawer } from "./topics-drawer";
|
||||||
import {
|
import {
|
||||||
deleteChannel,
|
deleteChannel,
|
||||||
toggleChannelActive,
|
toggleChannelActive,
|
||||||
@@ -32,6 +33,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
|||||||
const [rescanId, setRescanId] = useState<string | null>(null);
|
const [rescanId, setRescanId] = useState<string | null>(null);
|
||||||
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
|
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
|
||||||
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
||||||
|
const [topicsChannelId, setTopicsChannelId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Find the first authenticated account for "Fetch Channels"
|
// Find the first authenticated account for "Fetch Channels"
|
||||||
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
|
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
|
||||||
@@ -60,6 +62,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
|||||||
else toast.error(result.error);
|
else toast.error(result.error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onManageTopics: (id) => setTopicsChannelId(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
@@ -167,6 +170,17 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
|||||||
open={joinDialogOpen}
|
open={joinDialogOpen}
|
||||||
onOpenChange={setJoinDialogOpen}
|
onOpenChange={setJoinDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TopicsDrawer
|
||||||
|
channelId={topicsChannelId}
|
||||||
|
channelTitle={
|
||||||
|
channels.find((c) => c.id === topicsChannelId)?.title
|
||||||
|
}
|
||||||
|
open={!!topicsChannelId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTopicsChannelId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
182
src/app/(app)/telegram/_components/topics-drawer.tsx
Normal file
182
src/app/(app)/telegram/_components/topics-drawer.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, Search } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { setTopicFetchEnabled } from "../actions";
|
||||||
|
|
||||||
|
interface TopicRow {
|
||||||
|
id: string;
|
||||||
|
topicId: string;
|
||||||
|
topicName: string | null;
|
||||||
|
fetchEnabled: boolean;
|
||||||
|
lastScannedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicsDrawerProps {
|
||||||
|
channelId: string | null;
|
||||||
|
channelTitle?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicLabel(t: TopicRow): string {
|
||||||
|
return t.topicName ?? `Topic ${t.topicId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopicsDrawer({
|
||||||
|
channelId,
|
||||||
|
channelTitle,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: TopicsDrawerProps) {
|
||||||
|
const [topics, setTopics] = useState<TopicRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTopics = useCallback(async () => {
|
||||||
|
if (!channelId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/telegram/channels/${channelId}/topics`);
|
||||||
|
if (res.ok) setTopics(await res.json());
|
||||||
|
else toast.error("Failed to load topics");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load topics");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [channelId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && channelId) fetchTopics();
|
||||||
|
}, [open, channelId, fetchTopics]);
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
// Reset the filter on close so the next open starts clean (the drawer is
|
||||||
|
// opened per-channel, so close-then-open is the normal channel switch).
|
||||||
|
if (!next) setFilter("");
|
||||||
|
onOpenChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (topic: TopicRow, enabled: boolean) => {
|
||||||
|
// Optimistic update
|
||||||
|
setTopics((prev) =>
|
||||||
|
prev.map((t) => (t.id === topic.id ? { ...t, fetchEnabled: enabled } : t))
|
||||||
|
);
|
||||||
|
setPendingId(topic.id);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setTopicFetchEnabled(topic.id, enabled);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error ?? "Failed to update topic");
|
||||||
|
// Revert on failure
|
||||||
|
setTopics((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === topic.id ? { ...t, fetchEnabled: !enabled } : t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPendingId(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
if (!q) return topics;
|
||||||
|
return topics.filter((t) => topicLabel(t).toLowerCase().includes(q));
|
||||||
|
}, [topics, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col gap-0 p-0">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<DialogTitle className="truncate pr-8">
|
||||||
|
Topics{channelTitle ? `: ${channelTitle}` : ""}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enabled topics are scanned and their files fetched. Disable a topic
|
||||||
|
to stop fetching new files from it — already-fetched files are kept.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
{topics.length > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter topics..."
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Native overflow scroll: the Radix ScrollArea viewport does not get a
|
||||||
|
bounded height inside this flex-column, max-h, vertically-centred
|
||||||
|
dialog, so the list overflowed instead of scrolling. flex-1 +
|
||||||
|
min-h-0 + overflow-y-auto is the canonical, touch-friendly fix. */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 space-y-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-12">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Loading topics...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : topics.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
No topics discovered yet — they'll appear here after the next
|
||||||
|
scan.
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
No topics match "{filter}".
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((topic) => (
|
||||||
|
<div
|
||||||
|
key={topic.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{topicLabel(topic)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{topic.fetchEnabled
|
||||||
|
? "Fetching enabled"
|
||||||
|
: "Fetching disabled"}
|
||||||
|
{topic.lastScannedAt
|
||||||
|
? ` · last scanned ${new Date(
|
||||||
|
topic.lastScannedAt
|
||||||
|
).toLocaleDateString()}`
|
||||||
|
: " · not scanned yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={topic.fetchEnabled}
|
||||||
|
disabled={pendingId === topic.id}
|
||||||
|
onCheckedChange={(checked) => handleToggle(topic, checked)}
|
||||||
|
aria-label={`Toggle fetching for ${topicLabel(topic)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,13 +9,14 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
SkipForward,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { triggerIngestion } from "../actions";
|
import { triggerIngestion, disableActiveTopic } from "../actions";
|
||||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||||
|
|
||||||
interface WorkerStatusPanelProps {
|
interface WorkerStatusPanelProps {
|
||||||
@@ -218,6 +219,24 @@ function RunningStatus({
|
|||||||
}: {
|
}: {
|
||||||
run: NonNullable<IngestionAccountStatus["currentRun"]>;
|
run: NonNullable<IngestionAccountStatus["currentRun"]>;
|
||||||
}) {
|
}) {
|
||||||
|
const [isDisabling, startDisable] = useTransition();
|
||||||
|
|
||||||
|
const handleSkipTopic = () => {
|
||||||
|
const acmId = run.currentAccountChannelMapId;
|
||||||
|
const topicId = run.currentTopicId;
|
||||||
|
if (!acmId || !topicId) return;
|
||||||
|
startDisable(async () => {
|
||||||
|
const result = await disableActiveTopic(acmId, topicId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(
|
||||||
|
"Topic disabled — the current file finishes, then the rest is skipped"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Failed to disable topic");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -273,6 +292,27 @@ function RunningStatus({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Skip & disable the topic currently being processed */}
|
||||||
|
{run.currentTopicId && run.currentAccountChannelMapId && (
|
||||||
|
<div className="pl-6 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={handleSkipTopic}
|
||||||
|
disabled={isDisabling}
|
||||||
|
title="Finish the current file, skip the rest of this topic, and don't fetch it in future runs"
|
||||||
|
>
|
||||||
|
{isDisabling ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<SkipForward className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Skip & disable this topic
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,80 @@ export async function rescanChannel(channelId: string): Promise<ActionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Topic fetch toggle ──
|
||||||
|
|
||||||
|
export async function setTopicFetchEnabled(
|
||||||
|
topicProgressId: string,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin.success) return admin;
|
||||||
|
|
||||||
|
const existing = await prisma.topicProgress.findUnique({
|
||||||
|
where: { id: topicProgressId },
|
||||||
|
});
|
||||||
|
if (!existing) return { success: false, error: "Topic not found" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.topicProgress.update({
|
||||||
|
where: { id: topicProgressId },
|
||||||
|
data: { fetchEnabled: enabled },
|
||||||
|
});
|
||||||
|
revalidatePath(REVALIDATE_PATH);
|
||||||
|
return { success: true, data: undefined };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to update topic" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the topic currently being processed by the worker, identified by its
|
||||||
|
* account-channel mapping + Telegram topic id (as exposed on the live run
|
||||||
|
* status). Upserts so a disabled row exists even if the worker hasn't persisted
|
||||||
|
* the topic yet. The worker honours this live: it finishes the in-flight file
|
||||||
|
* then skips the rest of the topic, and future runs skip it entirely.
|
||||||
|
*/
|
||||||
|
export async function disableActiveTopic(
|
||||||
|
accountChannelMapId: string,
|
||||||
|
topicId: string
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin.success) return admin;
|
||||||
|
|
||||||
|
let topicIdBig: bigint;
|
||||||
|
try {
|
||||||
|
topicIdBig = BigInt(topicId);
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Invalid topic id" };
|
||||||
|
}
|
||||||
|
// BigInt("") / BigInt("0") don't throw — reject non-positive ids explicitly
|
||||||
|
// since this action is exported and admin-callable outside the UI button.
|
||||||
|
if (topicIdBig <= BigInt(0)) {
|
||||||
|
return { success: false, error: "Invalid topic id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.topicProgress.upsert({
|
||||||
|
where: {
|
||||||
|
accountChannelMapId_topicId: {
|
||||||
|
accountChannelMapId,
|
||||||
|
topicId: topicIdBig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
accountChannelMapId,
|
||||||
|
topicId: topicIdBig,
|
||||||
|
fetchEnabled: false,
|
||||||
|
},
|
||||||
|
update: { fetchEnabled: false },
|
||||||
|
});
|
||||||
|
revalidatePath(REVALIDATE_PATH);
|
||||||
|
return { success: true, data: undefined };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to disable topic" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account-Channel link actions ──
|
// ── Account-Channel link actions ──
|
||||||
|
|
||||||
export async function linkChannel(
|
export async function linkChannel(
|
||||||
|
|||||||
17
src/app/api/telegram/channels/[channelId]/topics/route.ts
Normal file
17
src/app/api/telegram/channels/[channelId]/topics/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
|
||||||
|
import { listChannelTopics } from "@/lib/telegram/admin-queries";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ channelId: string }> }
|
||||||
|
) {
|
||||||
|
const authResult = await authenticateApiRequest(request, true);
|
||||||
|
if ("error" in authResult) return authResult.error;
|
||||||
|
|
||||||
|
const { channelId } = await params;
|
||||||
|
const topics = await listChannelTopics(channelId);
|
||||||
|
return NextResponse.json(topics);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ export async function listChannels() {
|
|||||||
telegramId: c.telegramId.toString(),
|
telegramId: c.telegramId.toString(),
|
||||||
title: c.title,
|
title: c.title,
|
||||||
type: c.type,
|
type: c.type,
|
||||||
|
isForum: c.isForum,
|
||||||
isActive: c.isActive,
|
isActive: c.isActive,
|
||||||
category: c.category,
|
category: c.category,
|
||||||
createdAt: c.createdAt.toISOString(),
|
createdAt: c.createdAt.toISOString(),
|
||||||
@@ -140,3 +141,24 @@ export async function getUnlinkedChannels(accountId: string) {
|
|||||||
telegramId: c.telegramId.toString(),
|
telegramId: c.telegramId.toString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Channel topic queries ──
|
||||||
|
|
||||||
|
export async function listChannelTopics(channelId: string) {
|
||||||
|
const rows = await prisma.topicProgress.findMany({
|
||||||
|
where: { accountChannelMap: { channelId } },
|
||||||
|
orderBy: [{ fetchEnabled: "desc" }, { topicName: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
topicId: r.topicId.toString(),
|
||||||
|
topicName: r.topicName,
|
||||||
|
fetchEnabled: r.fetchEnabled,
|
||||||
|
lastScannedAt: r.lastScannedAt?.toISOString() ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelTopicRow = Awaited<
|
||||||
|
ReturnType<typeof listChannelTopics>
|
||||||
|
>[number];
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ import type {
|
|||||||
PackageGroupRow,
|
PackageGroupRow,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the subset of the given IDs whose `previewData` column is non-null,
|
||||||
|
* WITHOUT transferring the (large) image bytes.
|
||||||
|
*
|
||||||
|
* List views only need a `hasPreview` boolean per row. Selecting `previewData`
|
||||||
|
* directly pulls every JPEG blob (avg ~700 KB, up to 2 MB) into the Node heap
|
||||||
|
* just to compare it against null — under concurrent requests this exhausts the
|
||||||
|
* container memory limit and crashes the process. This keeps the check in SQL.
|
||||||
|
*/
|
||||||
|
async function fetchPreviewFlags(
|
||||||
|
table: "packages" | "package_groups",
|
||||||
|
ids: string[]
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
if (ids.length === 0) return new Set();
|
||||||
|
const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(
|
||||||
|
`SELECT id FROM ${table} WHERE "previewData" IS NOT NULL AND id IN (${placeholders})`,
|
||||||
|
...ids
|
||||||
|
);
|
||||||
|
return new Set(rows.map((r) => r.id));
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPackages(options: {
|
export async function listPackages(options: {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -40,13 +62,17 @@ export async function listPackages(options: {
|
|||||||
indexedAt: true,
|
indexedAt: true,
|
||||||
creator: true,
|
creator: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
previewData: true, // check actual image data, not previewMsgId proxy
|
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.package.count({ where }),
|
prisma.package.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const previewIds = await fetchPreviewFlags(
|
||||||
|
"packages",
|
||||||
|
items.map((p) => p.id)
|
||||||
|
);
|
||||||
|
|
||||||
const mapped: PackageListItem[] = items.map((pkg) => ({
|
const mapped: PackageListItem[] = items.map((pkg) => ({
|
||||||
id: pkg.id,
|
id: pkg.id,
|
||||||
fileName: pkg.fileName,
|
fileName: pkg.fileName,
|
||||||
@@ -55,7 +81,7 @@ export async function listPackages(options: {
|
|||||||
archiveType: pkg.archiveType,
|
archiveType: pkg.archiveType,
|
||||||
fileCount: pkg.fileCount,
|
fileCount: pkg.fileCount,
|
||||||
isMultipart: pkg.isMultipart,
|
isMultipart: pkg.isMultipart,
|
||||||
hasPreview: pkg.previewData !== null,
|
hasPreview: previewIds.has(pkg.id),
|
||||||
creator: pkg.creator,
|
creator: pkg.creator,
|
||||||
tags: pkg.tags,
|
tags: pkg.tags,
|
||||||
indexedAt: pkg.indexedAt.toISOString(),
|
indexedAt: pkg.indexedAt.toISOString(),
|
||||||
@@ -149,7 +175,7 @@ export async function listDisplayItems(options: {
|
|||||||
select: {
|
select: {
|
||||||
id: true, fileName: true, fileSize: true, contentHash: true,
|
id: true, fileName: true, fileSize: true, contentHash: true,
|
||||||
archiveType: true, fileCount: true, isMultipart: true,
|
archiveType: true, fileCount: true, isMultipart: true,
|
||||||
indexedAt: true, creator: true, tags: true, previewData: true,
|
indexedAt: true, creator: true, tags: true,
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -159,13 +185,13 @@ export async function listDisplayItems(options: {
|
|||||||
? await prisma.packageGroup.findMany({
|
? await prisma.packageGroup.findMany({
|
||||||
where: { id: { in: groupIds } },
|
where: { id: { in: groupIds } },
|
||||||
select: {
|
select: {
|
||||||
id: true, name: true, previewData: true,
|
id: true, name: true,
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
packages: {
|
packages: {
|
||||||
select: {
|
select: {
|
||||||
id: true, fileName: true, fileSize: true, contentHash: true,
|
id: true, fileName: true, fileSize: true, contentHash: true,
|
||||||
archiveType: true, fileCount: true, isMultipart: true,
|
archiveType: true, fileCount: true, isMultipart: true,
|
||||||
indexedAt: true, creator: true, tags: true, previewData: true,
|
indexedAt: true, creator: true, tags: true,
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
orderBy: { indexedAt: "desc" },
|
orderBy: { indexedAt: "desc" },
|
||||||
@@ -174,6 +200,16 @@ export async function listDisplayItems(options: {
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Compute hasPreview flags without transferring image bytes (see fetchPreviewFlags)
|
||||||
|
const allPackageIds = [
|
||||||
|
...standalonePackages.map((p) => p.id),
|
||||||
|
...groups.flatMap((g) => g.packages.map((p) => p.id)),
|
||||||
|
];
|
||||||
|
const [packagePreviewIds, groupPreviewIds] = await Promise.all([
|
||||||
|
fetchPreviewFlags("packages", allPackageIds),
|
||||||
|
fetchPreviewFlags("package_groups", groups.map((g) => g.id)),
|
||||||
|
]);
|
||||||
|
|
||||||
// Build DisplayItem array in the original sort order
|
// Build DisplayItem array in the original sort order
|
||||||
const packageMap = new Map(standalonePackages.map((p) => [p.id, p]));
|
const packageMap = new Map(standalonePackages.map((p) => [p.id, p]));
|
||||||
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
||||||
@@ -191,7 +227,7 @@ export async function listDisplayItems(options: {
|
|||||||
archiveType: pkg.archiveType,
|
archiveType: pkg.archiveType,
|
||||||
fileCount: pkg.fileCount,
|
fileCount: pkg.fileCount,
|
||||||
isMultipart: pkg.isMultipart,
|
isMultipart: pkg.isMultipart,
|
||||||
hasPreview: pkg.previewData !== null,
|
hasPreview: packagePreviewIds.has(pkg.id),
|
||||||
creator: pkg.creator,
|
creator: pkg.creator,
|
||||||
tags: pkg.tags,
|
tags: pkg.tags,
|
||||||
indexedAt: pkg.indexedAt.toISOString(),
|
indexedAt: pkg.indexedAt.toISOString(),
|
||||||
@@ -209,7 +245,7 @@ export async function listDisplayItems(options: {
|
|||||||
data: {
|
data: {
|
||||||
id: grp.id,
|
id: grp.id,
|
||||||
name: grp.name,
|
name: grp.name,
|
||||||
hasPreview: grp.previewData !== null,
|
hasPreview: groupPreviewIds.has(grp.id),
|
||||||
totalFileSize: grp.packages.reduce((sum, p) => sum + p.fileSize, BigInt(0)).toString(),
|
totalFileSize: grp.packages.reduce((sum, p) => sum + p.fileSize, BigInt(0)).toString(),
|
||||||
totalFileCount: grp.packages.reduce((sum, p) => sum + p.fileCount, 0),
|
totalFileCount: grp.packages.reduce((sum, p) => sum + p.fileCount, 0),
|
||||||
packageCount: grp.packages.length,
|
packageCount: grp.packages.length,
|
||||||
@@ -227,7 +263,7 @@ export async function listDisplayItems(options: {
|
|||||||
archiveType: pkg.archiveType,
|
archiveType: pkg.archiveType,
|
||||||
fileCount: pkg.fileCount,
|
fileCount: pkg.fileCount,
|
||||||
isMultipart: pkg.isMultipart,
|
isMultipart: pkg.isMultipart,
|
||||||
hasPreview: pkg.previewData !== null,
|
hasPreview: packagePreviewIds.has(pkg.id),
|
||||||
creator: pkg.creator,
|
creator: pkg.creator,
|
||||||
tags: pkg.tags,
|
tags: pkg.tags,
|
||||||
indexedAt: pkg.indexedAt.toISOString(),
|
indexedAt: pkg.indexedAt.toISOString(),
|
||||||
@@ -440,13 +476,17 @@ export async function searchPackages(options: {
|
|||||||
indexedAt: true,
|
indexedAt: true,
|
||||||
creator: true,
|
creator: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
previewData: true,
|
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Promise.resolve(allIds.length),
|
Promise.resolve(allIds.length),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const previewIds = await fetchPreviewFlags(
|
||||||
|
"packages",
|
||||||
|
items.map((p) => p.id)
|
||||||
|
);
|
||||||
|
|
||||||
const mapped: PackageListItem[] = items.map((pkg) => ({
|
const mapped: PackageListItem[] = items.map((pkg) => ({
|
||||||
id: pkg.id,
|
id: pkg.id,
|
||||||
fileName: pkg.fileName,
|
fileName: pkg.fileName,
|
||||||
@@ -455,7 +495,7 @@ export async function searchPackages(options: {
|
|||||||
archiveType: pkg.archiveType,
|
archiveType: pkg.archiveType,
|
||||||
fileCount: pkg.fileCount,
|
fileCount: pkg.fileCount,
|
||||||
isMultipart: pkg.isMultipart,
|
isMultipart: pkg.isMultipart,
|
||||||
hasPreview: pkg.previewData !== null,
|
hasPreview: previewIds.has(pkg.id),
|
||||||
creator: pkg.creator,
|
creator: pkg.creator,
|
||||||
tags: pkg.tags,
|
tags: pkg.tags,
|
||||||
indexedAt: pkg.indexedAt.toISOString(),
|
indexedAt: pkg.indexedAt.toISOString(),
|
||||||
@@ -494,6 +534,15 @@ export async function getAllPackageTags(): Promise<string[]> {
|
|||||||
return result.map((r) => r.tag);
|
return result.map((r) => r.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllPackageCreators(): Promise<string[]> {
|
||||||
|
const result = await prisma.$queryRaw<{ creator: string }[]>`
|
||||||
|
SELECT DISTINCT creator FROM packages
|
||||||
|
WHERE creator IS NOT NULL AND creator <> ''
|
||||||
|
ORDER BY creator
|
||||||
|
`;
|
||||||
|
return result.map((r) => r.creator);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
||||||
const accounts = await prisma.telegramAccount.findMany({
|
const accounts = await prisma.telegramAccount.findMany({
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
@@ -550,6 +599,8 @@ export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
|||||||
totalBytes: currentRun.totalBytes?.toString() ?? null,
|
totalBytes: currentRun.totalBytes?.toString() ?? null,
|
||||||
downloadPercent: currentRun.downloadPercent,
|
downloadPercent: currentRun.downloadPercent,
|
||||||
lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null,
|
lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null,
|
||||||
|
currentTopicId: currentRun.currentTopicId?.toString() ?? null,
|
||||||
|
currentAccountChannelMapId: currentRun.currentAccountChannelMapId,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
@@ -589,6 +640,7 @@ export async function listSkippedPackages(options: {
|
|||||||
sourceMessageId: s.sourceMessageId.toString(),
|
sourceMessageId: s.sourceMessageId.toString(),
|
||||||
isMultipart: s.isMultipart,
|
isMultipart: s.isMultipart,
|
||||||
partCount: s.partCount,
|
partCount: s.partCount,
|
||||||
|
attemptCount: s.attemptCount,
|
||||||
createdAt: s.createdAt.toISOString(),
|
createdAt: s.createdAt.toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -633,13 +685,17 @@ export async function listUngroupedPackages(options: {
|
|||||||
partCount: true,
|
partCount: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
indexedAt: true,
|
indexedAt: true,
|
||||||
previewData: true,
|
|
||||||
sourceChannel: { select: { id: true, title: true } },
|
sourceChannel: { select: { id: true, title: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.package.count({ where }),
|
prisma.package.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const previewIds = await fetchPreviewFlags(
|
||||||
|
"packages",
|
||||||
|
items.map((p) => p.id)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map((p) => ({
|
items: items.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -653,7 +709,7 @@ export async function listUngroupedPackages(options: {
|
|||||||
partCount: p.partCount,
|
partCount: p.partCount,
|
||||||
tags: p.tags,
|
tags: p.tags,
|
||||||
indexedAt: p.indexedAt.toISOString(),
|
indexedAt: p.indexedAt.toISOString(),
|
||||||
hasPreview: !!p.previewData,
|
hasPreview: previewIds.has(p.id),
|
||||||
sourceChannel: p.sourceChannel,
|
sourceChannel: p.sourceChannel,
|
||||||
matchedFileCount: 0,
|
matchedFileCount: 0,
|
||||||
matchedByContent: false,
|
matchedByContent: false,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface SkippedPackageItem {
|
|||||||
sourceMessageId: string;
|
sourceMessageId: string;
|
||||||
isMultipart: boolean;
|
isMultipart: boolean;
|
||||||
partCount: number;
|
partCount: number;
|
||||||
|
/** How many times the worker has tried this source message across cycles. */
|
||||||
|
attemptCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,5 +123,7 @@ export interface IngestionAccountStatus {
|
|||||||
totalBytes: string | null; // BigInt serialized as string
|
totalBytes: string | null; // BigInt serialized as string
|
||||||
downloadPercent: number | null;
|
downloadPercent: number | null;
|
||||||
lastActivityAt: string | null;
|
lastActivityAt: string | null;
|
||||||
|
currentTopicId: string | null; // BigInt serialized as string
|
||||||
|
currentAccountChannelMapId: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|||||||
94
worker/package-lock.json
generated
94
worker/package-lock.json
generated
@@ -12,8 +12,8 @@
|
|||||||
"@prisma/client": "^7.4.0",
|
"@prisma/client": "^7.4.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"prebuilt-tdlib": "^0.1008050.0",
|
"prebuilt-tdlib": "^0.1008064.0",
|
||||||
"tdl": "^8.0.0",
|
"tdl": "^8.1.0",
|
||||||
"yauzl": "^3.2.0"
|
"yauzl": "^3.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -568,9 +568,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/darwin-arm64": {
|
"node_modules/@prebuilt-tdlib/darwin-arm64": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-XrWN7M1gfvnzOBRX0YdXVfhSxIDSs/ZJ16QJ0ILDKe+grOFl/cfl7lwB/hK/MlHC6Rev56f5X7xaWnjMh0vktQ==",
|
"integrity": "sha512-Oq5us+o0g68Jag74RIV3LdLkZxQxJMcOdrVbgmyE7Unk+WcifqTb/gZw1rS6BrW+2SX2LNeGY4zQqqBTNDr17Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -581,9 +581,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/darwin-x64": {
|
"node_modules/@prebuilt-tdlib/darwin-x64": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-a1UfBW0lYx4tUy5viMPtsbqBfBncCAgDu3FPjljfYTHjP8wfkKFxpp5+8wdxhyqdy3QriWaipVtUXQgOeEWMJg==",
|
"integrity": "sha512-Pz11xjET2Y3uUJKxkWKBc0dmOtlykmBdZ9D6Ahh+EsoLDLIWHm7M91p6nZT396YZ4n2BL+FtDYK65Ae3LDIA5g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -594,9 +594,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/linux-arm64-glibc": {
|
"node_modules/@prebuilt-tdlib/linux-arm64-glibc": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-HRGspdQYzaBkU+W2M8uY5OgOkmgfTkyHkTYan/dn7EE/38QdIFW0YTvmGrl3DoFV2PA+SeJQw0xqK8tMSyHKaA==",
|
"integrity": "sha512-1kML9+RCfTOTWLzxq2klCN962/XwYhd+SGd4BxOwcmvPniYDNRUXtgMi3qRyV/Flola8dchGFrqZJU4kNZNLuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@prebuilt-tdlib/linux-arm64-musl": {
|
||||||
|
"version": "0.1008064.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-musl/-/linux-arm64-musl-0.1008064.0.tgz",
|
||||||
|
"integrity": "sha512-tN9FJOR8VDfmOoTHMivAqBfQ/d9Bry9T/9cGSTcms3H4ORun/WO5U5zT8VqadAsqjuiQ8Y9HaUqqz65xBDtcgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -607,9 +620,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@prebuilt-tdlib/linux-x64-glibc": {
|
"node_modules/@prebuilt-tdlib/linux-x64-glibc": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-Yf6ve3Dzxc66kV1cijFLn7EXKhPN5YHTjtJABEaCR5euetCI2wZp/1uBsXvyYTuFXqQbMfjO3xUCXUIBhLoChw==",
|
"integrity": "sha512-7fyCp2uk0BdeHKJ9PyQOCditC9vBXeeIjYPAKKBcrkum5bi1e9txy2g5kkGjqwUkN0ntIniS5QfHEyr17Idr9g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -619,10 +632,30 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@prebuilt-tdlib/linux-x64-musl": {
|
||||||
|
"version": "0.1008064.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-musl/-/linux-x64-musl-0.1008064.0.tgz",
|
||||||
|
"integrity": "sha512-e2zRucrRrrK6M04iQWMfwtrts+VvVtyUwtTP1hF2g3a6jW+AHMzFoB9Wu8fWr+vuJflLIQ6sG9r3lI07Q8NenQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@prebuilt-tdlib/types": {
|
||||||
|
"version": "0.1008064.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/types/-/types-0.1008064.0.tgz",
|
||||||
|
"integrity": "sha512-eqr1+fiHZ+Gj4lwcITzMp6FwPg8UrxlxxaFjhiJRHL9BlbmD2QkCRHac4wW1Sx8Dzwzd7f+xO21Pgi7TBRSwmw==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@prebuilt-tdlib/win32-x64": {
|
"node_modules/@prebuilt-tdlib/win32-x64": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-4v8tU5bodMcLhzrWWXzIzqdHBIpq0wim+7sDmQWQIMy3kDeIzVtpuM+vQjxrGoeH9oWr2WXSRKuj93ld7G5NbQ==",
|
"integrity": "sha512-rkacZWexQw52/EUaLAmbsu2+P3C1/AtinlCjfiX07oQAEg3327BCEZqrcY0ER83D8+MMf2pfwMPCDJKytr4hcg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1696,16 +1729,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuilt-tdlib": {
|
"node_modules/prebuilt-tdlib": {
|
||||||
"version": "0.1008050.0",
|
"version": "0.1008064.0",
|
||||||
"resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008050.0.tgz",
|
"resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008064.0.tgz",
|
||||||
"integrity": "sha512-CfeQE1rG51d2iC6m72fzrbCW4mqI17ugil9pVurWHtfUJi1Fcn7zadpTzDoUl4oc1dEtKgM7S24DVP67gcl4SQ==",
|
"integrity": "sha512-jJLowKZoH4slXYrkTkKlEgyGsIGv61AWjDZcxxVxJYu21X3kmukGwbCpk4ML99cJp2CwRsD41GCEQBkKJAwCUg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@prebuilt-tdlib/darwin-arm64": "0.1008050.0",
|
"@prebuilt-tdlib/darwin-arm64": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/darwin-x64": "0.1008050.0",
|
"@prebuilt-tdlib/darwin-x64": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/linux-arm64-glibc": "0.1008050.0",
|
"@prebuilt-tdlib/linux-arm64-glibc": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/linux-x64-glibc": "0.1008050.0",
|
"@prebuilt-tdlib/linux-arm64-musl": "0.1008064.0",
|
||||||
"@prebuilt-tdlib/win32-x64": "0.1008050.0"
|
"@prebuilt-tdlib/linux-x64-glibc": "0.1008064.0",
|
||||||
|
"@prebuilt-tdlib/linux-x64-musl": "0.1008064.0",
|
||||||
|
"@prebuilt-tdlib/types": "0.1008064.0",
|
||||||
|
"@prebuilt-tdlib/win32-x64": "0.1008064.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
@@ -1998,13 +2034,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tdl": {
|
"node_modules/tdl": {
|
||||||
"version": "8.0.2",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tdl/-/tdl-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tdl/-/tdl-8.1.0.tgz",
|
||||||
"integrity": "sha512-KYxlJ4eao7FUu91U1dCDkaHmK70JAyZ1KqitkKqpPC7rxAiXWhaYxddWvt84UxIYoWbgdd0B70FYJ4p/YqpFCA==",
|
"integrity": "sha512-idpw60gjJdiJALQg0+6UbxtJTMxVhzZAgCO6QzL81gqBYCkEFjm9zM9HwTTQGeOaAavw4yRHymR68yUUiCoKrA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.3",
|
||||||
"node-addon-api": "^7.1.1",
|
"node-addon-api": "^7.1.1",
|
||||||
"node-gyp-build": "^4.8.4"
|
"node-gyp-build": "^4.8.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
"@prisma/client": "^7.4.0",
|
"@prisma/client": "^7.4.0",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"prebuilt-tdlib": "^0.1008050.0",
|
"prebuilt-tdlib": "^0.1008064.0",
|
||||||
"tdl": "^8.0.0",
|
"tdl": "^8.1.0",
|
||||||
"yauzl": "^3.2.0"
|
"yauzl": "^3.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
128
worker/src/archive/integrity.ts
Normal file
128
worker/src/archive/integrity.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { childLogger } from "../util/logger.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const log = childLogger("integrity");
|
||||||
|
|
||||||
|
export type IntegrityFailureKind = "encrypted" | "corrupt" | "inconclusive";
|
||||||
|
|
||||||
|
export type IntegrityResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; reason: string; kind: IntegrityFailureKind };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test that the archive can be read end-to-end without errors, BEFORE we
|
||||||
|
* spend bandwidth uploading it to the destination channel.
|
||||||
|
*
|
||||||
|
* Failures are classified so the caller can react appropriately:
|
||||||
|
* - "encrypted" — password-protected; users can't extract it. Actionable.
|
||||||
|
* - "corrupt" — genuine CRC / structural error (truncated download, bad
|
||||||
|
* central directory, CRC mismatch). Actionable.
|
||||||
|
* - "inconclusive" — the test tool itself was killed (OOM) or timed out,
|
||||||
|
* typically on very large 7z archives in a memory-limited
|
||||||
|
* container (exit 137 / SIGKILL). This is a TOOL
|
||||||
|
* LIMITATION, not corruption — callers should NOT raise a
|
||||||
|
* user-facing alarm for it.
|
||||||
|
*
|
||||||
|
* Returns { ok: true } if the archive is intact, otherwise
|
||||||
|
* { ok: false, reason, kind }.
|
||||||
|
*
|
||||||
|
* For multipart archives, pass the first part. unzip / unrar / 7z all
|
||||||
|
* auto-discover sibling parts.
|
||||||
|
*
|
||||||
|
* archiveType "DOCUMENT" is a pass-through — there's no container to test.
|
||||||
|
*/
|
||||||
|
export async function testArchiveIntegrity(
|
||||||
|
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT",
|
||||||
|
firstPartPath: string
|
||||||
|
): Promise<IntegrityResult> {
|
||||||
|
if (archiveType === "DOCUMENT") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (archiveType === "ZIP") {
|
||||||
|
// -t = test, -qq = very quiet (errors only)
|
||||||
|
const { stderr } = await execFileAsync("unzip", ["-tqq", firstPartPath], {
|
||||||
|
timeout: 300_000, // 5 min for very large archives
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (stderr && stderr.trim()) {
|
||||||
|
return { ok: false, kind: "corrupt", reason: `unzip -t reported: ${stderr.slice(0, 500)}` };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (archiveType === "RAR") {
|
||||||
|
const { stdout, stderr } = await execFileAsync("unrar", ["t", firstPartPath], {
|
||||||
|
timeout: 300_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
// unrar uses non-zero exit code on errors, which becomes a throw.
|
||||||
|
// If it succeeds, "All OK" is in stdout.
|
||||||
|
const combined = `${stdout}\n${stderr}`;
|
||||||
|
if (/All OK/i.test(combined)) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, kind: "corrupt", reason: `unrar t did not report "All OK": ${combined.slice(-500)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (archiveType === "SEVEN_Z") {
|
||||||
|
const { stdout, stderr } = await execFileAsync("7z", ["t", firstPartPath], {
|
||||||
|
timeout: 300_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const combined = `${stdout}\n${stderr}`;
|
||||||
|
if (/Everything is Ok/i.test(combined)) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return { ok: false, kind: "corrupt", reason: `7z t did not report "Everything is Ok": ${combined.slice(-500)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, kind: "corrupt", reason: `Unknown archive type: ${archiveType}` };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
// execFile throws on non-zero exit, on timeout, and when killed by a signal.
|
||||||
|
const e = err as {
|
||||||
|
stderr?: unknown;
|
||||||
|
stdout?: unknown;
|
||||||
|
signal?: string | null;
|
||||||
|
killed?: boolean;
|
||||||
|
code?: number | string | null;
|
||||||
|
};
|
||||||
|
const stderr = typeof e.stderr === "string" ? e.stderr : "";
|
||||||
|
const stdout = typeof e.stdout === "string" ? e.stdout : "";
|
||||||
|
const detail = stderr ? `: ${stderr.slice(0, 500)}` : "";
|
||||||
|
const haystack = `${msg}\n${stdout}\n${stderr}`;
|
||||||
|
|
||||||
|
// Encrypted archives — users can't extract them, so flag clearly.
|
||||||
|
if (/password|encrypted|wrong password|enter password/i.test(haystack)) {
|
||||||
|
return { ok: false, kind: "encrypted", reason: `Archive is encrypted (password protected): ${msg}${detail}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inconclusive — the test tool was killed or timed out rather than
|
||||||
|
// reporting corruption. Common on large 7z in memory-limited containers,
|
||||||
|
// where `7z t` gets OOM-killed (SIGKILL / exit 137) mid-decompression.
|
||||||
|
// That's a tool limitation, not a corrupt archive.
|
||||||
|
const killedBySignal = e.signal === "SIGKILL" || e.signal === "SIGTERM";
|
||||||
|
const killedExitCode = e.code === 137 || e.code === 143; // 128 + SIGKILL/SIGTERM
|
||||||
|
const timedOut = e.killed === true;
|
||||||
|
const maxBufferExceeded = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
||||||
|
if (killedBySignal || killedExitCode || timedOut || maxBufferExceeded) {
|
||||||
|
log.debug(
|
||||||
|
{ err, archiveType, firstPartPath, signal: e.signal, code: e.code, killed: e.killed },
|
||||||
|
"Archive integrity test inconclusive (tool killed or timed out)"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
kind: "inconclusive",
|
||||||
|
reason: `Integrity test could not complete (tool killed or timed out — likely OOM on a large archive): ${msg}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genuine failure: a real non-zero exit with error output.
|
||||||
|
log.debug({ err, archiveType, firstPartPath }, "Archive integrity test failed");
|
||||||
|
return { ok: false, kind: "corrupt", reason: `Integrity test failed: ${msg}${detail}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,11 @@ export interface TelegramMessage {
|
|||||||
fileSize: bigint;
|
fileSize: bigint;
|
||||||
date: Date;
|
date: Date;
|
||||||
mediaAlbumId?: string;
|
mediaAlbumId?: string;
|
||||||
replyToMessageId?: bigint; // NEW
|
replyToMessageId?: bigint;
|
||||||
caption?: string; // NEW
|
caption?: string;
|
||||||
|
/** TDLib's `remote.unique_id` for the file — stable across reposts of
|
||||||
|
* the exact same content. Empty string if the message didn't expose it. */
|
||||||
|
remoteUniqueId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArchiveSet {
|
export interface ArchiveSet {
|
||||||
|
|||||||
@@ -8,83 +8,122 @@ const execFileAsync = promisify(execFile);
|
|||||||
const log = childLogger("rar-reader");
|
const log = childLogger("rar-reader");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse output of `unrar l -v <file>` to extract file metadata.
|
* Parse output of `unrar lt <file>` to extract file metadata.
|
||||||
* unrar automatically discovers sibling parts when they're co-located.
|
*
|
||||||
|
* `lt` (list technical) emits one block per archived file with key:value
|
||||||
|
* lines — far more reliable than the column-based default `l -v` output,
|
||||||
|
* which has changed format twice across unrar versions.
|
||||||
|
*
|
||||||
|
* unrar automatically discovers sibling multipart files when they're
|
||||||
|
* co-located (e.g. *.part1.rar + *.part2.rar in the same directory).
|
||||||
|
*
|
||||||
|
* Returns [] on any failure (best-effort: ingestion still succeeds with
|
||||||
|
* an empty file list rather than failing the whole archive).
|
||||||
*/
|
*/
|
||||||
export async function readRarContents(
|
export async function readRarContents(
|
||||||
firstPartPath: string
|
firstPartPath: string
|
||||||
): Promise<FileEntry[]> {
|
): Promise<FileEntry[]> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync("unrar", ["l", "-v", firstPartPath], {
|
const { stdout } = await execFileAsync("unrar", ["lt", firstPartPath], {
|
||||||
timeout: 30000,
|
timeout: 60_000,
|
||||||
maxBuffer: 10 * 1024 * 1024, // 10MB for very large archives
|
maxBuffer: 50 * 1024 * 1024, // 50MB for archives with very many files
|
||||||
});
|
});
|
||||||
|
|
||||||
return parseUnrarOutput(stdout);
|
const entries = parseUnrarTechnical(stdout);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
// Log a sample of the output so we can diagnose format changes
|
||||||
|
log.warn(
|
||||||
|
{ file: firstPartPath, sample: stdout.slice(0, 500) },
|
||||||
|
"unrar lt returned no parseable entries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn({ err, file: firstPartPath }, "Failed to read RAR contents");
|
log.warn({ err, file: firstPartPath }, "Failed to read RAR contents");
|
||||||
return []; // Fallback: return empty on error
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the tabular output of `unrar l -v`.
|
* Parse `unrar lt` output: header followed by per-file key:value blocks
|
||||||
|
* separated by blank lines.
|
||||||
*
|
*
|
||||||
* Example output format:
|
* Example block:
|
||||||
* Archive: test.rar
|
|
||||||
* Details: RAR 5
|
|
||||||
*
|
*
|
||||||
* Attributes Size Packed Ratio Date Time CRC-32 Name
|
* Name: folder/file.stl
|
||||||
* ----------- --------- --------- ----- -------- ----- -------- ----
|
* Type: File
|
||||||
* ...A.... 12345 10234 83% 2024-01-15 10:30 DEADBEEF folder/file.stl
|
* Size: 12345
|
||||||
* ----------- --------- --------- ----- -------- ----- -------- ----
|
* Packed size: 10234
|
||||||
|
* Ratio: 83%
|
||||||
|
* mtime: 2024-01-15 10:30:00,000000000
|
||||||
|
* Attributes: ..A....
|
||||||
|
* CRC32: DEADBEEF
|
||||||
|
* Host OS: Windows
|
||||||
|
* Compression: RAR 5.0(v50) -m3 -md=32M
|
||||||
*/
|
*/
|
||||||
function parseUnrarOutput(output: string): FileEntry[] {
|
function parseUnrarTechnical(output: string): FileEntry[] {
|
||||||
const entries: FileEntry[] = [];
|
const entries: FileEntry[] = [];
|
||||||
const lines = output.split("\n");
|
// Split into blocks on blank lines, then on each block read key:value pairs.
|
||||||
|
const blocks = output.split(/\r?\n\s*\r?\n/);
|
||||||
|
|
||||||
let inFileList = false;
|
for (const block of blocks) {
|
||||||
let separatorCount = 0;
|
const fields = parseBlock(block);
|
||||||
|
if (!fields) continue;
|
||||||
|
|
||||||
for (const line of lines) {
|
// Only File entries (skip Directory, and anything missing the basics)
|
||||||
const trimmed = line.trim();
|
if (fields.type && fields.type.toLowerCase() !== "file") continue;
|
||||||
|
if (!fields.name || fields.size === undefined) continue;
|
||||||
// Detect separator lines (------- pattern)
|
|
||||||
if (/^-{5,}/.test(trimmed)) {
|
|
||||||
separatorCount++;
|
|
||||||
if (separatorCount === 1) {
|
|
||||||
inFileList = true;
|
|
||||||
} else if (separatorCount >= 2) {
|
|
||||||
inFileList = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inFileList) continue;
|
|
||||||
|
|
||||||
// Parse file entry line
|
|
||||||
// Format: Attributes Size Packed Ratio Date Time CRC Name
|
|
||||||
const match = trimmed.match(
|
|
||||||
/^\S+\s+(\d+)\s+(\d+)\s+\d+%\s+\S+\s+\S+\s+([0-9A-Fa-f]+)\s+(.+)$/
|
|
||||||
);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const [, uncompressedStr, compressedStr, crc32, filePath] = match;
|
|
||||||
|
|
||||||
// Skip directory entries (typically end with / or have size 0 with dir attributes)
|
|
||||||
if (filePath.endsWith("/") || filePath.endsWith("\\")) continue;
|
|
||||||
|
|
||||||
|
const filePath = fields.name;
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
entries.push({
|
entries.push({
|
||||||
path: filePath,
|
path: filePath,
|
||||||
fileName: path.basename(filePath),
|
fileName: path.basename(filePath),
|
||||||
extension: ext ? ext.slice(1) : null,
|
extension: ext ? ext.slice(1) : null,
|
||||||
compressedSize: BigInt(compressedStr),
|
uncompressedSize: BigInt(fields.size),
|
||||||
uncompressedSize: BigInt(uncompressedStr),
|
compressedSize: fields.packedSize !== undefined
|
||||||
crc32: crc32.toLowerCase(),
|
? BigInt(fields.packedSize)
|
||||||
|
: BigInt(fields.size),
|
||||||
|
crc32: fields.crc32 ? fields.crc32.toLowerCase() : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BlockFields {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
size?: string;
|
||||||
|
packedSize?: string;
|
||||||
|
crc32?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlock(block: string): BlockFields | null {
|
||||||
|
// Skip the archive-header block (contains "Archive:" / "Details:" lines
|
||||||
|
// and lacks a Name field).
|
||||||
|
if (!/^\s*Name:/m.test(block)) return null;
|
||||||
|
|
||||||
|
const fields: BlockFields = {};
|
||||||
|
const lines = block.split(/\r?\n/);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Match " key: value" with arbitrary leading whitespace and a multi-word
|
||||||
|
// key (e.g. "Packed size", "Host OS").
|
||||||
|
const m = line.match(/^\s*([A-Za-z][A-Za-z0-9 ]*?)\s*:\s*(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const key = m[1].trim().toLowerCase();
|
||||||
|
const value = m[2].trim();
|
||||||
|
if (key === "name") fields.name = value;
|
||||||
|
else if (key === "type") fields.type = value;
|
||||||
|
else if (key === "size") fields.size = value;
|
||||||
|
else if (key === "packed size") fields.packedSize = value;
|
||||||
|
else if (key === "crc32" || key === "blake2sp" || key === "checksum") {
|
||||||
|
// unrar may report BLAKE2sp for newer archives instead of CRC32.
|
||||||
|
// Either way we just store it as a hex string in our crc32 field.
|
||||||
|
fields.crc32 = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|||||||
58
worker/src/archive/slicer-tags.ts
Normal file
58
worker/src/archive/slicer-tags.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { FileEntry } from "./zip-reader.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from file extensions to slicer tags. Each tag groups a family of
|
||||||
|
* extensions that mean the same thing for end users — "this archive contains
|
||||||
|
* files I can open in <slicer>".
|
||||||
|
*
|
||||||
|
* Extensions are matched case-insensitively without the leading dot.
|
||||||
|
*/
|
||||||
|
const SLICER_EXTENSION_MAP: Record<string, string> = {
|
||||||
|
// Lychee Slicer
|
||||||
|
lys: "lychee",
|
||||||
|
lyt: "lychee",
|
||||||
|
lyc: "lychee",
|
||||||
|
|
||||||
|
// ChituBox / Anycubic / Phrozen / Elegoo (resin printers)
|
||||||
|
chitubox: "chitubox",
|
||||||
|
ctb: "chitubox",
|
||||||
|
cbddlp: "chitubox",
|
||||||
|
|
||||||
|
// Anycubic Photon family
|
||||||
|
photon: "anycubic",
|
||||||
|
pwmo: "anycubic",
|
||||||
|
pwmx: "anycubic",
|
||||||
|
pwmb: "anycubic",
|
||||||
|
pwma: "anycubic",
|
||||||
|
pws: "anycubic",
|
||||||
|
pwsq: "anycubic",
|
||||||
|
phz: "anycubic",
|
||||||
|
|
||||||
|
// Bambu / Prusa
|
||||||
|
"3mf": "bambu",
|
||||||
|
bgcode: "bambu",
|
||||||
|
|
||||||
|
// FDM gcode (generic)
|
||||||
|
gcode: "fdm",
|
||||||
|
|
||||||
|
// Mango / generic resin formats sometimes seen in releases
|
||||||
|
mfp: "mango",
|
||||||
|
mfpv: "mango",
|
||||||
|
osla: "mango",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a deduplicated list of slicer tags from an archive's file listing.
|
||||||
|
* Returns an empty array if no recognised slicer-specific files are present
|
||||||
|
* (e.g., the archive is just STLs without pre-supports).
|
||||||
|
*/
|
||||||
|
export function extractSlicerTags(entries: FileEntry[]): string[] {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.extension) continue;
|
||||||
|
const ext = entry.extension.toLowerCase();
|
||||||
|
const tag = SLICER_EXTENSION_MAP[ext];
|
||||||
|
if (tag) tags.add(tag);
|
||||||
|
}
|
||||||
|
return [...tags].sort();
|
||||||
|
}
|
||||||
343
worker/src/backfill.ts
Normal file
343
worker/src/backfill.ts
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { mkdir, rm } from "fs/promises";
|
||||||
|
import { db } from "./db/client.js";
|
||||||
|
import { config } from "./util/config.js";
|
||||||
|
import { childLogger } from "./util/logger.js";
|
||||||
|
import { withTdlibMutex } from "./util/mutex.js";
|
||||||
|
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||||
|
import { downloadFile } from "./tdlib/download.js";
|
||||||
|
import { getActiveAccounts } from "./db/queries.js";
|
||||||
|
import { readZipCentralDirectory } from "./archive/zip-reader.js";
|
||||||
|
import { readRarContents } from "./archive/rar-reader.js";
|
||||||
|
import { read7zContents } from "./archive/sevenz-reader.js";
|
||||||
|
import { extractSlicerTags } from "./archive/slicer-tags.js";
|
||||||
|
import type { FileEntry } from "./archive/zip-reader.js";
|
||||||
|
|
||||||
|
const log = childLogger("backfill");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-extract file listings for Packages whose fileCount is 0 — usually
|
||||||
|
* caused by historical bugs in the archive readers (e.g. the RAR parser
|
||||||
|
* that silently returned [] for every archive before 0bdd4ba).
|
||||||
|
*
|
||||||
|
* For each candidate Package:
|
||||||
|
* 1. Download all destMessageIds from the destination channel
|
||||||
|
* 2. Run the appropriate reader (ZIP / RAR / 7Z) on the assembled files
|
||||||
|
* 3. Insert PackageFile rows + update Package.fileCount
|
||||||
|
* 4. Clean up the temp files
|
||||||
|
*
|
||||||
|
* Triggered via pg_notify "backfill_filelists" with optional payload
|
||||||
|
* `{"limit": N, "archiveType": "RAR"}` — both fields optional, defaults
|
||||||
|
* are limit=100, archiveType=any.
|
||||||
|
*/
|
||||||
|
export async function processBackfillRequest(payloadJson: string): Promise<void> {
|
||||||
|
let limit = 100;
|
||||||
|
let archiveTypeFilter: "ZIP" | "RAR" | "SEVEN_Z" | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadJson) as { limit?: number; archiveType?: string };
|
||||||
|
if (typeof parsed.limit === "number" && parsed.limit > 0) limit = parsed.limit;
|
||||||
|
if (parsed.archiveType === "ZIP" || parsed.archiveType === "RAR" || parsed.archiveType === "SEVEN_Z") {
|
||||||
|
archiveTypeFilter = parsed.archiveType;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Empty / invalid payload — use defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await db.package.findMany({
|
||||||
|
where: {
|
||||||
|
fileCount: 0,
|
||||||
|
destChannelId: { not: null },
|
||||||
|
destMessageId: { not: null },
|
||||||
|
archiveType: archiveTypeFilter
|
||||||
|
? archiveTypeFilter
|
||||||
|
: { in: ["ZIP", "RAR", "SEVEN_Z"] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileName: true,
|
||||||
|
fileSize: true,
|
||||||
|
archiveType: true,
|
||||||
|
destChannelId: true,
|
||||||
|
destMessageId: true,
|
||||||
|
destMessageIds: true,
|
||||||
|
isMultipart: true,
|
||||||
|
partCount: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
log.info({ archiveTypeFilter }, "Backfill: no candidates with fileCount=0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ count: candidates.length, archiveTypeFilter },
|
||||||
|
"Backfill: starting batch"
|
||||||
|
);
|
||||||
|
|
||||||
|
const accounts = await getActiveAccounts();
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
log.warn("Backfill: no authenticated accounts — aborting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the Premium account if available (faster downloads, larger files)
|
||||||
|
const account = accounts.find((a) => a.isPremium) ?? accounts[0];
|
||||||
|
|
||||||
|
await withTdlibMutex(account.phone, "backfill", async () => {
|
||||||
|
const { client } = await createTdlibClient({ id: account.id, phone: account.phone });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load chats so TDLib knows about the destination chat
|
||||||
|
try {
|
||||||
|
await client.invoke({
|
||||||
|
_: "getChats",
|
||||||
|
chat_list: { _: "chatListMain" },
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// May already be loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let succeeded = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const pkg of candidates) {
|
||||||
|
processed++;
|
||||||
|
const ctx = { packageId: pkg.id, fileName: pkg.fileName };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processOnePackage(client, pkg, ctx);
|
||||||
|
succeeded++;
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
log.warn({ err, ...ctx }, "Backfill failed for package");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ processed, succeeded, failed, archiveTypeFilter },
|
||||||
|
"Backfill batch complete"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await closeTdlibClient(client).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackfillPackage {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
fileSize: bigint;
|
||||||
|
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT" | string;
|
||||||
|
destChannelId: string | null;
|
||||||
|
destMessageId: bigint | null;
|
||||||
|
destMessageIds: bigint[];
|
||||||
|
isMultipart: boolean;
|
||||||
|
partCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processOnePackage(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
client: any,
|
||||||
|
pkg: BackfillPackage,
|
||||||
|
ctx: { packageId: string; fileName: string }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!pkg.destChannelId || !pkg.destMessageId) {
|
||||||
|
log.debug(ctx, "Skipping: no destination channel/message");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the destination channel's Telegram ID
|
||||||
|
const destChannel = await db.telegramChannel.findUnique({
|
||||||
|
where: { id: pkg.destChannelId },
|
||||||
|
select: { telegramId: true },
|
||||||
|
});
|
||||||
|
if (!destChannel) {
|
||||||
|
throw new Error("Destination channel not found in DB");
|
||||||
|
}
|
||||||
|
const chatId = Number(destChannel.telegramId);
|
||||||
|
|
||||||
|
// Resolve which message IDs to download. The Package may carry a
|
||||||
|
// single destMessageId or multiple destMessageIds (for multipart).
|
||||||
|
const messageIds: bigint[] =
|
||||||
|
pkg.destMessageIds.length > 0
|
||||||
|
? pkg.destMessageIds
|
||||||
|
: pkg.destMessageId
|
||||||
|
? [pkg.destMessageId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (messageIds.length === 0) {
|
||||||
|
throw new Error("Package has no destination message IDs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = path.join(config.tempDir, `backfill_${pkg.id}`);
|
||||||
|
await mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partPaths: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < messageIds.length; i++) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const message = (await client.invoke({
|
||||||
|
_: "getMessage",
|
||||||
|
chat_id: chatId,
|
||||||
|
message_id: Number(messageIds[i]),
|
||||||
|
})) as unknown as {
|
||||||
|
content?: { document?: { file_name?: string; document?: { id: number; size: number } } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const doc = message?.content?.document;
|
||||||
|
if (!doc?.document?.id) {
|
||||||
|
throw new Error(`Destination message ${messageIds[i]} has no document`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = String(doc.document.id);
|
||||||
|
const fileName = doc.file_name ?? `${pkg.id}.part${i + 1}`;
|
||||||
|
const localPath = path.join(tempDir, fileName);
|
||||||
|
|
||||||
|
await downloadFile(
|
||||||
|
client,
|
||||||
|
fileId,
|
||||||
|
localPath,
|
||||||
|
BigInt(doc.document.size),
|
||||||
|
fileName
|
||||||
|
);
|
||||||
|
|
||||||
|
partPaths.push(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the appropriate reader on the assembled file(s)
|
||||||
|
let entries: FileEntry[] = [];
|
||||||
|
if (pkg.archiveType === "ZIP") {
|
||||||
|
entries = await readZipCentralDirectory(partPaths);
|
||||||
|
} else if (pkg.archiveType === "RAR") {
|
||||||
|
// unrar auto-discovers sibling parts when in the same directory
|
||||||
|
entries = await readRarContents(partPaths[0]);
|
||||||
|
} else if (pkg.archiveType === "SEVEN_Z") {
|
||||||
|
entries = await read7zContents(partPaths[0]);
|
||||||
|
} else {
|
||||||
|
log.debug({ ...ctx, archiveType: pkg.archiveType }, "Skipping unsupported archive type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
log.warn(ctx, "Reader returned 0 entries — archive may be encrypted or corrupt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also derive slicer tags from the file list so the backfilled packages
|
||||||
|
// gain the same search/filter context as newly-ingested ones.
|
||||||
|
const slicerTags = extractSlicerTags(entries);
|
||||||
|
|
||||||
|
// Write everything in a single transaction so a partial backfill never
|
||||||
|
// leaves the Package half-indexed.
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
// Re-check fileCount inside the transaction: another worker might
|
||||||
|
// have backfilled this package between our read and write.
|
||||||
|
const current = await tx.package.findUnique({
|
||||||
|
where: { id: pkg.id },
|
||||||
|
select: { fileCount: true, tags: true },
|
||||||
|
});
|
||||||
|
if (current && current.fileCount > 0) {
|
||||||
|
log.debug({ ...ctx, existingFileCount: current.fileCount }, "Already backfilled by another worker — skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.packageFile.deleteMany({ where: { packageId: pkg.id } });
|
||||||
|
await tx.packageFile.createMany({
|
||||||
|
data: entries.map((e) => ({
|
||||||
|
packageId: pkg.id,
|
||||||
|
path: e.path,
|
||||||
|
fileName: e.fileName,
|
||||||
|
extension: e.extension,
|
||||||
|
compressedSize: e.compressedSize,
|
||||||
|
uncompressedSize: e.uncompressedSize,
|
||||||
|
crc32: e.crc32,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge slicer tags with whatever's already on the Package (preserve
|
||||||
|
// channel category, manual tags, etc.).
|
||||||
|
const existingTags = current?.tags ?? [];
|
||||||
|
const mergedTags = [...new Set([...existingTags, ...slicerTags])];
|
||||||
|
|
||||||
|
await tx.package.update({
|
||||||
|
where: { id: pkg.id },
|
||||||
|
data: { fileCount: entries.length, tags: mergedTags },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info({ ...ctx, fileCount: entries.length }, "Backfilled file list");
|
||||||
|
} finally {
|
||||||
|
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap pure-DB backfill: walk Packages that already have PackageFile rows
|
||||||
|
* but no slicer tags, recompute the tags from their extensions, and merge
|
||||||
|
* with the existing tag list. No downloads, no TDLib.
|
||||||
|
*
|
||||||
|
* Trigger:
|
||||||
|
* SELECT pg_notify('backfill_slicer_tags', '{"limit":1000}');
|
||||||
|
*/
|
||||||
|
export async function processSlicerTagBackfill(payloadJson: string): Promise<void> {
|
||||||
|
let limit = 1000;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payloadJson) as { limit?: number };
|
||||||
|
if (typeof parsed.limit === "number" && parsed.limit > 0) limit = parsed.limit;
|
||||||
|
} catch {
|
||||||
|
// Use default
|
||||||
|
}
|
||||||
|
|
||||||
|
// KNOWN_TAGS = the slicer tags we know how to derive. A Package missing
|
||||||
|
// all of these is a candidate for recompute. extractSlicerTags is safe
|
||||||
|
// to run on every package (returns [] for archives with no slicer files),
|
||||||
|
// but filtering up-front avoids walking the entire DB.
|
||||||
|
const KNOWN_TAGS = ["lychee", "chitubox", "anycubic", "bambu", "fdm", "mango"];
|
||||||
|
|
||||||
|
const candidates = await db.package.findMany({
|
||||||
|
where: {
|
||||||
|
fileCount: { gt: 0 },
|
||||||
|
NOT: { tags: { hasSome: KNOWN_TAGS } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
tags: true,
|
||||||
|
files: { select: { extension: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
log.info("Slicer tag backfill: no candidates");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ count: candidates.length }, "Slicer tag backfill: starting");
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
for (const pkg of candidates) {
|
||||||
|
const fileEntries = pkg.files.map((f) => ({
|
||||||
|
path: "",
|
||||||
|
fileName: "",
|
||||||
|
extension: f.extension,
|
||||||
|
compressedSize: 0n,
|
||||||
|
uncompressedSize: 0n,
|
||||||
|
crc32: null as string | null,
|
||||||
|
}));
|
||||||
|
const slicerTags = extractSlicerTags(fileEntries);
|
||||||
|
if (slicerTags.length === 0) continue;
|
||||||
|
const merged = [...new Set([...pkg.tags, ...slicerTags])];
|
||||||
|
if (merged.length === pkg.tags.length) continue;
|
||||||
|
await db.package.update({ where: { id: pkg.id }, data: { tags: merged } });
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ candidates: candidates.length, updated }, "Slicer tag backfill: done");
|
||||||
|
}
|
||||||
@@ -82,6 +82,8 @@ export interface CreatePackageStubInput {
|
|||||||
sourceChannelId: string;
|
sourceChannelId: string;
|
||||||
sourceMessageId: bigint;
|
sourceMessageId: bigint;
|
||||||
sourceTopicId?: bigint | null;
|
sourceTopicId?: bigint | null;
|
||||||
|
/** TDLib remote.unique_id of the first part — for future dedup. */
|
||||||
|
remoteUniqueId?: string | null;
|
||||||
destChannelId: string;
|
destChannelId: string;
|
||||||
destMessageId: bigint;
|
destMessageId: bigint;
|
||||||
destMessageIds: bigint[];
|
destMessageIds: bigint[];
|
||||||
@@ -111,6 +113,7 @@ export async function createPackageStub(
|
|||||||
sourceChannelId: input.sourceChannelId,
|
sourceChannelId: input.sourceChannelId,
|
||||||
sourceMessageId: input.sourceMessageId,
|
sourceMessageId: input.sourceMessageId,
|
||||||
sourceTopicId: input.sourceTopicId ?? undefined,
|
sourceTopicId: input.sourceTopicId ?? undefined,
|
||||||
|
remoteUniqueId: input.remoteUniqueId ?? undefined,
|
||||||
destChannelId: input.destChannelId,
|
destChannelId: input.destChannelId,
|
||||||
destMessageId: input.destMessageId,
|
destMessageId: input.destMessageId,
|
||||||
destMessageIds: input.destMessageIds,
|
destMessageIds: input.destMessageIds,
|
||||||
@@ -189,6 +192,73 @@ export async function packageExistsBySourceMessage(
|
|||||||
return pkg !== null;
|
return pkg !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strongest pre-download dedup signal: a Package in this channel already
|
||||||
|
* has a matching TDLib remote.unique_id. The unique_id is stable across
|
||||||
|
* reposts of the exact same file content, so a hit is a guaranteed
|
||||||
|
* (lossless) duplicate. No false positives.
|
||||||
|
*
|
||||||
|
* Falls back to the older findRepostedPackage (name + size) for packages
|
||||||
|
* that were ingested before we started capturing remote.unique_id.
|
||||||
|
*/
|
||||||
|
export async function findPackageByRemoteUniqueId(
|
||||||
|
sourceChannelId: string,
|
||||||
|
remoteUniqueId: string
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
destMessageId: bigint | null;
|
||||||
|
sourceTopicId: bigint | null;
|
||||||
|
} | null> {
|
||||||
|
return db.package.findFirst({
|
||||||
|
where: {
|
||||||
|
sourceChannelId,
|
||||||
|
remoteUniqueId,
|
||||||
|
destMessageId: { not: null },
|
||||||
|
},
|
||||||
|
orderBy: { sourceTopicId: { sort: "desc", nulls: "last" } },
|
||||||
|
select: { id: true, destMessageId: true, sourceTopicId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a likely repost: same source channel + same fileName + same total
|
||||||
|
* fileSize already exists with destMessageId set. Used to skip downloads
|
||||||
|
* when the channel admin re-posts the same file under a new message ID
|
||||||
|
* (which `packageExistsBySourceMessage` cannot catch because the message ID
|
||||||
|
* is different).
|
||||||
|
*
|
||||||
|
* Returns the existing package's destMessageId for logging/observability,
|
||||||
|
* or null if no match. Approximate: same name + same total size is an
|
||||||
|
* extremely strong signal that it's the same content, but theoretically
|
||||||
|
* two unrelated files could collide. If that ever happens, the new file
|
||||||
|
* gets treated as a duplicate and is lost; the user can manually re-link
|
||||||
|
* via the UI by removing the existing Package.
|
||||||
|
*/
|
||||||
|
export async function findRepostedPackage(
|
||||||
|
sourceChannelId: string,
|
||||||
|
fileName: string,
|
||||||
|
fileSize: bigint
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
destMessageId: bigint | null;
|
||||||
|
sourceTopicId: bigint | null;
|
||||||
|
} | null> {
|
||||||
|
return db.package.findFirst({
|
||||||
|
where: {
|
||||||
|
sourceChannelId,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
destMessageId: { not: null },
|
||||||
|
},
|
||||||
|
// Prefer the existing Package with the most specific (non-NULL)
|
||||||
|
// sourceTopicId, so when the user is re-scanning and the file already
|
||||||
|
// exists in a specific topic, the audit log shows the most informative
|
||||||
|
// match. NULLS LAST in DESC order achieves that for any non-null IDs.
|
||||||
|
orderBy: { sourceTopicId: { sort: "desc", nulls: "last" } },
|
||||||
|
select: { id: true, destMessageId: true, sourceTopicId: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete orphaned Package rows that have the same content hash but never
|
* Delete orphaned Package rows that have the same content hash but never
|
||||||
* completed the upload (destMessageId is null). Called before creating a
|
* completed the upload (destMessageId is null). Called before creating a
|
||||||
@@ -299,6 +369,8 @@ export interface ActivityUpdate {
|
|||||||
downloadedBytes?: bigint | null;
|
downloadedBytes?: bigint | null;
|
||||||
totalBytes?: bigint | null;
|
totalBytes?: bigint | null;
|
||||||
downloadPercent?: number | null;
|
downloadPercent?: number | null;
|
||||||
|
currentTopicId?: bigint | null;
|
||||||
|
currentAccountChannelMapId?: string | null;
|
||||||
messagesScanned?: number;
|
messagesScanned?: number;
|
||||||
zipsFound?: number;
|
zipsFound?: number;
|
||||||
zipsDuplicate?: number;
|
zipsDuplicate?: number;
|
||||||
@@ -326,6 +398,10 @@ export async function updateRunActivity(
|
|||||||
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
|
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
|
||||||
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
|
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
|
||||||
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
|
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
|
||||||
|
...(activity.currentTopicId !== undefined && { currentTopicId: activity.currentTopicId }),
|
||||||
|
...(activity.currentAccountChannelMapId !== undefined && {
|
||||||
|
currentAccountChannelMapId: activity.currentAccountChannelMapId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -340,6 +416,8 @@ const CLEAR_ACTIVITY = {
|
|||||||
downloadedBytes: null,
|
downloadedBytes: null,
|
||||||
totalBytes: null,
|
totalBytes: null,
|
||||||
downloadPercent: null,
|
downloadPercent: null,
|
||||||
|
currentTopicId: null,
|
||||||
|
currentAccountChannelMapId: null,
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -385,6 +463,73 @@ export async function updateLastProcessedMessage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScanStateUpdate {
|
||||||
|
/** New watermark to persist. Use the same value the caller would have
|
||||||
|
* passed to updateLastProcessedMessage / upsertTopicProgress. */
|
||||||
|
lastProcessedMessageId: bigint | null;
|
||||||
|
/** True if the scan found archives OR has retryable SkippedPackages
|
||||||
|
* pending. The caller computes this via the trulyIdle formula. */
|
||||||
|
lastScanFoundArchives: boolean;
|
||||||
|
/** Pre-incremented value of consecutiveEmptyScans. Caller passes:
|
||||||
|
* trulyIdle ? prev + 1 : 0
|
||||||
|
* We do the arithmetic outside the helper so the helper stays a pure
|
||||||
|
* setter — easier to reason about. */
|
||||||
|
consecutiveEmptyScans: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically update an AccountChannelMap's watermark and scan-state fields.
|
||||||
|
* Replaces the older updateLastProcessedMessage for the post-scan write.
|
||||||
|
* Sets lastScannedAt = NOW() server-side.
|
||||||
|
*/
|
||||||
|
export async function upsertChannelScanState(
|
||||||
|
mappingId: string,
|
||||||
|
update: ScanStateUpdate
|
||||||
|
) {
|
||||||
|
return db.accountChannelMap.update({
|
||||||
|
where: { id: mappingId },
|
||||||
|
data: {
|
||||||
|
lastProcessedMessageId: update.lastProcessedMessageId ?? undefined,
|
||||||
|
lastScannedAt: new Date(),
|
||||||
|
lastScanFoundArchives: update.lastScanFoundArchives,
|
||||||
|
consecutiveEmptyScans: update.consecutiveEmptyScans,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically upsert a TopicProgress row with the new watermark + scan-state
|
||||||
|
* fields. Same semantics as upsertChannelScanState but for forum topics.
|
||||||
|
*/
|
||||||
|
export async function upsertTopicScanState(
|
||||||
|
accountChannelMapId: string,
|
||||||
|
topicId: bigint,
|
||||||
|
topicName: string | null,
|
||||||
|
update: ScanStateUpdate
|
||||||
|
) {
|
||||||
|
return db.topicProgress.upsert({
|
||||||
|
where: {
|
||||||
|
accountChannelMapId_topicId: { accountChannelMapId, topicId },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
accountChannelMapId,
|
||||||
|
topicId,
|
||||||
|
topicName,
|
||||||
|
lastProcessedMessageId: update.lastProcessedMessageId,
|
||||||
|
lastScannedAt: new Date(),
|
||||||
|
lastScanFoundArchives: update.lastScanFoundArchives,
|
||||||
|
consecutiveEmptyScans: update.consecutiveEmptyScans,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
topicName,
|
||||||
|
lastProcessedMessageId: update.lastProcessedMessageId ?? undefined,
|
||||||
|
lastScannedAt: new Date(),
|
||||||
|
lastScanFoundArchives: update.lastScanFoundArchives,
|
||||||
|
consecutiveEmptyScans: update.consecutiveEmptyScans,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function markStaleRunsAsFailed() {
|
export async function markStaleRunsAsFailed() {
|
||||||
return db.ingestionRun.updateMany({
|
return db.ingestionRun.updateMany({
|
||||||
where: { status: "RUNNING" },
|
where: { status: "RUNNING" },
|
||||||
@@ -522,6 +667,45 @@ export async function upsertTopicProgress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a TopicProgress row exists for every discovered topic so the admin
|
||||||
|
* UI can list and toggle them — including brand-new topics. Inserts missing
|
||||||
|
* rows only (fetchEnabled defaults to true); never overwrites watermarks,
|
||||||
|
* scan-state, or the user's fetchEnabled choice on rows that already exist.
|
||||||
|
*/
|
||||||
|
export async function ensureTopicProgressRows(
|
||||||
|
mappingId: string,
|
||||||
|
topics: { topicId: bigint; name: string | null }[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (topics.length === 0) return;
|
||||||
|
await db.topicProgress.createMany({
|
||||||
|
data: topics.map((t) => ({
|
||||||
|
accountChannelMapId: mappingId,
|
||||||
|
topicId: t.topicId,
|
||||||
|
topicName: t.name,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the CURRENT per-topic fetch flag straight from the DB. Returns true
|
||||||
|
* when no row exists yet (default = enabled). Called fresh inside the topic
|
||||||
|
* loop so a mid-run toggle is honoured for topics not yet started.
|
||||||
|
*/
|
||||||
|
export async function isTopicFetchEnabled(
|
||||||
|
mappingId: string,
|
||||||
|
topicId: bigint
|
||||||
|
): Promise<boolean> {
|
||||||
|
const row = await db.topicProgress.findUnique({
|
||||||
|
where: {
|
||||||
|
accountChannelMapId_topicId: { accountChannelMapId: mappingId, topicId },
|
||||||
|
},
|
||||||
|
select: { fetchEnabled: true },
|
||||||
|
});
|
||||||
|
return row?.fetchEnabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Channel fetch requests (DB-mediated communication with web app) ──
|
// ── Channel fetch requests (DB-mediated communication with web app) ──
|
||||||
|
|
||||||
export async function getChannelFetchRequest(requestId: string) {
|
export async function getChannelFetchRequest(requestId: string) {
|
||||||
@@ -625,6 +809,7 @@ export async function upsertSkippedPackage(data: {
|
|||||||
errorMessage: data.errorMessage ?? null,
|
errorMessage: data.errorMessage ?? null,
|
||||||
fileName: data.fileName,
|
fileName: data.fileName,
|
||||||
fileSize: data.fileSize,
|
fileSize: data.fileSize,
|
||||||
|
attemptCount: { increment: 1 },
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
@@ -642,6 +827,26 @@ export async function upsertSkippedPackage(data: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return source-message IDs in a channel whose SkippedPackage attemptCount has
|
||||||
|
* reached or exceeded the cap — these are treated as "permanently failed for
|
||||||
|
* now" so the watermark can advance past them. The user can manually retry via
|
||||||
|
* the UI to reset the SkippedPackage record.
|
||||||
|
*/
|
||||||
|
export async function getCappedSkippedMessageIds(
|
||||||
|
sourceChannelId: string,
|
||||||
|
cap: number
|
||||||
|
): Promise<Set<bigint>> {
|
||||||
|
const rows = await db.skippedPackage.findMany({
|
||||||
|
where: {
|
||||||
|
sourceChannelId,
|
||||||
|
attemptCount: { gte: cap },
|
||||||
|
},
|
||||||
|
select: { sourceMessageId: true },
|
||||||
|
});
|
||||||
|
return new Set(rows.map((r) => r.sourceMessageId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteSkippedPackage(
|
export async function deleteSkippedPackage(
|
||||||
sourceChannelId: string,
|
sourceChannelId: string,
|
||||||
sourceMessageId: bigint
|
sourceMessageId: bigint
|
||||||
@@ -651,6 +856,63 @@ export async function deleteSkippedPackage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find SkippedPackages for a given account+channel that are still eligible
|
||||||
|
* for auto-retry (attemptCount below the cap). Used at the start of a scan
|
||||||
|
* to pull the watermark back so we don't strand failed messages forever
|
||||||
|
* after the watermark has advanced past them.
|
||||||
|
*
|
||||||
|
* For non-forum channels, pass `topicId: null` to get rows with NULL topic.
|
||||||
|
* For forum channels, pass the topic ID to scope to that topic only.
|
||||||
|
*/
|
||||||
|
export async function getRetryableSkippedMessageIds(args: {
|
||||||
|
accountId: string;
|
||||||
|
sourceChannelId: string;
|
||||||
|
topicId: bigint | null;
|
||||||
|
cap: number;
|
||||||
|
}): Promise<bigint[]> {
|
||||||
|
const rows = await db.skippedPackage.findMany({
|
||||||
|
where: {
|
||||||
|
accountId: args.accountId,
|
||||||
|
sourceChannelId: args.sourceChannelId,
|
||||||
|
sourceTopicId: args.topicId,
|
||||||
|
attemptCount: { lt: args.cap },
|
||||||
|
},
|
||||||
|
select: { sourceMessageId: true },
|
||||||
|
orderBy: { sourceMessageId: "asc" },
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.sourceMessageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a Package's source topic when a more specific topic context is
|
||||||
|
* discovered for the same content. Used when findRepostedPackage matches
|
||||||
|
* an existing Package whose topic is less specific (e.g., "General") than
|
||||||
|
* the topic we just encountered the file in.
|
||||||
|
*
|
||||||
|
* Also updates the creator if the new topic name is more informative than
|
||||||
|
* the existing creator (i.e., the existing creator was derived from a
|
||||||
|
* less-specific topic name like "General").
|
||||||
|
*/
|
||||||
|
export async function updatePackageTopicContext(
|
||||||
|
packageId: string,
|
||||||
|
newTopicId: bigint,
|
||||||
|
newTopicName: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
await db.package.update({
|
||||||
|
where: { id: packageId },
|
||||||
|
data: {
|
||||||
|
sourceTopicId: newTopicId,
|
||||||
|
// Only overwrite creator if the new topic name is meaningful (non-empty,
|
||||||
|
// non-General). Keeps explicit creator values from filename or admin
|
||||||
|
// input intact.
|
||||||
|
...(newTopicName && newTopicName !== "General"
|
||||||
|
? { creator: newTopicName }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createOrFindPackageGroup(input: {
|
export async function createOrFindPackageGroup(input: {
|
||||||
mediaAlbumId: string;
|
mediaAlbumId: string;
|
||||||
sourceChannelId: string;
|
sourceChannelId: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { processFetchRequest } from "./worker.js";
|
|||||||
import { processExtractRequest } from "./extract-listener.js";
|
import { processExtractRequest } from "./extract-listener.js";
|
||||||
import { rebuildPackageDatabase } from "./rebuild.js";
|
import { rebuildPackageDatabase } from "./rebuild.js";
|
||||||
import { processManualUpload } from "./manual-upload.js";
|
import { processManualUpload } from "./manual-upload.js";
|
||||||
|
import { processBackfillRequest, processSlicerTagBackfill } from "./backfill.js";
|
||||||
import { generateInviteLink, createSupergroup, searchPublicChat } from "./tdlib/chats.js";
|
import { generateInviteLink, createSupergroup, searchPublicChat } from "./tdlib/chats.js";
|
||||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||||
import { triggerImmediateCycle } from "./scheduler.js";
|
import { triggerImmediateCycle } from "./scheduler.js";
|
||||||
@@ -58,6 +59,8 @@ async function connectListener(): Promise<void> {
|
|||||||
await pgClient.query("LISTEN archive_extract");
|
await pgClient.query("LISTEN archive_extract");
|
||||||
await pgClient.query("LISTEN rebuild_packages");
|
await pgClient.query("LISTEN rebuild_packages");
|
||||||
await pgClient.query("LISTEN manual_upload");
|
await pgClient.query("LISTEN manual_upload");
|
||||||
|
await pgClient.query("LISTEN backfill_filelists");
|
||||||
|
await pgClient.query("LISTEN backfill_slicer_tags");
|
||||||
|
|
||||||
pgClient.on("notification", (msg) => {
|
pgClient.on("notification", (msg) => {
|
||||||
if (msg.channel === "channel_fetch" && msg.payload) {
|
if (msg.channel === "channel_fetch" && msg.payload) {
|
||||||
@@ -76,6 +79,10 @@ async function connectListener(): Promise<void> {
|
|||||||
handleRebuildPackages(msg.payload);
|
handleRebuildPackages(msg.payload);
|
||||||
} else if (msg.channel === "manual_upload" && msg.payload) {
|
} else if (msg.channel === "manual_upload" && msg.payload) {
|
||||||
handleManualUpload(msg.payload);
|
handleManualUpload(msg.payload);
|
||||||
|
} else if (msg.channel === "backfill_filelists") {
|
||||||
|
handleBackfillFilelists(msg.payload ?? "{}");
|
||||||
|
} else if (msg.channel === "backfill_slicer_tags") {
|
||||||
|
handleBackfillSlicerTags(msg.payload ?? "{}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +108,7 @@ async function connectListener(): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("Fetch listener started (channel_fetch, generate_invite, create_destination, ingestion_trigger, join_channel, archive_extract, rebuild_packages, manual_upload)");
|
log.info("Fetch listener started (channel_fetch, generate_invite, create_destination, ingestion_trigger, join_channel, archive_extract, rebuild_packages, manual_upload, backfill_filelists, backfill_slicer_tags)");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err }, "Failed to start fetch listener — retrying");
|
log.error({ err }, "Failed to start fetch listener — retrying");
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
@@ -528,3 +535,30 @@ function handleManualUpload(uploadId: string): void {
|
|||||||
.then(() => processManualUpload(uploadId))
|
.then(() => processManualUpload(uploadId))
|
||||||
.catch((err) => log.error({ err, uploadId }, "Manual upload processing failed"));
|
.catch((err) => log.error({ err, uploadId }, "Manual upload processing failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Backfill file-list handler ──
|
||||||
|
//
|
||||||
|
// Trigger via:
|
||||||
|
// SELECT pg_notify('backfill_filelists', '{"limit":50,"archiveType":"RAR"}');
|
||||||
|
//
|
||||||
|
// Both fields are optional. archiveType filters to one of ZIP/RAR/SEVEN_Z.
|
||||||
|
// Default limit is 100. The handler queues so multiple notifications run
|
||||||
|
// sequentially (no concurrent TDLib downloads competing for the mutex).
|
||||||
|
function handleBackfillFilelists(payload: string): void {
|
||||||
|
fetchQueue = fetchQueue
|
||||||
|
.then(() => processBackfillRequest(payload))
|
||||||
|
.catch((err) => log.error({ err, payload }, "Backfill request failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slicer tag backfill handler ──
|
||||||
|
//
|
||||||
|
// Trigger:
|
||||||
|
// SELECT pg_notify('backfill_slicer_tags', '{"limit":1000}');
|
||||||
|
//
|
||||||
|
// Pure-DB pass over Packages that have file lists but no slicer tags.
|
||||||
|
// No downloads, no TDLib involvement — fast and safe.
|
||||||
|
function handleBackfillSlicerTags(payload: string): void {
|
||||||
|
fetchQueue = fetchQueue
|
||||||
|
.then(() => processSlicerTagBackfill(payload))
|
||||||
|
.catch((err) => log.error({ err, payload }, "Slicer tag backfill failed"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -308,14 +308,15 @@ async function scanDestinationChannel(
|
|||||||
}>(client, {
|
}>(client, {
|
||||||
_: "searchChatMessages",
|
_: "searchChatMessages",
|
||||||
chat_id: Number(chatId),
|
chat_id: Number(chatId),
|
||||||
|
// No topic context for a flat destination scan. TDLib 1.8.64+ replaced
|
||||||
|
// `message_thread_id` / `saved_messages_topic_id` with a single
|
||||||
|
// optional `topic_id`; for a flat scan we just omit it.
|
||||||
query: "",
|
query: "",
|
||||||
from_message_id: currentFromId,
|
from_message_id: currentFromId,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
filter: { _: "searchMessagesFilterDocument" },
|
filter: { _: "searchMessagesFilterDocument" },
|
||||||
sender_id: null,
|
sender_id: null,
|
||||||
message_thread_id: 0,
|
|
||||||
saved_messages_topic_id: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.messages || result.messages.length === 0) break;
|
if (!result.messages || result.messages.length === 0) break;
|
||||||
|
|||||||
@@ -78,18 +78,31 @@ export async function recoverIncompleteUploads(): Promise<void> {
|
|||||||
|
|
||||||
let resetCount = 0;
|
let resetCount = 0;
|
||||||
let verifiedCount = 0;
|
let verifiedCount = 0;
|
||||||
|
let unknownCount = 0;
|
||||||
|
let wrongContentCount = 0;
|
||||||
|
|
||||||
|
// Batch size for getMessages. TDLib accepts up to ~100 IDs per call.
|
||||||
|
// Using 100 means 20k packages → ~200 round-trips instead of 20k.
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
for (const [, channelPackages] of byChannel) {
|
for (const [, channelPackages] of byChannel) {
|
||||||
for (const pkg of channelPackages) {
|
// Group packages by destChannelId (already done) — within each group,
|
||||||
const exists = await verifyMessageExists(
|
// process in batches via getMessages (plural).
|
||||||
|
for (let i = 0; i < channelPackages.length; i += BATCH_SIZE) {
|
||||||
|
const batch = channelPackages.slice(i, i + BATCH_SIZE);
|
||||||
|
const batchResults = await verifyMessagesBatch(
|
||||||
client,
|
client,
|
||||||
destChannel.telegramId,
|
destChannel.telegramId,
|
||||||
pkg.destMessageId!
|
batch.map((p) => p.destMessageId!)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (exists) {
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const pkg = batch[j];
|
||||||
|
const result = batchResults[j];
|
||||||
|
|
||||||
|
if (result.state === "exists") {
|
||||||
verifiedCount++;
|
verifiedCount++;
|
||||||
} else {
|
} else if (result.state === "deleted") {
|
||||||
log.warn(
|
log.warn(
|
||||||
{
|
{
|
||||||
packageId: pkg.id,
|
packageId: pkg.id,
|
||||||
@@ -100,21 +113,50 @@ export async function recoverIncompleteUploads(): Promise<void> {
|
|||||||
);
|
);
|
||||||
await resetPackageDestination(pkg.id);
|
await resetPackageDestination(pkg.id);
|
||||||
resetCount++;
|
resetCount++;
|
||||||
|
} else if (result.state === "wrong-content") {
|
||||||
|
// The message exists but isn't a document anymore (got cleared /
|
||||||
|
// replaced). Treat as missing so we re-upload.
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
packageId: pkg.id,
|
||||||
|
fileName: pkg.fileName,
|
||||||
|
destMessageId: Number(pkg.destMessageId),
|
||||||
|
contentType: result.contentType,
|
||||||
|
},
|
||||||
|
"Destination message is not a document, resetting package for re-upload"
|
||||||
|
);
|
||||||
|
await resetPackageDestination(pkg.id);
|
||||||
|
wrongContentCount++;
|
||||||
|
} else {
|
||||||
|
// Unknown — TDLib couldn't tell us. Don't reset, but DO count this
|
||||||
|
// so the summary line shows recovery wasn't 100% successful.
|
||||||
|
unknownCount++;
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
packageId: pkg.id,
|
||||||
|
fileName: pkg.fileName,
|
||||||
|
destMessageId: Number(pkg.destMessageId),
|
||||||
|
reason: result.reason.slice(0, 200),
|
||||||
|
},
|
||||||
|
"Could not verify destination message — will retry on next startup"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resetCount > 0) {
|
|
||||||
log.info(
|
log.info(
|
||||||
{ resetCount, verifiedCount, totalChecked: packages.length },
|
{
|
||||||
"Upload recovery complete — packages reset for re-processing"
|
verifiedCount,
|
||||||
|
resetCount,
|
||||||
|
wrongContentCount,
|
||||||
|
unknownCount,
|
||||||
|
totalChecked: packages.length,
|
||||||
|
},
|
||||||
|
unknownCount === 0
|
||||||
|
? "Upload recovery complete"
|
||||||
|
: "Upload recovery complete — some packages could not be verified, will retry next startup"
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
log.info(
|
|
||||||
{ verifiedCount, totalChecked: packages.length },
|
|
||||||
"Upload recovery complete — all destination messages verified"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err }, "Upload recovery failed (non-fatal, will retry next startup)");
|
log.error({ err }, "Upload recovery failed (non-fatal, will retry next startup)");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -124,15 +166,77 @@ export async function recoverIncompleteUploads(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VerifyResult =
|
||||||
|
| { state: "exists" }
|
||||||
|
| { state: "deleted" }
|
||||||
|
| { state: "wrong-content"; contentType: string }
|
||||||
|
| { state: "unknown"; reason: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a message exists in a Telegram chat.
|
* Batch version of verifyMessageExists. Calls TDLib's getMessages (plural)
|
||||||
* Returns false if the message was deleted or never existed.
|
* with up to ~100 message IDs at once. Returns one VerifyResult per input
|
||||||
|
* ID, in input order. Missing messages come back as null in TDLib's response
|
||||||
|
* — translated to {state: "deleted"} here.
|
||||||
|
*
|
||||||
|
* Falls back to per-message verification on any error so that one bad batch
|
||||||
|
* doesn't lose all verification for that chunk.
|
||||||
|
*/
|
||||||
|
async function verifyMessagesBatch(
|
||||||
|
client: Client,
|
||||||
|
chatTelegramId: bigint,
|
||||||
|
messageIds: bigint[]
|
||||||
|
): Promise<VerifyResult[]> {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = (await withFloodWait(
|
||||||
|
() =>
|
||||||
|
client.invoke({
|
||||||
|
_: "getMessages",
|
||||||
|
chat_id: Number(chatTelegramId),
|
||||||
|
message_ids: messageIds.map((id) => Number(id)),
|
||||||
|
}),
|
||||||
|
"getMessages:verify"
|
||||||
|
)) as { messages?: (null | { content?: { _: string } })[] };
|
||||||
|
|
||||||
|
const messages = result.messages ?? [];
|
||||||
|
return messageIds.map((_id, i) => {
|
||||||
|
const m = messages[i];
|
||||||
|
if (!m || !m.content) return { state: "deleted" };
|
||||||
|
if (m.content._ !== "messageDocument") {
|
||||||
|
return { state: "wrong-content", contentType: String(m.content._) };
|
||||||
|
}
|
||||||
|
return { state: "exists" };
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// If the whole batch errors out, fall back to per-message verification.
|
||||||
|
log.warn(
|
||||||
|
{ err, batchSize: messageIds.length, chatTelegramId: chatTelegramId.toString() },
|
||||||
|
"getMessages batch failed, falling back to per-message verification"
|
||||||
|
);
|
||||||
|
const out: VerifyResult[] = [];
|
||||||
|
for (const id of messageIds) {
|
||||||
|
out.push(await verifyMessageExists(client, chatTelegramId, id));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a message exists in a Telegram chat and is the document we
|
||||||
|
* uploaded. Returns a discriminated result instead of a bare boolean so the
|
||||||
|
* caller can distinguish "definitely gone" (reset) from "couldn't reach TG"
|
||||||
|
* (leave alone, try again next startup).
|
||||||
|
*
|
||||||
|
* Previous version conflated all non-404 errors with "exists", which masked
|
||||||
|
* recovery completely when TDLib had a degraded connection — the worker
|
||||||
|
* would log "all destination messages verified" even though it had answered
|
||||||
|
* questions it couldn't actually answer.
|
||||||
*/
|
*/
|
||||||
async function verifyMessageExists(
|
async function verifyMessageExists(
|
||||||
client: Client,
|
client: Client,
|
||||||
chatTelegramId: bigint,
|
chatTelegramId: bigint,
|
||||||
messageId: bigint
|
messageId: bigint
|
||||||
): Promise<boolean> {
|
): Promise<VerifyResult> {
|
||||||
try {
|
try {
|
||||||
const result = await withFloodWait(
|
const result = await withFloodWait(
|
||||||
() =>
|
() =>
|
||||||
@@ -144,44 +248,37 @@ async function verifyMessageExists(
|
|||||||
"getMessage:verify"
|
"getMessage:verify"
|
||||||
);
|
);
|
||||||
|
|
||||||
// TDLib returns the message object if it exists.
|
|
||||||
// A deleted message may return with content type "messageChatDeleteMessage"
|
|
||||||
// or the call may throw. Check that we got a real message with content.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const msg = result as any;
|
const msg = result as any;
|
||||||
if (!msg || !msg.content) {
|
if (!msg || !msg.content) {
|
||||||
return false;
|
return { state: "deleted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that the message has document content (our uploads are documents)
|
|
||||||
// A message that exists but has no document content was likely cleared/replaced
|
|
||||||
if (msg.content._ !== "messageDocument") {
|
if (msg.content._ !== "messageDocument") {
|
||||||
log.debug(
|
return { state: "wrong-content", contentType: String(msg.content._) };
|
||||||
{
|
|
||||||
messageId: Number(messageId),
|
|
||||||
contentType: msg.content._,
|
|
||||||
},
|
|
||||||
"Destination message exists but is not a document"
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return { state: "exists" };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// TDLib throws "Message not found" (error code 404) for deleted messages
|
const errMessage = err instanceof Error ? err.message : String(err);
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
const code = (err as { code?: number })?.code;
|
const code = (err as { code?: number })?.code;
|
||||||
|
|
||||||
if (code === 404 || message.includes("not found") || message.includes("Not Found")) {
|
// Hard "the message is definitely gone" signals from TDLib:
|
||||||
return false;
|
// - HTTP 404
|
||||||
|
// - "Message not found" / "MESSAGE_ID_INVALID" error strings
|
||||||
|
const lower = errMessage.toLowerCase();
|
||||||
|
if (
|
||||||
|
code === 404 ||
|
||||||
|
lower.includes("message not found") ||
|
||||||
|
lower.includes("message_id_invalid") ||
|
||||||
|
lower.includes("messageidinvalid") ||
|
||||||
|
lower.includes("not found")
|
||||||
|
) {
|
||||||
|
return { state: "deleted" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other errors (network issues, etc.), assume the message exists
|
// Everything else (network, connection, TDLib internal) is genuinely
|
||||||
// to avoid incorrectly resetting packages due to transient failures
|
// unknown — do NOT claim "verified".
|
||||||
log.warn(
|
return { state: "unknown", reason: errMessage };
|
||||||
{ err, messageId: Number(messageId) },
|
|
||||||
"Could not verify message (assuming it exists)"
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ let activeCyclePromise: Promise<void> | null = null;
|
|||||||
*/
|
*/
|
||||||
const CYCLE_TIMEOUT_MS = (parseInt(process.env.WORKER_CYCLE_TIMEOUT_MINUTES ?? "240", 10)) * 60 * 1000;
|
const CYCLE_TIMEOUT_MS = (parseInt(process.env.WORKER_CYCLE_TIMEOUT_MINUTES ?? "240", 10)) * 60 * 1000;
|
||||||
|
|
||||||
|
/** Read-only access to the current cycle counter for code that needs to
|
||||||
|
* apply per-cycle modulo logic (e.g. the cold-channel backoff). */
|
||||||
|
export function getCurrentCycle(): number {
|
||||||
|
return cycleCount;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one ingestion cycle:
|
* Run one ingestion cycle:
|
||||||
* 1. Authenticate any PENDING accounts (triggers SMS code flow + auto-fetch channels)
|
* 1. Authenticate any PENDING accounts (triggers SMS code flow + auto-fetch channels)
|
||||||
|
|||||||
@@ -5,6 +5,36 @@ import { withFloodWait } from "../util/retry.js";
|
|||||||
|
|
||||||
const log = childLogger("chats");
|
const log = childLogger("chats");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect chat folder IDs to widen the loadChats sweep across all folder
|
||||||
|
* chat lists. In TDLib 1.8.64+ there's no synchronous getChatFolders call —
|
||||||
|
* the folder list arrives via updateChatFolders. We listen for it briefly
|
||||||
|
* (200ms) and fall back to an empty list if nothing arrives; chats inside
|
||||||
|
* folders are still reachable via chatListMain so this only loses some
|
||||||
|
* preemptive cache warming.
|
||||||
|
*/
|
||||||
|
async function collectFolderIds(
|
||||||
|
client: Client
|
||||||
|
): Promise<{ _: "chatListFolder"; chat_folder_id: number }[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const ids: number[] = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const handler = (update: any) => {
|
||||||
|
if (update?._ === "updateChatFolders") {
|
||||||
|
const folders = update.chat_folders as { id: number }[] | undefined;
|
||||||
|
if (folders) {
|
||||||
|
for (const f of folders) ids.push(f.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.on("update", handler);
|
||||||
|
setTimeout(() => {
|
||||||
|
client.off("update", handler);
|
||||||
|
resolve(ids.map((id) => ({ _: "chatListFolder" as const, chat_folder_id: id })));
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface TelegramChatInfo {
|
export interface TelegramChatInfo {
|
||||||
chatId: bigint;
|
chatId: bigint;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -37,21 +67,16 @@ export async function getAccountChats(
|
|||||||
// First, load all chats into TDLib's cache using loadChats (the proper API).
|
// First, load all chats into TDLib's cache using loadChats (the proper API).
|
||||||
// loadChats returns 404 when all chats have been loaded.
|
// loadChats returns 404 when all chats have been loaded.
|
||||||
// Then use getChats to retrieve the IDs for enrichment.
|
// Then use getChats to retrieve the IDs for enrichment.
|
||||||
// Load from main, archive, AND chat folders to cover all chat types.
|
//
|
||||||
const folderLists: { _: "chatListFolder"; chat_folder_id: number }[] = [];
|
// Folder-specific loading (chatListFolder) was removed in TDLib 1.8.64+ —
|
||||||
try {
|
// getChatFolders (plural) is no longer a callable method, only the
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// updateChatFolders event. The chats inside folders are still reachable
|
||||||
const folders = (await client.invoke({ _: "getChatFolders" })) as any;
|
// via chatListMain so this isn't a functional regression.
|
||||||
if (folders?.chat_folders) {
|
const folderLists: { _: "chatListFolder"; chat_folder_id: number }[] =
|
||||||
for (const f of folders.chat_folders) {
|
await collectFolderIds(client);
|
||||||
folderLists.push({ _: "chatListFolder", chat_folder_id: f.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// getChatFolders may not be available in older TDLib versions
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatLists: Record<string, unknown>[] = [
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const chatLists: any[] = [
|
||||||
{ _: "chatListMain" },
|
{ _: "chatListMain" },
|
||||||
{ _: "chatListArchive" },
|
{ _: "chatListArchive" },
|
||||||
...folderLists,
|
...folderLists,
|
||||||
@@ -282,3 +307,63 @@ export async function searchPublicChat(
|
|||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the chat's server-side last_message.id from TDLib's local cache.
|
||||||
|
* Used by the channel-scan-skip guard to short-circuit a paginated
|
||||||
|
* searchChatMessages when nothing has changed since our watermark.
|
||||||
|
*
|
||||||
|
* Returns null when the chat has no last_message (empty channel) or the
|
||||||
|
* call fails — callers must treat null as "unknown" and run the scan.
|
||||||
|
*/
|
||||||
|
export async function getChannelLastMessageId(
|
||||||
|
client: Client,
|
||||||
|
chatId: bigint
|
||||||
|
): Promise<bigint | null> {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const chat = (await client.invoke({
|
||||||
|
_: "getChat",
|
||||||
|
chat_id: Number(chatId),
|
||||||
|
})) as { last_message?: { id?: number } };
|
||||||
|
const id = chat.last_message?.id;
|
||||||
|
return id ? BigInt(id) : null;
|
||||||
|
} catch (err) {
|
||||||
|
log.debug({ err, chatId: chatId.toString() }, "getChannelLastMessageId failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the forum topic's last_message_id from TDLib. Same purpose as
|
||||||
|
* getChannelLastMessageId but scoped to a single topic in a forum
|
||||||
|
* supergroup. TDLib's `getForumTopic` returns a `forumTopic` whose `info`
|
||||||
|
* field contains the last_message_id.
|
||||||
|
*
|
||||||
|
* Returns null on failure or empty topic — caller treats as "unknown".
|
||||||
|
*/
|
||||||
|
export async function getForumTopicLastMessageId(
|
||||||
|
client: Client,
|
||||||
|
chatId: bigint,
|
||||||
|
topicId: bigint
|
||||||
|
): Promise<bigint | null> {
|
||||||
|
try {
|
||||||
|
// TDLib 1.8.64 uses `forum_topic_id` (renamed from `message_thread_id`
|
||||||
|
// in the request) — consistent with the rest of the forum-topic API
|
||||||
|
// surface in this version.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const topic = (await client.invoke({
|
||||||
|
_: "getForumTopic",
|
||||||
|
chat_id: Number(chatId),
|
||||||
|
forum_topic_id: Number(topicId),
|
||||||
|
})) as { last_message?: { id?: number }; info?: { last_message_id?: number } };
|
||||||
|
const id = topic.last_message?.id ?? topic.info?.last_message_id;
|
||||||
|
return id ? BigInt(id) : null;
|
||||||
|
} catch (err) {
|
||||||
|
log.debug(
|
||||||
|
{ err, chatId: chatId.toString(), topicId: topicId.toString() },
|
||||||
|
"getForumTopicLastMessageId failed"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,15 @@ interface TdMessage {
|
|||||||
id: number;
|
id: number;
|
||||||
date: number;
|
date: number;
|
||||||
media_album_id?: string;
|
media_album_id?: string;
|
||||||
|
// TDLib 1.8.50 exposed `reply_to_message_id` directly on the message.
|
||||||
|
// 1.8.64+ replaced it with a tagged-union `reply_to: MessageReplyTo`.
|
||||||
|
// Read both for resilience across versions.
|
||||||
reply_to_message_id?: number;
|
reply_to_message_id?: number;
|
||||||
|
reply_to?: {
|
||||||
|
_: string;
|
||||||
|
chat_id?: number;
|
||||||
|
message_id?: number;
|
||||||
|
};
|
||||||
content: {
|
content: {
|
||||||
_: string;
|
_: string;
|
||||||
document?: {
|
document?: {
|
||||||
@@ -51,6 +59,10 @@ interface TdMessage {
|
|||||||
path?: string;
|
path?: string;
|
||||||
is_downloading_completed?: boolean;
|
is_downloading_completed?: boolean;
|
||||||
};
|
};
|
||||||
|
remote?: {
|
||||||
|
/** Stable identifier across reposts of the same file content. */
|
||||||
|
unique_id?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
photo?: {
|
photo?: {
|
||||||
@@ -62,6 +74,24 @@ interface TdMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the right "the message I'm replying to" ID across TDLib versions.
|
||||||
|
* - 1.8.50 and earlier expose it directly as `reply_to_message_id`.
|
||||||
|
* - 1.8.64+ expose `reply_to: MessageReplyTo` (tagged union); a reply to
|
||||||
|
* a regular message has `_: "messageReplyToMessage"` with `message_id`.
|
||||||
|
* - Story replies (`_: "messageReplyToStory"`) intentionally return null
|
||||||
|
* here — they aren't useful for our reply-chain grouping.
|
||||||
|
*/
|
||||||
|
function extractReplyToMessageId(msg: TdMessage): bigint | undefined {
|
||||||
|
if (msg.reply_to_message_id) {
|
||||||
|
return BigInt(msg.reply_to_message_id);
|
||||||
|
}
|
||||||
|
if (msg.reply_to && msg.reply_to._ === "messageReplyToMessage" && msg.reply_to.message_id) {
|
||||||
|
return BigInt(msg.reply_to.message_id);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface TdFile {
|
interface TdFile {
|
||||||
id: number;
|
id: number;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -115,7 +145,10 @@ export async function invokeWithTimeout<T>(
|
|||||||
}
|
}
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
(client.invoke(request) as Promise<T>)
|
// The tdl 8.1+ types are very strict about the literal `_` field;
|
||||||
|
// our generic wrapper passes arbitrary requests, so cast through any.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(client.invoke(request as any) as Promise<T>)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
@@ -195,12 +228,14 @@ export async function getChannelMessages(
|
|||||||
const result = await invokeWithTimeout<{ messages: TdMessage[]; total_count?: number }>(client, {
|
const result = await invokeWithTimeout<{ messages: TdMessage[]; total_count?: number }>(client, {
|
||||||
_: "searchChatMessages",
|
_: "searchChatMessages",
|
||||||
chat_id: Number(chatId),
|
chat_id: Number(chatId),
|
||||||
|
// No topic_id for a flat (non-forum) channel scan. TDLib 1.8.64+
|
||||||
|
// dropped the top-level `message_thread_id: 0` we used to pass; the
|
||||||
|
// type-narrow now is "omit the field entirely if not in a topic".
|
||||||
query: "",
|
query: "",
|
||||||
from_message_id: fromMessageId,
|
from_message_id: fromMessageId,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: Math.min(limit, 100),
|
limit: Math.min(limit, 100),
|
||||||
filter,
|
filter,
|
||||||
message_thread_id: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.messages || result.messages.length === 0) break;
|
if (!result.messages || result.messages.length === 0) break;
|
||||||
@@ -226,8 +261,9 @@ export async function getChannelMessages(
|
|||||||
fileSize: BigInt(doc.document.size),
|
fileSize: BigInt(doc.document.size),
|
||||||
date: new Date(msg.date * 1000),
|
date: new Date(msg.date * 1000),
|
||||||
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
|
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
|
||||||
replyToMessageId: msg.reply_to_message_id ? BigInt(msg.reply_to_message_id) : undefined,
|
replyToMessageId: extractReplyToMessageId(msg),
|
||||||
caption: msg.content?.caption?.text || undefined,
|
caption: msg.content?.caption?.text || undefined,
|
||||||
|
remoteUniqueId: doc.document.remote?.unique_id || undefined,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ export async function getForumTopicList(
|
|||||||
const topics: ForumTopic[] = [];
|
const topics: ForumTopic[] = [];
|
||||||
let offsetDate = 0;
|
let offsetDate = 0;
|
||||||
let offsetMessageId = 0;
|
let offsetMessageId = 0;
|
||||||
let offsetMessageThreadId = 0;
|
// TDLib 1.8.64+ renamed `offset_message_thread_id` → `offset_forum_topic_id`
|
||||||
|
// in the getForumTopics request, and `next_offset_message_thread_id` →
|
||||||
|
// `next_offset_forum_topic_id` in the response. Individual topic infos
|
||||||
|
// also moved from `info.message_thread_id` → `info.forum_topic_id`.
|
||||||
|
let offsetForumTopicId = 0;
|
||||||
let pageCount = 0;
|
let pageCount = 0;
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
// eslint-disable-next-line no-constant-condition
|
||||||
@@ -80,12 +84,16 @@ export async function getForumTopicList(
|
|||||||
|
|
||||||
const prevOffsetDate = offsetDate;
|
const prevOffsetDate = offsetDate;
|
||||||
const prevOffsetMessageId = offsetMessageId;
|
const prevOffsetMessageId = offsetMessageId;
|
||||||
const prevOffsetMessageThreadId = offsetMessageThreadId;
|
const prevOffsetForumTopicId = offsetForumTopicId;
|
||||||
|
|
||||||
const result = await invokeWithTimeout<{
|
const result = await invokeWithTimeout<{
|
||||||
topics?: {
|
topics?: {
|
||||||
info?: {
|
info?: {
|
||||||
|
// Both names — 1.8.50 used the first, 1.8.64+ uses the second.
|
||||||
|
// Read both so a future TDLib downgrade or transition build is
|
||||||
|
// still handled.
|
||||||
message_thread_id?: number;
|
message_thread_id?: number;
|
||||||
|
forum_topic_id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
is_general?: boolean;
|
is_general?: boolean;
|
||||||
};
|
};
|
||||||
@@ -93,45 +101,49 @@ export async function getForumTopicList(
|
|||||||
next_offset_date?: number;
|
next_offset_date?: number;
|
||||||
next_offset_message_id?: number;
|
next_offset_message_id?: number;
|
||||||
next_offset_message_thread_id?: number;
|
next_offset_message_thread_id?: number;
|
||||||
|
next_offset_forum_topic_id?: number;
|
||||||
}>(client, {
|
}>(client, {
|
||||||
_: "getForumTopics",
|
_: "getForumTopics",
|
||||||
chat_id: Number(chatId),
|
chat_id: Number(chatId),
|
||||||
query: "",
|
query: "",
|
||||||
offset_date: offsetDate,
|
offset_date: offsetDate,
|
||||||
offset_message_id: offsetMessageId,
|
offset_message_id: offsetMessageId,
|
||||||
offset_message_thread_id: offsetMessageThreadId,
|
offset_forum_topic_id: offsetForumTopicId,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.topics || result.topics.length === 0) break;
|
if (!result.topics || result.topics.length === 0) break;
|
||||||
|
|
||||||
for (const t of result.topics) {
|
for (const t of result.topics) {
|
||||||
if (!t.info?.message_thread_id) continue;
|
const topicId = t.info?.forum_topic_id ?? t.info?.message_thread_id;
|
||||||
|
if (!topicId) continue;
|
||||||
|
|
||||||
topics.push({
|
topics.push({
|
||||||
topicId: BigInt(t.info.message_thread_id),
|
topicId: BigInt(topicId),
|
||||||
name: t.info.is_general ? "General" : (t.info.name ?? "Unnamed"),
|
name: t.info?.is_general ? "General" : (t.info?.name ?? "Unnamed"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are more pages
|
// Check if there are more pages
|
||||||
|
const nextForumTopicId =
|
||||||
|
result.next_offset_forum_topic_id ?? result.next_offset_message_thread_id;
|
||||||
if (
|
if (
|
||||||
!result.next_offset_date &&
|
!result.next_offset_date &&
|
||||||
!result.next_offset_message_id &&
|
!result.next_offset_message_id &&
|
||||||
!result.next_offset_message_thread_id
|
!nextForumTopicId
|
||||||
) {
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
offsetDate = result.next_offset_date ?? 0;
|
offsetDate = result.next_offset_date ?? 0;
|
||||||
offsetMessageId = result.next_offset_message_id ?? 0;
|
offsetMessageId = result.next_offset_message_id ?? 0;
|
||||||
offsetMessageThreadId = result.next_offset_message_thread_id ?? 0;
|
offsetForumTopicId = nextForumTopicId ?? 0;
|
||||||
|
|
||||||
// Stuck detection: if offsets didn't advance, break
|
// Stuck detection: if offsets didn't advance, break
|
||||||
if (
|
if (
|
||||||
offsetDate === prevOffsetDate &&
|
offsetDate === prevOffsetDate &&
|
||||||
offsetMessageId === prevOffsetMessageId &&
|
offsetMessageId === prevOffsetMessageId &&
|
||||||
offsetMessageThreadId === prevOffsetMessageThreadId
|
offsetForumTopicId === prevOffsetForumTopicId
|
||||||
) {
|
) {
|
||||||
log.warn(
|
log.warn(
|
||||||
{ chatId: chatId.toString(), topicCount: topics.length },
|
{ chatId: chatId.toString(), topicCount: topics.length },
|
||||||
@@ -210,6 +222,7 @@ export async function getTopicMessages(
|
|||||||
document?: {
|
document?: {
|
||||||
id: number;
|
id: number;
|
||||||
size: number;
|
size: number;
|
||||||
|
remote?: { unique_id?: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
photo?: {
|
photo?: {
|
||||||
@@ -226,14 +239,20 @@ export async function getTopicMessages(
|
|||||||
}>(client, {
|
}>(client, {
|
||||||
_: "searchChatMessages",
|
_: "searchChatMessages",
|
||||||
chat_id: Number(chatId),
|
chat_id: Number(chatId),
|
||||||
|
// TDLib 1.8.64+ replaced the top-level `message_thread_id` and
|
||||||
|
// `saved_messages_topic_id` parameters with a single tagged-union
|
||||||
|
// `topic_id: MessageTopic$Input`. For a forum topic, use the
|
||||||
|
// messageTopicForum variant carrying the forum_topic_id.
|
||||||
|
topic_id: {
|
||||||
|
_: "messageTopicForum",
|
||||||
|
forum_topic_id: Number(topicId),
|
||||||
|
},
|
||||||
query: "",
|
query: "",
|
||||||
message_thread_id: Number(topicId),
|
|
||||||
from_message_id: currentFromId,
|
from_message_id: currentFromId,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: Math.min(limit, 100),
|
limit: Math.min(limit, 100),
|
||||||
filter: null,
|
filter: null,
|
||||||
sender_id: null,
|
sender_id: null,
|
||||||
saved_messages_topic_id: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.messages || result.messages.length === 0) break;
|
if (!result.messages || result.messages.length === 0) break;
|
||||||
@@ -257,6 +276,7 @@ export async function getTopicMessages(
|
|||||||
fileSize: BigInt(doc.document.size),
|
fileSize: BigInt(doc.document.size),
|
||||||
date: new Date(msg.date * 1000),
|
date: new Date(msg.date * 1000),
|
||||||
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
|
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
|
||||||
|
remoteUniqueId: doc.document.remote?.unique_id || undefined,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,25 +119,45 @@ async function sendWithRetry(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stall or timeout — retry with a cooldown
|
// Stall or timeout — fail fast and let the caller recreate the TDLib
|
||||||
|
// client. Retrying on the same degraded event stream wastes ~15 min
|
||||||
|
// per attempt because the underlying issue (missing send-success
|
||||||
|
// events) is client-level, not transient. The set ends up in
|
||||||
|
// SkippedPackage and the caller's watermark cap ensures it gets
|
||||||
|
// retried next cycle on a fresh client.
|
||||||
const errMsg = err instanceof Error ? err.message : "";
|
const errMsg = err instanceof Error ? err.message : "";
|
||||||
if (errMsg.includes("stalled") || errMsg.includes("timed out")) {
|
if (errMsg.includes("stalled") || errMsg.includes("timed out")) {
|
||||||
if (!isLastAttempt) {
|
|
||||||
log.warn(
|
log.warn(
|
||||||
{ fileName, attempt: attempt + 1, maxRetries: MAX_UPLOAD_RETRIES },
|
{ fileName, attempt: attempt + 1 },
|
||||||
"Upload stalled/timed out — retrying"
|
"Upload stalled — failing fast so caller can recreate TDLib client"
|
||||||
);
|
);
|
||||||
await sleep(10_000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// All stall retries exhausted — throw UploadStallError so the caller
|
|
||||||
// knows the TDLib client's event stream is likely degraded and can
|
|
||||||
// recreate the client before continuing.
|
|
||||||
throw new UploadStallError(
|
throw new UploadStallError(
|
||||||
`Upload stalled after ${MAX_UPLOAD_RETRIES} retries for ${fileName}`
|
`Upload stalled for ${fileName}: ${errMsg}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transient Telegram server-side error (HTTP 5xx returned via
|
||||||
|
// updateMessageSendFailed). These are NOT FLOOD_WAIT, NOT stalls — just
|
||||||
|
// TG having a bad moment. They typically resolve on a short backoff, so
|
||||||
|
// retry up to MAX_UPLOAD_RETRIES with linear backoff before giving up.
|
||||||
|
const lowerMsg = errMsg.toLowerCase();
|
||||||
|
const isTransientServerError =
|
||||||
|
lowerMsg.includes("internal server error") ||
|
||||||
|
lowerMsg.includes("internal error") ||
|
||||||
|
lowerMsg.includes("server error") ||
|
||||||
|
lowerMsg.includes("bad gateway") ||
|
||||||
|
lowerMsg.includes("service unavailable") ||
|
||||||
|
lowerMsg.includes("gateway timeout");
|
||||||
|
if (isTransientServerError && !isLastAttempt) {
|
||||||
|
const backoffMs = 15_000 * (attempt + 1) + Math.random() * 5_000;
|
||||||
|
log.warn(
|
||||||
|
{ fileName, attempt: attempt + 1, maxRetries: MAX_UPLOAD_RETRIES, backoffMs: Math.round(backoffMs) },
|
||||||
|
`Transient Telegram server error — retrying after backoff`
|
||||||
|
);
|
||||||
|
await sleep(backoffMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +188,12 @@ async function sendAndWaitForUpload(
|
|||||||
let lastProgressBytes = 0;
|
let lastProgressBytes = 0;
|
||||||
let lastProgressTime = Date.now();
|
let lastProgressTime = Date.now();
|
||||||
|
|
||||||
|
// Events for our message can arrive before `sendMessage` resolves
|
||||||
|
// (TDLib emits them while our .then() is still in the microtask queue).
|
||||||
|
// Buffer them and replay once tempMsgId is known.
|
||||||
|
let pendingSuccess: { oldMsgId: number; finalId: number } | null = null;
|
||||||
|
let pendingFailure: { oldMsgId: number; errorMsg: string; code?: number } | null = null;
|
||||||
|
|
||||||
// Timeout: 20 minutes per GB, minimum 15 minutes
|
// Timeout: 20 minutes per GB, minimum 15 minutes
|
||||||
const timeoutMs = Math.max(
|
const timeoutMs = Math.max(
|
||||||
15 * 60_000,
|
15 * 60_000,
|
||||||
@@ -204,6 +230,26 @@ async function sendAndWaitForUpload(
|
|||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
const completeWithSuccess = (finalId: number) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
log.info(
|
||||||
|
{ fileName, tempMsgId, finalMsgId: finalId },
|
||||||
|
"Upload confirmed by Telegram"
|
||||||
|
);
|
||||||
|
resolve(BigInt(finalId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeWithFailure = (errorMsg: string, code?: number) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
const error = new Error(`Upload failed for ${fileName}: ${errorMsg}`);
|
||||||
|
(error as Error & { code?: number }).code = code;
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const handleUpdate = (update: any) => {
|
const handleUpdate = (update: any) => {
|
||||||
// Track upload progress via updateFile events
|
// Track upload progress via updateFile events
|
||||||
@@ -234,33 +280,29 @@ async function sendAndWaitForUpload(
|
|||||||
// The money event: upload succeeded, we get the final server message ID
|
// The money event: upload succeeded, we get the final server message ID
|
||||||
if (update?._ === "updateMessageSendSucceeded") {
|
if (update?._ === "updateMessageSendSucceeded") {
|
||||||
const msg = update.message;
|
const msg = update.message;
|
||||||
const oldMsgId = update.old_message_id;
|
const oldMsgId: number = update.old_message_id;
|
||||||
if (tempMsgId !== null && oldMsgId === tempMsgId) {
|
if (tempMsgId === null) {
|
||||||
if (!settled) {
|
// Race: event arrived before our .then() assigned tempMsgId.
|
||||||
settled = true;
|
// Buffer it and process once tempMsgId is known.
|
||||||
cleanup();
|
pendingSuccess = { oldMsgId, finalId: msg.id };
|
||||||
const finalId = BigInt(msg.id);
|
return;
|
||||||
log.info(
|
|
||||||
{ fileName, tempMsgId, finalMsgId: Number(finalId) },
|
|
||||||
"Upload confirmed by Telegram"
|
|
||||||
);
|
|
||||||
resolve(finalId);
|
|
||||||
}
|
}
|
||||||
|
if (oldMsgId === tempMsgId) {
|
||||||
|
completeWithSuccess(msg.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload failed
|
// Upload failed
|
||||||
if (update?._ === "updateMessageSendFailed") {
|
if (update?._ === "updateMessageSendFailed") {
|
||||||
const oldMsgId = update.old_message_id;
|
const oldMsgId: number = update.old_message_id;
|
||||||
if (tempMsgId !== null && oldMsgId === tempMsgId) {
|
const errorMsg: string = update.error?.message ?? "Unknown upload error";
|
||||||
if (!settled) {
|
const code: number | undefined = update.error?.code;
|
||||||
settled = true;
|
if (tempMsgId === null) {
|
||||||
cleanup();
|
pendingFailure = { oldMsgId, errorMsg, code };
|
||||||
const errorMsg = update.error?.message ?? "Unknown upload error";
|
return;
|
||||||
const error = new Error(`Upload failed for ${fileName}: ${errorMsg}`);
|
|
||||||
(error as Error & { code?: number }).code = update.error?.code;
|
|
||||||
reject(error);
|
|
||||||
}
|
}
|
||||||
|
if (oldMsgId === tempMsgId) {
|
||||||
|
completeWithFailure(errorMsg, code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -304,6 +346,13 @@ async function sendAndWaitForUpload(
|
|||||||
{ fileName, tempMsgId },
|
{ fileName, tempMsgId },
|
||||||
"Message queued, waiting for upload confirmation"
|
"Message queued, waiting for upload confirmation"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Replay any event that arrived before we knew tempMsgId
|
||||||
|
if (pendingSuccess && pendingSuccess.oldMsgId === tempMsgId) {
|
||||||
|
completeWithSuccess(pendingSuccess.finalId);
|
||||||
|
} else if (pendingFailure && pendingFailure.oldMsgId === tempMsgId) {
|
||||||
|
completeWithFailure(pendingFailure.errorMsg, pendingFailure.code);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
|
|||||||
@@ -20,4 +20,26 @@ export const config = {
|
|||||||
apiDelayMs: 1000,
|
apiDelayMs: 1000,
|
||||||
/** Max retries for rate-limited requests */
|
/** Max retries for rate-limited requests */
|
||||||
maxRetries: 5,
|
maxRetries: 5,
|
||||||
|
/** After this many failed attempts on the same source message, the worker
|
||||||
|
* stops auto-retrying and lets the watermark advance past it. The user can
|
||||||
|
* manually retry via the UI to reset and try again. */
|
||||||
|
maxSkipAttempts: parseInt(process.env.WORKER_MAX_SKIP_ATTEMPTS ?? "5", 10),
|
||||||
|
/** Window in which a recent successful empty scan lets us skip the next
|
||||||
|
* scan entirely. Default 5 minutes. */
|
||||||
|
skipRecentScanWindowMs: parseInt(
|
||||||
|
process.env.WORKER_SKIP_RECENT_SCAN_WINDOW_MS ?? "300000",
|
||||||
|
10
|
||||||
|
),
|
||||||
|
/** After this many consecutive empty scans, a channel/topic enters
|
||||||
|
* backoff and is only scanned every Nth cycle. */
|
||||||
|
emptyScanBackoffThreshold: parseInt(
|
||||||
|
process.env.WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD ?? "5",
|
||||||
|
10
|
||||||
|
),
|
||||||
|
/** While in backoff, scan only every Nth cycle. Default 5 = scan every
|
||||||
|
* fifth cycle = once every ~5 hours given the 60-min default interval. */
|
||||||
|
emptyScanBackoffEveryNth: parseInt(
|
||||||
|
process.env.WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH ?? "5",
|
||||||
|
10
|
||||||
|
),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user