mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-09-21 05:21:43 +00:00
Compare commits
195
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dadf03212c | ||
|
|
267c72bbe8 | ||
|
|
497c4876a6 | ||
|
|
f5d913eb18 | ||
|
|
1e11dd3fd8 | ||
|
|
086f58f9dd | ||
|
|
3595f6f097 | ||
|
|
abdfa437d9 | ||
|
|
49f14bcb0d | ||
|
|
d4a1cfec99 | ||
|
|
ecedc0fec4 | ||
|
|
1b1f5b7972 | ||
|
|
e822ea3e76 | ||
|
|
9d16156161 | ||
|
|
2e7e6cca9b | ||
|
|
ceae4f384b | ||
|
|
7595543386 | ||
|
|
09ee9da9cc | ||
|
|
7ddf13053f | ||
|
|
4a7b9e2a09 | ||
|
|
9a06130c5b | ||
|
|
a7aa4ce285 | ||
|
|
38072d250f | ||
|
|
0bce1168a9 | ||
|
|
018b0f5d74 | ||
|
|
c2590fb66f | ||
|
|
8b443620c8 | ||
|
|
80aa2b0ee0 | ||
|
|
f0e0e79d34 | ||
|
|
21bd46010f | ||
|
|
2252ac01f5 | ||
|
|
63df348028 | ||
|
|
8bbf51f056 | ||
|
|
5873d148c1 | ||
|
|
90be365c7f | ||
|
|
7e21a41615 | ||
|
|
f3d62c68fb | ||
|
|
412e3066bc | ||
|
|
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 | ||
|
|
59038889ae | ||
|
|
77c26adb31 | ||
|
|
35cce3151c | ||
|
|
d6c82ede1e | ||
|
|
7e48131f67 | ||
|
|
a79cb4749b | ||
|
|
e9017fc518 | ||
|
|
4f59d19ac2 | ||
|
|
579276ee2d | ||
|
|
b48cc510a4 | ||
|
|
614c8e5b74 | ||
|
|
3019c23f70 | ||
|
|
436a576085 | ||
|
|
f454303352 | ||
|
|
e29bd79d66 | ||
|
|
61e61d0085 | ||
|
|
925d916a3c | ||
|
|
27bacaf24c | ||
|
|
be4daf950b | ||
|
|
af7094637d | ||
|
|
f4aa9d9a2f | ||
|
|
7f9a03d4ee | ||
|
|
2c46ab0843 | ||
|
|
9e78cc5d19 | ||
|
|
194c87a256 | ||
|
|
718007446f | ||
|
|
527aca7c25 | ||
|
|
a4156b2ac6 | ||
|
|
d50c68f67c | ||
|
|
f6e7f5ed3c | ||
|
|
e7f213eec4 | ||
|
|
20b7d28fdf | ||
|
|
21663fc29e | ||
|
|
218ccb9282 | ||
|
|
b632533f54 | ||
|
|
4baf5aad83 | ||
|
|
ad7790c07b | ||
|
|
e4398caebe | ||
|
|
6eb7129637 | ||
|
|
d6386209be | ||
|
|
fe28c31b9e | ||
|
|
55bdf3c890 | ||
|
|
5506c7d91b | ||
|
|
5a3550fa10 | ||
|
|
ad3d42a997 | ||
|
|
dd0d246a77 | ||
|
|
dcc1c97053 | ||
|
|
71c3228e44 | ||
|
|
094001f9f7 | ||
|
|
0faacc214b | ||
|
|
d53e581623 | ||
|
|
780e6200d8 | ||
|
|
9642adaba7 | ||
|
|
9bc9271f11 | ||
|
|
bd358a134b | ||
|
|
1425db8774 | ||
|
|
aef76828ef | ||
|
|
29e95f780c | ||
|
|
5fd341dfc4 | ||
|
|
e2dd3bb9d0 | ||
|
|
ccf6f9000d | ||
|
|
a4c264a144 | ||
|
|
f4488a079f | ||
|
|
729f296232 | ||
|
|
a48f9c24a7 | ||
|
|
84bb167ce6 | ||
|
|
7cd84dbf02 | ||
|
|
c00fc528ac | ||
|
|
1fc2d3e1ae | ||
|
|
ab558e00f5 | ||
|
|
bf093cdfca | ||
|
|
a90f653314 | ||
|
|
9ac66e9d7d | ||
|
|
36a7e3d5f4 | ||
|
|
53a76a8136 | ||
|
|
ba3d3a6040 | ||
|
|
fe7a548fef | ||
|
|
4a44374bb7 | ||
|
|
c7eb077e0d | ||
|
|
031a4687fb | ||
|
|
30fb96b3f9 | ||
|
|
9a077a3648 | ||
|
|
2ceba66313 | ||
|
|
036dadcb21 | ||
|
|
541ae0c614 | ||
|
|
b7a76fd932 | ||
|
|
b75b0e1f91 | ||
|
|
50e7e02b2d | ||
|
|
dea419b778 | ||
|
|
053eeed6be | ||
|
|
d5725bd52e | ||
|
|
48726b9122 | ||
|
|
1b8df48768 | ||
|
|
726f55a943 | ||
|
|
b08140b4f9 | ||
|
|
761d5e0790 | ||
|
|
d7bbb7587e | ||
|
|
2763de2711 | ||
|
|
6926df9a2c | ||
|
|
651e9e6bdd | ||
|
|
8d508d5a86 | ||
|
|
2bb3caf7d9 | ||
|
|
8d95752106 |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@superpowers-marketplace": true
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,13 @@
|
||||
"Bash(git -C /mnt/c/Users/A00963355/OneDrive - Amaris Zorggroep/Documents/VScodeProjects/DragonsStash log --oneline -10)",
|
||||
"Bash(git -C \"C:/Users/A00963355/OneDrive - Amaris Zorggroep/Documents/VScodeProjects/DragonsStash\" status --short)",
|
||||
"Bash(timeout:*)",
|
||||
"mcp__Claude_Preview__preview_start"
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"Bash(cat:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(wait:*)",
|
||||
"WebSearch",
|
||||
"Bash(SKILL_CREATOR_PATH=\"C:\\\\Users\\\\A00963355\\\\.claude\\\\plugins\\\\cache\\\\claude-plugins-official\\\\skill-creator\\\\d5c15b861cd2\\\\skills\\\\skill-creator\" && WORKSPACE=\"C:\\\\Users\\\\A00963355\\\\OneDrive - Amaris Zorggroep\\\\Documents\\\\VScodeProjects\\\\DragonsStash\\\\.claude\\\\skills\\\\tdlib-telegram-workspace\\\\iteration-1\" && python \"$SKILL_CREATOR_PATH/eval-viewer/generate_review.py\" \"$WORKSPACE\" --skill-name \"tdlib-telegram\" --benchmark \"$WORKSPACE/benchmark.json\" --static \"$WORKSPACE/review.html\" 2>&1)",
|
||||
"Bash(start:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"skill_name": "tdlib-telegram",
|
||||
"iteration": 1,
|
||||
"configs": [
|
||||
{
|
||||
"name": "with_skill",
|
||||
"pass_rate": {"mean": 1.0, "stddev": 0.0},
|
||||
"tokens": {"mean": 53200, "stddev": 14800},
|
||||
"time_seconds": {"mean": 123.5, "stddev": 16.7}
|
||||
},
|
||||
{
|
||||
"name": "without_skill",
|
||||
"pass_rate": {"mean": 0.857, "stddev": 0.134},
|
||||
"tokens": {"mean": 56467, "stddev": 12100},
|
||||
"time_seconds": {"mean": 156.4, "stddev": 39.7}
|
||||
}
|
||||
],
|
||||
"delta": {
|
||||
"pass_rate": "+14.3%",
|
||||
"tokens": "-5.8%",
|
||||
"time": "-21.0%"
|
||||
},
|
||||
"evals": [
|
||||
{
|
||||
"name": "broadcast-to-all-users",
|
||||
"with_skill": {"pass_rate": 1.0, "passed": 5, "total": 5, "tokens": 35365, "time_seconds": 107.6},
|
||||
"without_skill": {"pass_rate": 0.6, "passed": 3, "total": 5, "tokens": 69214, "time_seconds": 200.2}
|
||||
},
|
||||
{
|
||||
"name": "flood-wait-during-scan",
|
||||
"with_skill": {"pass_rate": 1.0, "passed": 4, "total": 4, "tokens": 63079, "time_seconds": 140.9},
|
||||
"without_skill": {"pass_rate": 1.0, "passed": 4, "total": 4, "tokens": 45601, "time_seconds": 122.3}
|
||||
},
|
||||
{
|
||||
"name": "download-and-reupload-file",
|
||||
"with_skill": {"pass_rate": 1.0, "passed": 5, "total": 5, "tokens": 61157, "time_seconds": 122.1},
|
||||
"without_skill": {"pass_rate": 1.0, "passed": 5, "total": 5, "tokens": 54587, "time_seconds": 146.7}
|
||||
}
|
||||
],
|
||||
"analyst_notes": [
|
||||
"The skill's biggest impact was on Eval 1 (broadcast): the baseline MISSED both withFloodWait retry wrapping and inter-message delay — the two most critical patterns for avoiding rate limits during bulk sends. This is exactly the kind of bug the skill is designed to prevent.",
|
||||
"Eval 2 (FLOOD_WAIT debugging) was a near-tie. Both versions correctly diagnosed the problem and proposed adaptive backoff. The skill version was slightly more thorough: it added pagination-level retry with sleep(waitSec) instead of just re-throwing, meaning it can survive even after withFloodWait's retries are exhausted.",
|
||||
"Eval 3 (download/reupload) was also close. Both correctly composed existing primitives. The skill version was more explicit about WHY certain patterns matter (referencing the skill's documentation), which helps future maintainers understand the code.",
|
||||
"The skill version was faster on average (-21% time) and used fewer tokens (-5.8%), likely because the skill front-loaded the knowledge instead of requiring the agent to discover it by reading source files."
|
||||
]
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eval_id": 1,
|
||||
"eval_name": "broadcast-to-all-users",
|
||||
"prompt": "Add a new bot command /broadcast that sends a text message to ALL users who have a TelegramLink in the database. The admin triggers it from the web app. Add it to the bot's command handler and create an API endpoint that triggers it.",
|
||||
"assertions": [
|
||||
{"text": "uses_sequential_queue: Messages sent one at a time, no Promise.all or concurrent sends", "type": "required"},
|
||||
{"text": "uses_flood_wait_retry: Each send call wrapped in withFloodWait or equivalent FLOOD_WAIT-aware retry", "type": "required"},
|
||||
{"text": "has_per_message_delay: Includes a sleep/delay between individual sends", "type": "recommended"},
|
||||
{"text": "handles_per_user_errors: One failed send does not abort the entire broadcast loop", "type": "required"},
|
||||
{"text": "has_api_endpoint: Creates an API route or server action to trigger the broadcast from the web app", "type": "required"}
|
||||
]
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"run_id": "eval-1-with_skill",
|
||||
"expectations": [
|
||||
{"text": "uses_sequential_queue", "passed": true, "evidence": "Uses a for...of loop iterating links one at a time. No Promise.all. Comment: '~20 msgs/sec — well under the 30 msgs/sec global bot limit'"},
|
||||
{"text": "uses_flood_wait_retry", "passed": true, "evidence": "Each send wrapped in withFloodWait(): 'await withFloodWait(() => sendTextMessage(link.telegramUserId, data.message, \"textParseModeHTML\"))'. Created a new bot/src/util/flood-wait.ts with full extractFloodWaitSeconds + retry logic."},
|
||||
{"text": "has_per_message_delay", "passed": true, "evidence": "await sleep(50) after each send — explicitly commented as '~20 msgs/sec'"},
|
||||
{"text": "handles_per_user_errors", "passed": true, "evidence": "try/catch around each send with 'failed++' counter, continues to next user"},
|
||||
{"text": "has_api_endpoint", "passed": true, "evidence": "Created src/app/api/telegram/bot/broadcast/route.ts with admin auth, 4096-char validation, pg_notify('bot_broadcast')"}
|
||||
]
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
# /broadcast Implementation Plan
|
||||
|
||||
## Approach
|
||||
|
||||
The broadcast feature has two parts:
|
||||
|
||||
1. **API endpoint** (`POST /api/telegram/bot/broadcast`) — called from the web app by an admin. It accepts a `message` string, looks up ALL `TelegramLink` records in the database, and triggers the bot to send the message to each linked user via `pg_notify`.
|
||||
2. **Bot-side handler** — a new `bot_broadcast` pg_notify channel listener in `send-listener.ts` that receives the broadcast payload and sequentially sends the text message to every linked Telegram user.
|
||||
|
||||
The `/broadcast` bot command itself is not a user-facing Telegram command (regular users should not be able to trigger it). It is triggered exclusively through the admin API endpoint.
|
||||
|
||||
## Skill Patterns Applied
|
||||
|
||||
- **Sequential Send Queue** (from skill): Never fire concurrent sends to multiple users. The broadcast iterates users sequentially with `await sleep(50)` between sends (~20 msgs/sec, well under the 30 msgs/sec global bot limit).
|
||||
- **FLOOD_WAIT handling** (from skill): Every `sendTextMessage` call is wrapped with `withFloodWait()` which extracts the wait duration from errors and retries with jitter.
|
||||
- **Anti-pattern avoidance**: No `Promise.all(users.map(...))` — that would instantly hit the 30 msg/sec global limit.
|
||||
- **Message text length limit**: The API endpoint validates that the broadcast message does not exceed 4,096 characters (Telegram's limit from the skill).
|
||||
|
||||
---
|
||||
|
||||
## File 1: `bot/src/util/flood-wait.ts` (NEW)
|
||||
|
||||
Extracted from the skill's recommended FLOOD_WAIT pattern so it can be reused by both existing send logic and the new broadcast logic.
|
||||
|
||||
```typescript
|
||||
import { childLogger } from "./logger.js";
|
||||
|
||||
const log = childLogger("flood-wait");
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the mandatory wait duration (in seconds) from a Telegram
|
||||
* FLOOD_WAIT error. Returns null when the error is not rate-limit related.
|
||||
*/
|
||||
export function extractFloodWaitSeconds(err: unknown): number | null {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Pattern 1: FLOOD_WAIT_30
|
||||
const flood = message.match(/FLOOD_WAIT_(\d+)/i);
|
||||
if (flood) return parseInt(flood[1], 10);
|
||||
|
||||
// Pattern 2: "retry after 30"
|
||||
const retry = message.match(/retry after (\d+)/i);
|
||||
if (retry) return parseInt(retry[1], 10);
|
||||
|
||||
// Pattern 3: HTTP 429 without explicit seconds
|
||||
if (String((err as any)?.code) === "429") return 30;
|
||||
|
||||
return null; // Not a rate limit error
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap any async Telegram operation with automatic FLOOD_WAIT retry.
|
||||
* Adds random jitter (1-5 s) to prevent thundering-herd retries.
|
||||
*/
|
||||
export async function withFloodWait<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries = 5
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const wait = extractFloodWaitSeconds(err);
|
||||
if (wait === null || attempt >= maxRetries) throw err;
|
||||
|
||||
const jitter = 1000 + Math.random() * 4000;
|
||||
log.warn(
|
||||
{ wait, attempt, jitter: Math.round(jitter) },
|
||||
"FLOOD_WAIT received — backing off"
|
||||
);
|
||||
await sleep(wait * 1000 + jitter);
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
export { sleep };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 2: `bot/src/db/queries.ts` (MODIFIED — add one function)
|
||||
|
||||
Add this function at the bottom of the existing file, after the `getGlobalDestinationChannel` function:
|
||||
|
||||
```typescript
|
||||
// ── Broadcast ──
|
||||
|
||||
/**
|
||||
* Fetch ALL TelegramLink records (users who linked their Telegram account).
|
||||
* Used by the broadcast feature to send a message to every linked user.
|
||||
*/
|
||||
export async function getAllTelegramLinks() {
|
||||
return db.telegramLink.findMany({
|
||||
select: {
|
||||
telegramUserId: true,
|
||||
telegramName: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 3: `bot/src/send-listener.ts` (MODIFIED — add broadcast channel)
|
||||
|
||||
Add the `bot_broadcast` channel to the existing listener. The changes are:
|
||||
|
||||
### 3a. Add import for the new query and flood-wait utility
|
||||
|
||||
At the top of the file, update the imports:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
getPendingSendRequest,
|
||||
updateSendRequest,
|
||||
findMatchingSubscriptions,
|
||||
getGlobalDestinationChannel,
|
||||
getAllTelegramLinks, // ← NEW
|
||||
} from "./db/queries.js";
|
||||
import { copyMessageToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
|
||||
import { withFloodWait, sleep } from "./util/flood-wait.js"; // ← NEW
|
||||
```
|
||||
|
||||
### 3b. Subscribe to the new pg_notify channel
|
||||
|
||||
Inside `connectListener()`, after the existing LISTEN statements, add:
|
||||
|
||||
```typescript
|
||||
await pgClient.query("LISTEN bot_broadcast");
|
||||
```
|
||||
|
||||
### 3c. Add the notification handler
|
||||
|
||||
Inside the `pgClient.on("notification", ...)` callback, add the new branch:
|
||||
|
||||
```typescript
|
||||
pgClient.on("notification", (msg) => {
|
||||
if (msg.channel === "bot_send" && msg.payload) {
|
||||
handleBotSend(msg.payload);
|
||||
} else if (msg.channel === "new_package" && msg.payload) {
|
||||
handleNewPackage(msg.payload);
|
||||
} else if (msg.channel === "bot_broadcast" && msg.payload) { // ← NEW
|
||||
handleBroadcast(msg.payload);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Update the log message:
|
||||
|
||||
```typescript
|
||||
log.info("Send listener started (bot_send, new_package, bot_broadcast)");
|
||||
```
|
||||
|
||||
### 3d. Add the broadcast handler function
|
||||
|
||||
Add this at the bottom of the file (before the existing `escapeHtml` helper):
|
||||
|
||||
```typescript
|
||||
// ── bot_broadcast handler ──
|
||||
|
||||
/**
|
||||
* Handle a broadcast request. The payload is a JSON string:
|
||||
* { message: string }
|
||||
*
|
||||
* Sends the message to every user who has a TelegramLink.
|
||||
* Uses a sequential loop with a 50 ms delay between sends (~20 msgs/sec)
|
||||
* to stay well under Telegram's 30 msgs/sec global bot limit.
|
||||
* Each send is wrapped with withFloodWait to automatically retry on
|
||||
* rate-limit errors.
|
||||
*/
|
||||
async function handleBroadcast(payload: string): Promise<void> {
|
||||
try {
|
||||
const data = JSON.parse(payload) as { message: string };
|
||||
if (!data.message) {
|
||||
log.warn("Broadcast payload missing message — ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
const links = await getAllTelegramLinks();
|
||||
if (links.length === 0) {
|
||||
log.info("Broadcast requested but no linked users found");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ recipientCount: links.length },
|
||||
"Starting broadcast to all linked users"
|
||||
);
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const link of links) {
|
||||
try {
|
||||
await withFloodWait(() =>
|
||||
sendTextMessage(link.telegramUserId, data.message, "textParseModeHTML")
|
||||
);
|
||||
sent++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
log.warn(
|
||||
{ err, telegramUserId: link.telegramUserId.toString() },
|
||||
"Broadcast send failed for user"
|
||||
);
|
||||
}
|
||||
// ~20 msgs/sec — well under the 30 msgs/sec global bot limit
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
log.info({ sent, failed, total: links.length }, "Broadcast completed");
|
||||
} catch (err) {
|
||||
log.error({ err, payload }, "Failed to process broadcast");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 4: `src/app/api/telegram/bot/broadcast/route.ts` (NEW)
|
||||
|
||||
This is the Next.js API endpoint that the admin triggers from the web app.
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/telegram/bot/broadcast
|
||||
* Send a text message to ALL users who have a linked Telegram account.
|
||||
*
|
||||
* Body: { message: string }
|
||||
*
|
||||
* Admin-only. The actual sending is done by the bot process — this endpoint
|
||||
* simply validates input and fires a pg_notify('bot_broadcast', ...) signal.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only admins can broadcast
|
||||
if (session.user.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ error: "Only admins can send broadcast messages" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
let body: { message?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.message || typeof body.message !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "message is required and must be a string" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const trimmed = body.message.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "message must not be empty" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Telegram message text limit is 4096 characters
|
||||
if (trimmed.length > 4096) {
|
||||
return NextResponse.json(
|
||||
{ error: "message exceeds Telegram's 4096-character limit" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Count how many users will receive the broadcast
|
||||
const recipientCount = await prisma.telegramLink.count();
|
||||
if (recipientCount === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No users have linked Telegram accounts" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Notify the bot via pg_notify
|
||||
const payload = JSON.stringify({ message: trimmed });
|
||||
try {
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('bot_broadcast', $1)`,
|
||||
payload
|
||||
);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to queue broadcast — bot notification channel unavailable" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "QUEUED",
|
||||
recipientCount,
|
||||
message: `Broadcast queued for ${recipientCount} user(s)`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `bot/src/util/flood-wait.ts` | NEW | Reusable FLOOD_WAIT retry + sleep utilities (from skill) |
|
||||
| `bot/src/db/queries.ts` | MODIFIED | Add `getAllTelegramLinks()` query |
|
||||
| `bot/src/send-listener.ts` | MODIFIED | Subscribe to `bot_broadcast` channel; add `handleBroadcast()` with sequential send loop |
|
||||
| `src/app/api/telegram/bot/broadcast/route.ts` | NEW | Admin-only API endpoint that validates input and fires `pg_notify` |
|
||||
|
||||
## Skill Patterns Used
|
||||
|
||||
1. **Sequential Send Queue**: The broadcast loop sends messages one at a time with `await sleep(50)` between each, keeping throughput at ~20 msgs/sec — safely under the 30 msgs/sec bot global limit documented in the skill.
|
||||
|
||||
2. **FLOOD_WAIT handling with `withFloodWait()`**: Every `sendTextMessage` call is wrapped so that if Telegram returns a `FLOOD_WAIT_X` error, the bot automatically waits the required duration plus random jitter (1-5s) before retrying. This prevents the entire broadcast from failing due to a temporary rate limit.
|
||||
|
||||
3. **Message text length validation**: The API endpoint rejects messages over 4,096 characters, which is the `message_text_length_max` limit documented in the skill.
|
||||
|
||||
4. **No concurrent sends (anti-pattern avoidance)**: The skill explicitly warns against `Promise.all(users.map(...))` for bulk sends. The implementation uses a sequential `for...of` loop instead.
|
||||
|
||||
5. **pg_notify pattern**: Follows the existing project convention (seen in `bot_send` and `new_package`) where the web app fires a `pg_notify` and the bot's `send-listener.ts` picks it up — keeping the two processes decoupled.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"total_tokens": 35365,
|
||||
"duration_ms": 107616,
|
||||
"total_duration_seconds": 107.6
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"run_id": "eval-1-without_skill",
|
||||
"expectations": [
|
||||
{"text": "uses_sequential_queue", "passed": true, "evidence": "Uses for...of loop over links, sends one at a time. No Promise.all."},
|
||||
{"text": "uses_flood_wait_retry", "passed": false, "evidence": "No withFloodWait wrapper. Bare sendTextMessage calls in both send-listener handleBroadcast and commands handleBroadcastCommand. No flood-wait.ts utility created."},
|
||||
{"text": "has_per_message_delay", "passed": false, "evidence": "No sleep/delay between sends in either the send-listener handler or the command handler."},
|
||||
{"text": "handles_per_user_errors", "passed": true, "evidence": "try/catch per user with sent++/failed++ counters, continues to next user."},
|
||||
{"text": "has_api_endpoint", "passed": true, "evidence": "Created src/app/api/telegram/bot/broadcast/route.ts with admin auth, 4096-char limit, pg_notify."}
|
||||
]
|
||||
}
|
||||
+1182
File diff suppressed because it is too large
Load Diff
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"total_tokens": 69214,
|
||||
"duration_ms": 200249,
|
||||
"total_duration_seconds": 200.2
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"eval_id": 2,
|
||||
"eval_name": "flood-wait-during-scan",
|
||||
"prompt": "The worker keeps crashing with 'FLOOD_WAIT_35' errors when scanning a source channel that has about 10,000 messages. It happens during the getChannelMessages pagination loop. How do I fix this?",
|
||||
"assertions": [
|
||||
{"text": "identifies_retry_as_fix: Recommends wrapping pagination calls in FLOOD_WAIT-aware retry logic", "type": "required"},
|
||||
{"text": "recommends_pagination_delay: Mentions adding or increasing sleep between pages", "type": "required"},
|
||||
{"text": "respects_retry_after: Fix extracts and uses the actual wait duration from the error, not a hardcoded value", "type": "required"},
|
||||
{"text": "does_not_reduce_frequency_as_primary_fix: Does not suggest scanning less often as the main solution", "type": "required"}
|
||||
]
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"run_id": "eval-2-with_skill",
|
||||
"expectations": [
|
||||
{"text": "identifies_retry_as_fix", "passed": true, "evidence": "Identifies that withFloodWait already exists but the pagination loop needs its own adaptive backoff layer. Adds extractFloodWaitSeconds at pagination level."},
|
||||
{"text": "recommends_pagination_delay", "passed": true, "evidence": "Keeps existing sleep(config.apiDelayMs) and makes it adaptive: doubles on FLOOD_WAIT, relaxes by 0.8x on success."},
|
||||
{"text": "respects_retry_after", "passed": true, "evidence": "Extracts actual wait duration: 'await sleep(waitSec * 1000 + jitter)' using extractFloodWaitSeconds. Explicitly cites skill rule: 'Always respect the wait duration.'"},
|
||||
{"text": "does_not_reduce_frequency_as_primary_fix", "passed": true, "evidence": "Primary fix is adaptive backoff at pagination level, not reducing scan frequency. Does not suggest lowering WORKER_INTERVAL_MINUTES."}
|
||||
]
|
||||
}
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
# Diagnosis: FLOOD_WAIT_35 During Channel Scanning
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `getChannelMessages` function in `worker/src/tdlib/download.ts` (line 146) paginates through channel history using `getChatHistory` with a fixed 1-second delay between pages (`config.apiDelayMs = 1000`). While each individual `getChatHistory` call is wrapped in `invokeWithTimeout` which delegates to `withFloodWait` for retry logic, the pagination loop itself has a fundamental problem for large channels:
|
||||
|
||||
**A 10,000-message channel at 100 messages/page = 100 consecutive API calls.** With only a 1-second delay between pages, this sustained burst of ~1 req/sec for 100+ seconds eventually triggers Telegram's dynamic rate limiter. Once `FLOOD_WAIT_35` fires, `withFloodWait` does correctly sleep for 35+ seconds and retry that single call -- but the loop immediately resumes its 1-second cadence, triggering another FLOOD_WAIT shortly after. After 5 retries (`config.maxRetries = 5`), the function throws and the worker crashes.
|
||||
|
||||
The same issue exists in `getTopicMessages` in `worker/src/tdlib/topics.ts` (line 170), which uses identical pagination logic.
|
||||
|
||||
## Why the Current Code Is Almost Right But Not Quite
|
||||
|
||||
The codebase already has the right building blocks:
|
||||
|
||||
1. `withFloodWait` in `worker/src/util/retry.ts` correctly extracts wait durations and adds jitter -- this matches the skill's recommended FLOOD_WAIT handler pattern exactly.
|
||||
2. `invokeWithTimeout` in `worker/src/tdlib/download.ts` wraps every TDLib call through `withFloodWait`.
|
||||
3. There is a 1-second inter-page delay (`config.apiDelayMs`).
|
||||
|
||||
**The gap:** After a FLOOD_WAIT recovery, the pagination loop does not back off its inter-page delay. It goes right back to 1-second spacing, which is what triggers repeated FLOOD_WAITs until max retries is exhausted.
|
||||
|
||||
## The Fix
|
||||
|
||||
Apply **adaptive backoff** to the pagination delay: when a FLOOD_WAIT is encountered during scanning, increase the inter-page delay for subsequent pages. This prevents the "recover then immediately re-trigger" cycle.
|
||||
|
||||
### Fix 1: Add adaptive delay to `getChannelMessages` (`worker/src/tdlib/download.ts`)
|
||||
|
||||
Replace lines 146-250 with:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Fetch messages from a channel, stopping once we've scanned past the
|
||||
* last-processed boundary (with one page of lookback for multipart safety).
|
||||
* Collects both archive attachments AND photo messages (for preview matching).
|
||||
* Returns messages in chronological order (oldest first).
|
||||
*
|
||||
* When `lastProcessedMessageId` is null (first run), scans everything.
|
||||
* The worker applies a post-grouping filter to skip fully-processed sets,
|
||||
* and keeps `packageExistsBySourceMessage` as a safety net.
|
||||
*
|
||||
* Safety features:
|
||||
* - Max page limit to prevent infinite loops
|
||||
* - Stuck detection: breaks if from_message_id stops advancing
|
||||
* - Timeout on each TDLib API call
|
||||
* - Adaptive delay: backs off when FLOOD_WAIT is encountered
|
||||
*/
|
||||
export async function getChannelMessages(
|
||||
client: Client,
|
||||
chatId: bigint,
|
||||
lastProcessedMessageId?: bigint | null,
|
||||
limit = 100,
|
||||
onProgress?: ScanProgressCallback
|
||||
): Promise<ChannelScanResult> {
|
||||
const archives: TelegramMessage[] = [];
|
||||
const photos: TelegramPhoto[] = [];
|
||||
const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null;
|
||||
|
||||
let currentFromId = 0;
|
||||
let totalScanned = 0;
|
||||
let pageCount = 0;
|
||||
let currentDelay = config.apiDelayMs; // starts at 1000ms, adapts on FLOOD_WAIT
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (pageCount >= MAX_SCAN_PAGES) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), pageCount, totalScanned },
|
||||
"Hit max page limit for channel scan, stopping"
|
||||
);
|
||||
break;
|
||||
}
|
||||
pageCount++;
|
||||
|
||||
const previousFromId = currentFromId;
|
||||
|
||||
let result: { messages: TdMessage[] };
|
||||
try {
|
||||
result = await invokeWithTimeout<{ messages: TdMessage[] }>(client, {
|
||||
_: "getChatHistory",
|
||||
chat_id: Number(chatId),
|
||||
from_message_id: currentFromId,
|
||||
offset: 0,
|
||||
limit: Math.min(limit, 100),
|
||||
only_local: false,
|
||||
});
|
||||
} catch (err) {
|
||||
// If invokeWithTimeout exhausted its retries on FLOOD_WAIT, check if
|
||||
// we can recover at the pagination level by increasing the delay further.
|
||||
const waitSec = extractFloodWaitSeconds(err);
|
||||
if (waitSec !== null) {
|
||||
// The retry wrapper already slept; bump the inter-page delay to
|
||||
// prevent the next page from immediately re-triggering.
|
||||
currentDelay = Math.min(currentDelay * 2, 30_000);
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), newDelay: currentDelay, totalScanned },
|
||||
"FLOOD_WAIT persisted after retries — increasing inter-page delay and retrying"
|
||||
);
|
||||
// Sleep the full flood wait duration + jitter before continuing
|
||||
const jitter = 1000 + Math.random() * 4000;
|
||||
await sleep(waitSec * 1000 + jitter);
|
||||
continue; // retry this page with the new delay
|
||||
}
|
||||
throw err; // non-rate-limit error — propagate
|
||||
}
|
||||
|
||||
// Successful call — gradually relax the delay back toward baseline
|
||||
if (currentDelay > config.apiDelayMs) {
|
||||
currentDelay = Math.max(config.apiDelayMs, Math.floor(currentDelay * 0.8));
|
||||
}
|
||||
|
||||
if (!result.messages || result.messages.length === 0) break;
|
||||
|
||||
totalScanned += result.messages.length;
|
||||
|
||||
for (const msg of result.messages) {
|
||||
// Check for archive documents
|
||||
const doc = msg.content?.document;
|
||||
if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) {
|
||||
archives.push({
|
||||
id: BigInt(msg.id),
|
||||
fileName: doc.file_name,
|
||||
fileId: String(doc.document.id),
|
||||
fileSize: BigInt(doc.document.size),
|
||||
date: new Date(msg.date * 1000),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for photo messages (potential previews)
|
||||
const photo = msg.content?.photo;
|
||||
const caption = msg.content?.caption?.text ?? "";
|
||||
if (photo?.sizes && photo.sizes.length > 0) {
|
||||
const smallest = photo.sizes[0];
|
||||
photos.push({
|
||||
id: BigInt(msg.id),
|
||||
date: new Date(msg.date * 1000),
|
||||
caption,
|
||||
fileId: String(smallest.photo.id),
|
||||
fileSize: smallest.photo.size || smallest.photo.expected_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Report scanning progress after each page
|
||||
onProgress?.(totalScanned);
|
||||
|
||||
currentFromId = result.messages[result.messages.length - 1].id;
|
||||
|
||||
// Stuck detection: if from_message_id didn't advance, break to prevent infinite loop
|
||||
if (currentFromId === previousFromId) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), currentFromId, totalScanned },
|
||||
"Pagination stuck (from_message_id not advancing), breaking"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Stop scanning once we've gone past the boundary (this page is the lookback)
|
||||
if (boundary && currentFromId < boundary) break;
|
||||
|
||||
if (result.messages.length < Math.min(limit, 100)) break;
|
||||
|
||||
// Rate limit delay — adaptive based on FLOOD_WAIT history
|
||||
await sleep(currentDelay);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ chatId: chatId.toString(), archives: archives.length, photos: photos.length, totalScanned, pages: pageCount },
|
||||
"Channel scan complete"
|
||||
);
|
||||
|
||||
// Reverse to chronological order (oldest first) so worker processes old→new
|
||||
return {
|
||||
archives: archives.reverse(),
|
||||
photos: photos.reverse(),
|
||||
totalScanned,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
You will also need to add the import for `extractFloodWaitSeconds` at the top of `download.ts`:
|
||||
|
||||
```typescript
|
||||
import { withFloodWait, extractFloodWaitSeconds } from "../util/retry.js";
|
||||
```
|
||||
|
||||
### Fix 2: Apply the same pattern to `getTopicMessages` (`worker/src/tdlib/topics.ts`)
|
||||
|
||||
The same adaptive delay logic should be applied to the `getTopicMessages` function. Add the import:
|
||||
|
||||
```typescript
|
||||
import { extractFloodWaitSeconds } from "../util/retry.js";
|
||||
```
|
||||
|
||||
Then apply the same changes to the pagination loop (the structure is identical):
|
||||
|
||||
```typescript
|
||||
export async function getTopicMessages(
|
||||
client: Client,
|
||||
chatId: bigint,
|
||||
topicId: bigint,
|
||||
lastProcessedMessageId?: bigint | null,
|
||||
limit = 100,
|
||||
onProgress?: ScanProgressCallback
|
||||
): Promise<ChannelScanResult> {
|
||||
const archives: TelegramMessage[] = [];
|
||||
const photos: TelegramPhoto[] = [];
|
||||
const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null;
|
||||
|
||||
let currentFromId = 0;
|
||||
let totalScanned = 0;
|
||||
let pageCount = 0;
|
||||
let currentDelay = config.apiDelayMs;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (pageCount >= MAX_SCAN_PAGES) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), pageCount, totalScanned },
|
||||
"Hit max page limit for topic scan, stopping"
|
||||
);
|
||||
break;
|
||||
}
|
||||
pageCount++;
|
||||
|
||||
const previousFromId = currentFromId;
|
||||
|
||||
let result: {
|
||||
messages?: {
|
||||
id: number;
|
||||
date: number;
|
||||
content: {
|
||||
_: string;
|
||||
document?: {
|
||||
file_name?: string;
|
||||
document?: {
|
||||
id: number;
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
photo?: {
|
||||
sizes?: {
|
||||
type: string;
|
||||
photo: { id: number; size: number; expected_size: number };
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
};
|
||||
caption?: { text?: string };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
try {
|
||||
result = await invokeWithTimeout(client, {
|
||||
_: "searchChatMessages",
|
||||
chat_id: Number(chatId),
|
||||
query: "",
|
||||
message_thread_id: Number(topicId),
|
||||
from_message_id: currentFromId,
|
||||
offset: 0,
|
||||
limit: Math.min(limit, 100),
|
||||
filter: null,
|
||||
sender_id: null,
|
||||
saved_messages_topic_id: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
const waitSec = extractFloodWaitSeconds(err);
|
||||
if (waitSec !== null) {
|
||||
currentDelay = Math.min(currentDelay * 2, 30_000);
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), newDelay: currentDelay, totalScanned },
|
||||
"FLOOD_WAIT persisted after retries — increasing inter-page delay and retrying"
|
||||
);
|
||||
const jitter = 1000 + Math.random() * 4000;
|
||||
await sleep(waitSec * 1000 + jitter);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Successful call — gradually relax the delay back toward baseline
|
||||
if (currentDelay > config.apiDelayMs) {
|
||||
currentDelay = Math.max(config.apiDelayMs, Math.floor(currentDelay * 0.8));
|
||||
}
|
||||
|
||||
if (!result.messages || result.messages.length === 0) break;
|
||||
|
||||
totalScanned += result.messages.length;
|
||||
|
||||
for (const msg of result.messages) {
|
||||
const doc = msg.content?.document;
|
||||
if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) {
|
||||
archives.push({
|
||||
id: BigInt(msg.id),
|
||||
fileName: doc.file_name,
|
||||
fileId: String(doc.document.id),
|
||||
fileSize: BigInt(doc.document.size),
|
||||
date: new Date(msg.date * 1000),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const photo = msg.content?.photo;
|
||||
const caption = msg.content?.caption?.text ?? "";
|
||||
if (photo?.sizes && photo.sizes.length > 0) {
|
||||
const smallest = photo.sizes[0];
|
||||
photos.push({
|
||||
id: BigInt(msg.id),
|
||||
date: new Date(msg.date * 1000),
|
||||
caption,
|
||||
fileId: String(smallest.photo.id),
|
||||
fileSize: smallest.photo.size || smallest.photo.expected_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(totalScanned);
|
||||
|
||||
currentFromId = result.messages[result.messages.length - 1].id;
|
||||
|
||||
if (currentFromId === previousFromId) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), currentFromId, totalScanned },
|
||||
"Topic pagination stuck (from_message_id not advancing), breaking"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (boundary && currentFromId < boundary) break;
|
||||
|
||||
if (result.messages.length < Math.min(limit, 100)) break;
|
||||
|
||||
await sleep(currentDelay);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), archives: archives.length, photos: photos.length, totalScanned, pages: pageCount },
|
||||
"Topic scan complete"
|
||||
);
|
||||
|
||||
return {
|
||||
archives: archives.reverse(),
|
||||
photos: photos.reverse(),
|
||||
totalScanned,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Skill Patterns Applied
|
||||
|
||||
### 1. FLOOD_WAIT Handling (Skill: "The Right Way to Handle It")
|
||||
|
||||
The existing `withFloodWait` and `extractFloodWaitSeconds` in `worker/src/util/retry.ts` already implement the skill's recommended pattern verbatim -- extract wait duration, add 1-5s jitter, retry up to maxRetries. The fix reuses `extractFloodWaitSeconds` at the pagination loop level as a second layer of defense.
|
||||
|
||||
### 2. Paginated Scanning with Delay (Skill: "Pattern: Paginated Scanning with Delay")
|
||||
|
||||
The skill states: *"When reading channel history or enumerating topics, always add a delay between pages"* and shows a 1-second delay example. The existing code has this (`config.apiDelayMs = 1000`). The fix enhances this with adaptive backoff: the delay doubles when FLOOD_WAIT is encountered and gradually relaxes back to baseline on success.
|
||||
|
||||
### 3. Non-rate-limit Errors Should Fail Fast (Skill: "Key Rules")
|
||||
|
||||
The skill states: *"Non-rate-limit errors should fail fast. Only retry on FLOOD_WAIT, not on other errors."* The fix checks `extractFloodWaitSeconds` and only applies the pagination-level recovery for rate limit errors. All other errors propagate immediately via `throw err`.
|
||||
|
||||
### 4. Always Respect the Wait Duration (Skill: "Key Rules")
|
||||
|
||||
The skill states: *"Always respect the wait duration. Never retry before retry_after expires."* The fix sleeps for the full `waitSec * 1000 + jitter` before retrying the page, ensuring the mandatory pause is honored.
|
||||
|
||||
### 5. Add Jitter (Skill: "Key Rules")
|
||||
|
||||
The skill states: *"Add jitter. Without it, multiple clients retry simultaneously and trigger another FLOOD_WAIT."* Both the existing `withFloodWait` wrapper and the new pagination-level recovery use `1000 + Math.random() * 4000` jitter, consistent with the skill's recommendation.
|
||||
|
||||
## Files Affected
|
||||
|
||||
- `worker/src/tdlib/download.ts` -- `getChannelMessages` function (adaptive delay + pagination-level FLOOD_WAIT recovery)
|
||||
- `worker/src/tdlib/topics.ts` -- `getTopicMessages` function (same fix)
|
||||
|
||||
## Summary
|
||||
|
||||
The crash happens because the pagination loop fires 100+ consecutive `getChatHistory` calls at 1-second intervals. When FLOOD_WAIT triggers, `withFloodWait` sleeps and retries that single call, but the loop immediately resumes its aggressive cadence, re-triggering FLOOD_WAIT until retries are exhausted. The fix adds adaptive backoff at the pagination level: double the inter-page delay on each FLOOD_WAIT, gradually relax on success, and add a pagination-level catch that respects the full wait duration before retrying.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"total_tokens": 63079,
|
||||
"duration_ms": 140851,
|
||||
"total_duration_seconds": 140.9
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"run_id": "eval-2-without_skill",
|
||||
"expectations": [
|
||||
{"text": "identifies_retry_as_fix", "passed": true, "evidence": "Correctly identifies that invokeWithTimeout already wraps calls in withFloodWait. Adds adaptive backoff at pagination level."},
|
||||
{"text": "recommends_pagination_delay", "passed": true, "evidence": "Raises default apiDelayMs from 1000 to 2000 and adds adaptive backoff pattern."},
|
||||
{"text": "respects_retry_after", "passed": true, "evidence": "Uses extractFloodWaitSeconds to get actual wait. However, the catch block throws immediately after logging — doesn't sleep the flood wait at pagination level before retry like the with-skill version does."},
|
||||
{"text": "does_not_reduce_frequency_as_primary_fix", "passed": true, "evidence": "Primary fix is adaptive backoff + raised defaults, not reducing scan frequency."}
|
||||
]
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
# Diagnosis: FLOOD_WAIT_35 Crashes During Channel Message Pagination
|
||||
|
||||
## Root Cause
|
||||
|
||||
The `getChannelMessages` function in `worker/src/tdlib/download.ts` (line 146) paginates through a channel's entire history using `getChatHistory`. For a channel with ~10,000 messages at 100 messages per page, that is **100 consecutive API calls** in a tight loop.
|
||||
|
||||
The current rate-limit mitigation has two problems:
|
||||
|
||||
1. **The inter-page delay is too low.** `config.apiDelayMs` is hardcoded to **1000ms** (1 second) in `worker/src/util/config.ts:15`. Telegram's rate limiter for `getChatHistory` typically allows roughly one call every 1-2 seconds for moderate volumes, but when you are hammering it 100 times in a row the server starts issuing `FLOOD_WAIT` penalties. A 1-second fixed delay is not enough for sustained high-volume pagination.
|
||||
|
||||
2. **The pagination call (`getChatHistory`) does NOT use the `withFloodWait` retry wrapper.** Look at `download.ts:174` -- it calls `invokeWithTimeout`, which **does** wrap the call with `withFloodWait`. So the retry logic IS present. However, the retry wrapper in `worker/src/util/retry.ts` has `maxRetries` set to **5** (from config). When you are scanning 10,000 messages, you may hit FLOOD_WAIT multiple times across different pages, and each individual page gets only 5 retries. If Telegram escalates the wait time (e.g., FLOOD_WAIT_35 means "wait 35 seconds"), the retry logic does handle it -- but the real problem is that the **fixed 1-second inter-page delay is too aggressive**, causing FLOOD_WAIT errors to pile up on nearly every page in the latter half of the scan. Eventually one page exhausts its 5 retries and the entire scan crashes.
|
||||
|
||||
3. **No adaptive/exponential backoff between pages.** After successfully recovering from a FLOOD_WAIT, the code immediately goes back to the 1-second delay for the next page, triggering another FLOOD_WAIT almost instantly. There is no mechanism to slow down after being rate-limited.
|
||||
|
||||
## The Fix
|
||||
|
||||
The fix has three parts:
|
||||
|
||||
### Part 1: Make `apiDelayMs` configurable and increase the default
|
||||
|
||||
**File: `worker/src/util/config.ts`**
|
||||
|
||||
```typescript
|
||||
export const config = {
|
||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||
workerIntervalMinutes: parseInt(process.env.WORKER_INTERVAL_MINUTES ?? "60", 10),
|
||||
tempDir: process.env.WORKER_TEMP_DIR ?? "/tmp/zips",
|
||||
tdlibStateDir: process.env.TDLIB_STATE_DIR ?? "/data/tdlib",
|
||||
maxZipSizeMB: parseInt(process.env.WORKER_MAX_ZIP_SIZE_MB ?? "4096", 10),
|
||||
logLevel: (process.env.LOG_LEVEL ?? "info") as "debug" | "info" | "warn" | "error",
|
||||
telegramApiId: parseInt(process.env.TELEGRAM_API_ID ?? "0", 10),
|
||||
telegramApiHash: process.env.TELEGRAM_API_HASH ?? "",
|
||||
/** Maximum jitter added to scheduler interval (in minutes) */
|
||||
jitterMinutes: 5,
|
||||
/** Maximum time span for multipart archive parts (in hours). 0 = no limit. */
|
||||
multipartTimeoutHours: parseInt(process.env.MULTIPART_TIMEOUT_HOURS ?? "0", 10),
|
||||
/** Delay between Telegram API calls (in ms) to avoid rate limits */
|
||||
apiDelayMs: parseInt(process.env.WORKER_API_DELAY_MS ?? "2000", 10),
|
||||
/** Max retries for rate-limited requests */
|
||||
maxRetries: parseInt(process.env.WORKER_MAX_RETRIES ?? "10", 10),
|
||||
} as const;
|
||||
```
|
||||
|
||||
Changes: default `apiDelayMs` raised from 1000 to **2000**, `maxRetries` raised from 5 to **10**, both now configurable via environment variables.
|
||||
|
||||
### Part 2: Add adaptive backoff to the pagination loops
|
||||
|
||||
When a FLOOD_WAIT is encountered and recovered from inside `invokeWithTimeout`/`withFloodWait`, the pagination loop should temporarily increase its inter-page delay to prevent immediately triggering another FLOOD_WAIT.
|
||||
|
||||
**File: `worker/src/tdlib/download.ts`** -- replace the `getChannelMessages` function:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Fetch messages from a channel, stopping once we've scanned past the
|
||||
* last-processed boundary (with one page of lookback for multipart safety).
|
||||
* Collects both archive attachments AND photo messages (for preview matching).
|
||||
* Returns messages in chronological order (oldest first).
|
||||
*
|
||||
* When `lastProcessedMessageId` is null (first run), scans everything.
|
||||
* The worker applies a post-grouping filter to skip fully-processed sets,
|
||||
* and keeps `packageExistsBySourceMessage` as a safety net.
|
||||
*
|
||||
* Safety features:
|
||||
* - Max page limit to prevent infinite loops
|
||||
* - Stuck detection: breaks if from_message_id stops advancing
|
||||
* - Timeout on each TDLib API call
|
||||
* - Adaptive backoff: increases delay after FLOOD_WAIT recovery
|
||||
*/
|
||||
export async function getChannelMessages(
|
||||
client: Client,
|
||||
chatId: bigint,
|
||||
lastProcessedMessageId?: bigint | null,
|
||||
limit = 100,
|
||||
onProgress?: ScanProgressCallback
|
||||
): Promise<ChannelScanResult> {
|
||||
const archives: TelegramMessage[] = [];
|
||||
const photos: TelegramPhoto[] = [];
|
||||
const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null;
|
||||
|
||||
let currentFromId = 0;
|
||||
let totalScanned = 0;
|
||||
let pageCount = 0;
|
||||
|
||||
// Adaptive delay: starts at config value, increases after FLOOD_WAIT recovery
|
||||
let currentDelayMs = config.apiDelayMs;
|
||||
const MAX_DELAY_MS = 30_000; // Cap at 30 seconds between pages
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (pageCount >= MAX_SCAN_PAGES) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), pageCount, totalScanned },
|
||||
"Hit max page limit for channel scan, stopping"
|
||||
);
|
||||
break;
|
||||
}
|
||||
pageCount++;
|
||||
|
||||
const previousFromId = currentFromId;
|
||||
|
||||
let result: { messages: TdMessage[] };
|
||||
try {
|
||||
result = await invokeWithTimeout<{ messages: TdMessage[] }>(client, {
|
||||
_: "getChatHistory",
|
||||
chat_id: Number(chatId),
|
||||
from_message_id: currentFromId,
|
||||
offset: 0,
|
||||
limit: Math.min(limit, 100),
|
||||
only_local: false,
|
||||
});
|
||||
|
||||
// Successful call without rate limiting — gradually reduce delay back
|
||||
// toward the base value (but never below it)
|
||||
if (currentDelayMs > config.apiDelayMs) {
|
||||
currentDelayMs = Math.max(
|
||||
config.apiDelayMs,
|
||||
Math.floor(currentDelayMs * 0.8)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// If withFloodWait inside invokeWithTimeout exhausted retries on a
|
||||
// FLOOD_WAIT error, increase the inter-page delay significantly
|
||||
// before re-throwing so the caller (or a future retry of the whole
|
||||
// scan) starts slower.
|
||||
const floodSeconds = extractFloodWaitSeconds(err);
|
||||
if (floodSeconds !== null) {
|
||||
// The retry wrapper already waited, but we still got rate-limited
|
||||
// after max retries. Double the inter-page delay for next time.
|
||||
currentDelayMs = Math.min(MAX_DELAY_MS, currentDelayMs * 2);
|
||||
log.warn(
|
||||
{
|
||||
chatId: chatId.toString(),
|
||||
pageCount,
|
||||
totalScanned,
|
||||
newDelayMs: currentDelayMs,
|
||||
},
|
||||
"FLOOD_WAIT exhausted retries — increased inter-page delay"
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!result.messages || result.messages.length === 0) break;
|
||||
|
||||
totalScanned += result.messages.length;
|
||||
|
||||
for (const msg of result.messages) {
|
||||
// Check for archive documents
|
||||
const doc = msg.content?.document;
|
||||
if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) {
|
||||
archives.push({
|
||||
id: BigInt(msg.id),
|
||||
fileName: doc.file_name,
|
||||
fileId: String(doc.document.id),
|
||||
fileSize: BigInt(doc.document.size),
|
||||
date: new Date(msg.date * 1000),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for photo messages (potential previews)
|
||||
const photo = msg.content?.photo;
|
||||
const caption = msg.content?.caption?.text ?? "";
|
||||
if (photo?.sizes && photo.sizes.length > 0) {
|
||||
const smallest = photo.sizes[0];
|
||||
photos.push({
|
||||
id: BigInt(msg.id),
|
||||
date: new Date(msg.date * 1000),
|
||||
caption,
|
||||
fileId: String(smallest.photo.id),
|
||||
fileSize: smallest.photo.size || smallest.photo.expected_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Report scanning progress after each page
|
||||
onProgress?.(totalScanned);
|
||||
|
||||
currentFromId = result.messages[result.messages.length - 1].id;
|
||||
|
||||
// Stuck detection: if from_message_id didn't advance, break to prevent infinite loop
|
||||
if (currentFromId === previousFromId) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), currentFromId, totalScanned },
|
||||
"Pagination stuck (from_message_id not advancing), breaking"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Stop scanning once we've gone past the boundary (this page is the lookback)
|
||||
if (boundary && currentFromId < boundary) break;
|
||||
|
||||
if (result.messages.length < Math.min(limit, 100)) break;
|
||||
|
||||
// Rate limit delay (adaptive)
|
||||
await sleep(currentDelayMs);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ chatId: chatId.toString(), archives: archives.length, photos: photos.length, totalScanned, pages: pageCount },
|
||||
"Channel scan complete"
|
||||
);
|
||||
|
||||
// Reverse to chronological order (oldest first) so worker processes old→new
|
||||
return {
|
||||
archives: archives.reverse(),
|
||||
photos: photos.reverse(),
|
||||
totalScanned,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
You also need to add the import for `extractFloodWaitSeconds` at the top of `download.ts`:
|
||||
|
||||
```typescript
|
||||
import { withFloodWait, extractFloodWaitSeconds } from "../util/retry.js";
|
||||
```
|
||||
|
||||
### Part 3: Apply the same adaptive backoff to `getTopicMessages` in `topics.ts`
|
||||
|
||||
The same pattern should be applied to `worker/src/tdlib/topics.ts` in the `getTopicMessages` function (and `getForumTopicList` for good measure). The change is identical in structure: track `currentDelayMs`, reduce it on success, and note the escalation path on FLOOD_WAIT errors.
|
||||
|
||||
**File: `worker/src/tdlib/topics.ts`** -- add the import and apply to both pagination loops. Here is the updated `getTopicMessages`:
|
||||
|
||||
```typescript
|
||||
import { extractFloodWaitSeconds } from "../util/retry.js";
|
||||
|
||||
// ... (existing code) ...
|
||||
|
||||
export async function getTopicMessages(
|
||||
client: Client,
|
||||
chatId: bigint,
|
||||
topicId: bigint,
|
||||
lastProcessedMessageId?: bigint | null,
|
||||
limit = 100,
|
||||
onProgress?: ScanProgressCallback
|
||||
): Promise<ChannelScanResult> {
|
||||
const archives: TelegramMessage[] = [];
|
||||
const photos: TelegramPhoto[] = [];
|
||||
const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null;
|
||||
|
||||
let currentFromId = 0;
|
||||
let totalScanned = 0;
|
||||
let pageCount = 0;
|
||||
|
||||
// Adaptive delay
|
||||
let currentDelayMs = config.apiDelayMs;
|
||||
const MAX_DELAY_MS = 30_000;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
if (pageCount >= MAX_SCAN_PAGES) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), pageCount, totalScanned },
|
||||
"Hit max page limit for topic scan, stopping"
|
||||
);
|
||||
break;
|
||||
}
|
||||
pageCount++;
|
||||
|
||||
const previousFromId = currentFromId;
|
||||
|
||||
let result: {
|
||||
messages?: {
|
||||
id: number;
|
||||
date: number;
|
||||
content: {
|
||||
_: string;
|
||||
document?: {
|
||||
file_name?: string;
|
||||
document?: { id: number; size: number };
|
||||
};
|
||||
photo?: {
|
||||
sizes?: {
|
||||
type: string;
|
||||
photo: { id: number; size: number; expected_size: number };
|
||||
width: number;
|
||||
height: number;
|
||||
}[];
|
||||
};
|
||||
caption?: { text?: string };
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
try {
|
||||
result = await invokeWithTimeout(client, {
|
||||
_: "searchChatMessages",
|
||||
chat_id: Number(chatId),
|
||||
query: "",
|
||||
message_thread_id: Number(topicId),
|
||||
from_message_id: currentFromId,
|
||||
offset: 0,
|
||||
limit: Math.min(limit, 100),
|
||||
filter: null,
|
||||
sender_id: null,
|
||||
saved_messages_topic_id: 0,
|
||||
});
|
||||
|
||||
// Gradually reduce delay back toward base after success
|
||||
if (currentDelayMs > config.apiDelayMs) {
|
||||
currentDelayMs = Math.max(
|
||||
config.apiDelayMs,
|
||||
Math.floor(currentDelayMs * 0.8)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const floodSeconds = extractFloodWaitSeconds(err);
|
||||
if (floodSeconds !== null) {
|
||||
currentDelayMs = Math.min(MAX_DELAY_MS, currentDelayMs * 2);
|
||||
log.warn(
|
||||
{
|
||||
chatId: chatId.toString(),
|
||||
topicId: topicId.toString(),
|
||||
pageCount,
|
||||
totalScanned,
|
||||
newDelayMs: currentDelayMs,
|
||||
},
|
||||
"FLOOD_WAIT exhausted retries — increased inter-page delay"
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!result.messages || result.messages.length === 0) break;
|
||||
|
||||
totalScanned += result.messages.length;
|
||||
|
||||
for (const msg of result.messages) {
|
||||
const doc = msg.content?.document;
|
||||
if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) {
|
||||
archives.push({
|
||||
id: BigInt(msg.id),
|
||||
fileName: doc.file_name,
|
||||
fileId: String(doc.document.id),
|
||||
fileSize: BigInt(doc.document.size),
|
||||
date: new Date(msg.date * 1000),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const photo = msg.content?.photo;
|
||||
const caption = msg.content?.caption?.text ?? "";
|
||||
if (photo?.sizes && photo.sizes.length > 0) {
|
||||
const smallest = photo.sizes[0];
|
||||
photos.push({
|
||||
id: BigInt(msg.id),
|
||||
date: new Date(msg.date * 1000),
|
||||
caption,
|
||||
fileId: String(smallest.photo.id),
|
||||
fileSize: smallest.photo.size || smallest.photo.expected_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(totalScanned);
|
||||
|
||||
currentFromId = result.messages[result.messages.length - 1].id;
|
||||
|
||||
if (currentFromId === previousFromId) {
|
||||
log.warn(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), currentFromId, totalScanned },
|
||||
"Topic pagination stuck (from_message_id not advancing), breaking"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (boundary && currentFromId < boundary) break;
|
||||
|
||||
if (result.messages.length < Math.min(limit, 100)) break;
|
||||
|
||||
// Adaptive rate limit delay
|
||||
await sleep(currentDelayMs);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ chatId: chatId.toString(), topicId: topicId.toString(), archives: archives.length, photos: photos.length, totalScanned, pages: pageCount },
|
||||
"Topic scan complete"
|
||||
);
|
||||
|
||||
return {
|
||||
archives: archives.reverse(),
|
||||
photos: photos.reverse(),
|
||||
totalScanned,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change | Why |
|
||||
|---|---|---|
|
||||
| `worker/src/util/config.ts` | Raise `apiDelayMs` default to 2000, `maxRetries` to 10; make both env-configurable | 1s delay is too aggressive for 100-page scans; 5 retries is too few for sustained scanning |
|
||||
| `worker/src/tdlib/download.ts` | Add adaptive backoff to `getChannelMessages` loop; import `extractFloodWaitSeconds` | After FLOOD_WAIT recovery, the next page should wait longer, not immediately go back to the base delay |
|
||||
| `worker/src/tdlib/topics.ts` | Same adaptive backoff in `getTopicMessages` and `getForumTopicList` | Same vulnerability exists in topic scanning |
|
||||
|
||||
## Approach Explanation
|
||||
|
||||
The core insight is that `FLOOD_WAIT_35` is Telegram telling the client "you are calling me too fast, wait 35 seconds." The existing `withFloodWait` retry wrapper correctly handles individual occurrences by sleeping and retrying. But when scanning 10,000 messages (100 pages), the **loop itself** needs to adapt its pace. A fixed 1-second delay between pages is what causes the flood of FLOOD_WAIT errors in the first place.
|
||||
|
||||
The adaptive backoff strategy:
|
||||
- **On success:** gradually decrease the delay back toward the base value (multiply by 0.8), so scanning speeds back up once the rate limit pressure eases.
|
||||
- **On FLOOD_WAIT recovery (inside withFloodWait):** the retry wrapper handles it transparently -- the loop just sees a slower successful call and reduces delay.
|
||||
- **On FLOOD_WAIT exhausting retries:** double the inter-page delay (capped at 30s) before re-throwing, so if the scan is retried it starts slower.
|
||||
- **Higher base delay (2s):** prevents most FLOOD_WAIT errors from occurring in the first place for typical channel sizes.
|
||||
- **More retries (10):** gives the retry wrapper enough headroom to survive occasional rate limits during long scans without crashing.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"total_tokens": 45601,
|
||||
"duration_ms": 122326,
|
||||
"total_duration_seconds": 122.3
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"eval_id": 3,
|
||||
"eval_name": "download-and-reupload-file",
|
||||
"prompt": "Write a utility function for the worker that downloads a file from a source Telegram channel message and re-uploads it to the destination channel. It needs to handle files up to 2GB and work reliably in Docker.",
|
||||
"assertions": [
|
||||
{"text": "uses_flood_wait_retry: Download and upload calls wrapped in FLOOD_WAIT-aware retry logic", "type": "required"},
|
||||
{"text": "has_size_scaled_timeout: Timeout scales with file size, not a fixed constant", "type": "required"},
|
||||
{"text": "listener_before_action: Upload event listener attached BEFORE calling sendMessage", "type": "required"},
|
||||
{"text": "has_temp_cleanup: Temporary files cleaned up in a finally block", "type": "required"},
|
||||
{"text": "handles_2gb_limit: Acknowledges or handles the 2GB Telegram file size limit", "type": "recommended"}
|
||||
]
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"run_id": "eval-3-with_skill",
|
||||
"expectations": [
|
||||
{"text": "uses_flood_wait_retry", "passed": true, "evidence": "Delegates to existing downloadFile and uploadToChannel which internally use withFloodWait. Explicitly notes: 'All TDLib invocations go through withFloodWait via invokeWithTimeout'"},
|
||||
{"text": "has_size_scaled_timeout", "passed": true, "evidence": "Inherits from existing primitives: 'Timeouts scale with file size (10 minutes per GB, minimum 5 minutes for download, 10 minutes for upload)'. Explicitly cited from skill."},
|
||||
{"text": "listener_before_action", "passed": true, "evidence": "Explicitly noted in skill patterns table: 'Event Listener Before Action — Inherited from existing downloadFile and uploadToChannel'. The skill pattern is referenced."},
|
||||
{"text": "has_temp_cleanup", "passed": true, "evidence": "finally block with 'await rm(transferDir, { recursive: true, force: true })'. Uses randomUUID for isolation."},
|
||||
{"text": "handles_2gb_limit", "passed": true, "evidence": "MAX_TRANSFER_BYTES = 2n * 1024n * 1024n * 1024n. Upfront validation with clear error message."}
|
||||
]
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
# Download & Re-upload Utility for the Worker
|
||||
|
||||
## Approach
|
||||
|
||||
This utility function downloads a file from a source Telegram channel message and re-uploads it to a destination channel, returning the server-assigned message ID of the uploaded file. It is designed to slot into the existing worker codebase at `worker/src/tdlib/transfer.ts`.
|
||||
|
||||
The function composes the existing `downloadFile` (from `tdlib/download.ts`) and `uploadToChannel` (from `upload/channel.ts`) primitives, adding:
|
||||
|
||||
1. **Temporary file management** with guaranteed cleanup in a `finally` block
|
||||
2. **Size-scaled timeouts** for files up to 2 GB (the TDLib user account limit)
|
||||
3. **Cross-filesystem safety** via the existing `verifyAndMove` pattern (rename with EXDEV fallback)
|
||||
4. **FLOOD_WAIT-aware retries** by delegating to the existing `withFloodWait` wrapper on every TDLib invoke
|
||||
5. **Docker reliability**: uses `/tmp/zips` (the volume from the Dockerfile) for temp storage, ensures cleanup even on crash, and avoids holding large buffers in memory
|
||||
|
||||
## Skill Patterns Applied
|
||||
|
||||
| Skill Pattern | Where Applied |
|
||||
|---|---|
|
||||
| **FLOOD_WAIT handling** (`withFloodWait` + `extractFloodWaitSeconds`) | All TDLib invocations go through `withFloodWait` via `invokeWithTimeout` (download) and the upload internals |
|
||||
| **Download/Upload Timeouts** (scale with file size: 10 min/GB, min 5-10 min) | Inherited from existing `downloadFile` and `sendAndWaitForUpload` |
|
||||
| **Event Listener Before Action** | Inherited from existing `downloadFile` (subscribes to `updateFile` before calling `downloadFile` invoke) and `uploadToChannel` (subscribes before `sendMessage`) |
|
||||
| **TDLib Client Lifecycle** (`finally` block for cleanup) | The caller is expected to manage the client; this utility manages temp files in its own `finally` |
|
||||
| **File Size Limits** (User account TDLib: 2 GB upload/download) | Explicit 2 GB guard with clear error message |
|
||||
| **Sequential Send Queue** / no concurrent sends | Single sequential download-then-upload, rate limit delay between steps |
|
||||
| **BigInt Chat IDs** | Passes `Number(chatId)` to TDLib invoke calls (inherited from existing code) |
|
||||
| **Docker Considerations** | Uses the mounted `/tmp/zips` volume, per-operation subdirectory, guaranteed cleanup |
|
||||
| **Never bare `client.invoke()`** | All invocations go through `withFloodWait` wrappers |
|
||||
|
||||
## Full Implementation
|
||||
|
||||
File: `worker/src/tdlib/transfer.ts`
|
||||
|
||||
```typescript
|
||||
import path from "path";
|
||||
import { mkdir, rm, stat } from "fs/promises";
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Client } from "tdl";
|
||||
import { config } from "../util/config.js";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
import { downloadFile } from "./download.js";
|
||||
import type { DownloadProgress } from "./download.js";
|
||||
import { uploadToChannel } from "../upload/channel.js";
|
||||
|
||||
const log = childLogger("transfer");
|
||||
|
||||
/** Maximum file size TDLib user accounts can handle (2 GB). */
|
||||
const MAX_TRANSFER_BYTES = 2n * 1024n * 1024n * 1024n;
|
||||
|
||||
export interface TransferOptions {
|
||||
/** TDLib file ID (numeric string) of the file to download. */
|
||||
fileId: string;
|
||||
/** Original file name from the source message. */
|
||||
fileName: string;
|
||||
/** Expected file size in bytes. */
|
||||
fileSize: bigint;
|
||||
/** Telegram chat ID of the source channel (used only for logging context). */
|
||||
sourceChatId: bigint;
|
||||
/** Telegram chat ID of the destination channel. */
|
||||
destChatId: bigint;
|
||||
/** Optional caption to attach to the uploaded message. */
|
||||
caption?: string;
|
||||
/** Optional callback for download progress updates. */
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void;
|
||||
}
|
||||
|
||||
export interface TransferResult {
|
||||
/** Server-assigned message ID of the uploaded file in the destination channel. */
|
||||
destMessageId: bigint;
|
||||
/** Size of the transferred file in bytes (verified on disk after download). */
|
||||
transferredBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a source Telegram channel message and re-upload it
|
||||
* to a destination channel.
|
||||
*
|
||||
* This function:
|
||||
* 1. Validates the file size is within TDLib's 2 GB limit
|
||||
* 2. Downloads the file to a temporary directory (with progress tracking)
|
||||
* 3. Verifies the downloaded file's size matches the expected size
|
||||
* 4. Uploads the file to the destination channel
|
||||
* 5. Waits for Telegram's server-side upload confirmation
|
||||
* 6. Cleans up the temporary file (even on failure)
|
||||
*
|
||||
* All TDLib calls are wrapped with FLOOD_WAIT-aware retry logic.
|
||||
* Timeouts scale with file size (10 minutes per GB, minimum 5 minutes for
|
||||
* download, 10 minutes for upload).
|
||||
*
|
||||
* Designed for Docker: uses the configured temp directory (mounted volume at
|
||||
* /tmp/zips) and creates a unique subdirectory per transfer to avoid collisions
|
||||
* between concurrent operations.
|
||||
*
|
||||
* @throws Error if file exceeds 2 GB
|
||||
* @throws Error if download fails, times out, or size verification fails
|
||||
* @throws Error if upload fails or times out
|
||||
*/
|
||||
export async function downloadAndReupload(
|
||||
client: Client,
|
||||
options: TransferOptions
|
||||
): Promise<TransferResult> {
|
||||
const {
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize,
|
||||
sourceChatId,
|
||||
destChatId,
|
||||
caption,
|
||||
onDownloadProgress,
|
||||
} = options;
|
||||
|
||||
// ── Validate file size ──
|
||||
if (fileSize > MAX_TRANSFER_BYTES) {
|
||||
throw new Error(
|
||||
`File "${fileName}" is ${Number(fileSize / (1024n * 1024n))}MB, ` +
|
||||
`which exceeds the TDLib user account limit of 2 GB`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create isolated temp directory for this transfer ──
|
||||
const transferId = randomUUID();
|
||||
const transferDir = path.join(config.tempDir, `transfer_${transferId}`);
|
||||
await mkdir(transferDir, { recursive: true });
|
||||
|
||||
const tempFilePath = path.join(transferDir, fileName);
|
||||
|
||||
log.info(
|
||||
{
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize: Number(fileSize),
|
||||
sourceChatId: Number(sourceChatId),
|
||||
destChatId: Number(destChatId),
|
||||
transferDir,
|
||||
},
|
||||
"Starting download-and-reupload transfer"
|
||||
);
|
||||
|
||||
try {
|
||||
// ── Step 1: Download from source ──
|
||||
log.info({ fileId, fileName }, "Downloading file from source channel");
|
||||
|
||||
await downloadFile(
|
||||
client,
|
||||
fileId,
|
||||
tempFilePath,
|
||||
fileSize,
|
||||
fileName,
|
||||
onDownloadProgress
|
||||
);
|
||||
|
||||
// ── Step 2: Verify downloaded file ──
|
||||
const fileStats = await stat(tempFilePath);
|
||||
const actualBytes = fileStats.size;
|
||||
|
||||
if (Number(fileSize) > 0 && actualBytes !== Number(fileSize)) {
|
||||
throw new Error(
|
||||
`Downloaded file size mismatch for "${fileName}": ` +
|
||||
`expected ${Number(fileSize)} bytes, got ${actualBytes} bytes`
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ fileName, bytes: actualBytes },
|
||||
"Download complete, starting upload to destination"
|
||||
);
|
||||
|
||||
// ── Step 3: Upload to destination ──
|
||||
const uploadResult = await uploadToChannel(
|
||||
client,
|
||||
destChatId,
|
||||
[tempFilePath],
|
||||
caption
|
||||
);
|
||||
|
||||
log.info(
|
||||
{
|
||||
fileName,
|
||||
destMessageId: Number(uploadResult.messageId),
|
||||
destChatId: Number(destChatId),
|
||||
bytes: actualBytes,
|
||||
},
|
||||
"Transfer complete: file uploaded to destination channel"
|
||||
);
|
||||
|
||||
return {
|
||||
destMessageId: uploadResult.messageId,
|
||||
transferredBytes: actualBytes,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error(
|
||||
{
|
||||
err,
|
||||
fileId,
|
||||
fileName,
|
||||
sourceChatId: Number(sourceChatId),
|
||||
destChatId: Number(destChatId),
|
||||
},
|
||||
"Transfer failed"
|
||||
);
|
||||
throw err;
|
||||
} finally {
|
||||
// ── Always clean up temp files ──
|
||||
await rm(transferDir, { recursive: true, force: true }).catch((cleanupErr) => {
|
||||
log.warn(
|
||||
{ err: cleanupErr, transferDir },
|
||||
"Failed to clean up transfer temp directory"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that extracts the file info from a TDLib message object
|
||||
* and calls downloadAndReupload.
|
||||
*
|
||||
* Accepts the raw TDLib message shape (as returned by getChatHistory) so callers
|
||||
* don't need to manually extract document fields.
|
||||
*
|
||||
* @throws Error if the message does not contain a document attachment
|
||||
*/
|
||||
export async function transferMessageFile(
|
||||
client: Client,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
message: any,
|
||||
sourceChatId: bigint,
|
||||
destChatId: bigint,
|
||||
caption?: string,
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<TransferResult> {
|
||||
const doc = message?.content?.document;
|
||||
if (!doc?.document) {
|
||||
throw new Error(
|
||||
`Message ${message?.id} does not contain a document attachment`
|
||||
);
|
||||
}
|
||||
|
||||
return downloadAndReupload(client, {
|
||||
fileId: String(doc.document.id),
|
||||
fileName: doc.file_name ?? `file_${message.id}`,
|
||||
fileSize: BigInt(doc.document.size),
|
||||
sourceChatId,
|
||||
destChatId,
|
||||
caption,
|
||||
onDownloadProgress,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
Within the worker pipeline (e.g., in `worker.ts` or a new route handler):
|
||||
|
||||
```typescript
|
||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||
import { downloadAndReupload } from "./tdlib/transfer.js";
|
||||
|
||||
const client = await createTdlibClient({ id: account.id, phone: account.phone });
|
||||
try {
|
||||
const result = await downloadAndReupload(client, {
|
||||
fileId: "12345",
|
||||
fileName: "model_pack_v2.zip",
|
||||
fileSize: 1_500_000_000n, // ~1.4 GB
|
||||
sourceChatId: -1001234567890n,
|
||||
destChatId: -1009876543210n,
|
||||
caption: "Re-uploaded from source channel",
|
||||
onDownloadProgress: (progress) => {
|
||||
console.log(`Download: ${progress.percent}%`);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Uploaded as message ${result.destMessageId}`);
|
||||
} finally {
|
||||
await closeTdlibClient(client);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Delegates to existing primitives**: Rather than reimplementing download/upload logic, this composes `downloadFile` and `uploadToChannel` which already have FLOOD_WAIT retries, progress tracking, size-scaled timeouts, event-listener-before-action ordering, and upload confirmation via `updateMessageSendSucceeded`.
|
||||
|
||||
2. **Unique temp directory per transfer**: Uses `randomUUID()` to create an isolated subdirectory under `/tmp/zips`. This prevents filename collisions when multiple transfers run concurrently and ensures `rm -rf` in the `finally` block only removes this transfer's files.
|
||||
|
||||
3. **No in-memory buffering**: Files stream to disk via TDLib's native download mechanism, then upload from disk. This is essential for 2 GB files in Docker containers where memory is typically constrained.
|
||||
|
||||
4. **Two-phase verification**: The downloaded file size is verified both inside `downloadFile` (via `verifyAndMove`) and again before upload. This double-check catches edge cases where `verifyAndMove` might succeed but the file gets corrupted during the cross-device copy fallback.
|
||||
|
||||
5. **`transferMessageFile` convenience wrapper**: Extracts document metadata from raw TDLib message objects, so callers working with `getChatHistory` results don't need to manually navigate the nested content structure.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"total_tokens": 61157,
|
||||
"duration_ms": 122135,
|
||||
"total_duration_seconds": 122.1
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"run_id": "eval-3-without_skill",
|
||||
"expectations": [
|
||||
{"text": "uses_flood_wait_retry", "passed": true, "evidence": "Delegates to existing downloadFile and uploadToChannel which use withFloodWait internally. Notes 'Both are wrapped with withFloodWait internally'."},
|
||||
{"text": "has_size_scaled_timeout", "passed": true, "evidence": "Notes existing timeouts: '10 minutes per GB for both download and upload, with a minimum of 5 minutes for download and 10 minutes for upload'. Inherited from composed functions."},
|
||||
{"text": "listener_before_action", "passed": true, "evidence": "Mentions existing patterns in downloadFile and uploadToChannel handle this. But does NOT explicitly call it out as a critical concern or verify it."},
|
||||
{"text": "has_temp_cleanup", "passed": true, "evidence": "finally block with 'await unlink(tempPath)'. Uses tempSubdir for isolation."},
|
||||
{"text": "handles_2gb_limit", "passed": true, "evidence": "MAX_UPLOAD_BYTES = 2n * 1024n * 1024n * 1024n. Upfront validation with error directing to byteLevelSplit."}
|
||||
]
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
# Download and Re-upload Utility for the DragonsStash Worker
|
||||
|
||||
## Approach
|
||||
|
||||
The utility function `downloadAndReupload` bridges a source Telegram channel message to a destination channel by:
|
||||
|
||||
1. **Downloading** the file from the source message using TDLib's async `downloadFile` with `updateFile` progress events (the same pattern already used in `worker/src/tdlib/download.ts`).
|
||||
2. **Writing** the file to the worker's temp directory (`/tmp/zips` in Docker, configured via `WORKER_TEMP_DIR`).
|
||||
3. **Verifying** the downloaded file's size matches the expected size (integrity check).
|
||||
4. **Re-uploading** to the destination channel using TDLib's `sendMessage` + `updateMessageSendSucceeded` pattern (the same pattern already used in `worker/src/upload/channel.ts`).
|
||||
5. **Cleaning up** the temp file in a `finally` block to prevent disk exhaustion in Docker.
|
||||
|
||||
### Docker Considerations
|
||||
|
||||
- Uses the existing `/tmp/zips` volume mount (declared in the worker Dockerfile) so large files don't fill the container's overlay filesystem.
|
||||
- Handles cross-device rename (`EXDEV`) when moving files between TDLib's cache directory (`/data/tdlib`) and the temp directory, since these may be on different Docker volumes.
|
||||
- Timeouts scale with file size (10 minutes per GB for both download and upload), with a minimum of 5 minutes for download and 10 minutes for upload -- matching the existing patterns in the codebase.
|
||||
- All temp files are cleaned up in `finally` blocks even if the operation fails partway through.
|
||||
|
||||
### 2GB File Support
|
||||
|
||||
- TDLib's `downloadFile` with `synchronous: false` handles files up to 2GB natively. The existing codebase already has this pattern working.
|
||||
- Telegram's upload limit is 2GB per message. The function validates the file size upfront and rejects files exceeding this limit, directing callers to use the existing `byteLevelSplit` + `uploadToChannel` multi-part flow for oversized files.
|
||||
- Uses `bigint` for file sizes throughout (matching the codebase convention) to avoid JavaScript number precision issues near the 2GB boundary.
|
||||
|
||||
## File Location
|
||||
|
||||
This utility would be placed at `worker/src/tdlib/reupload.ts`, alongside the existing `download.ts` module.
|
||||
|
||||
## Full Code
|
||||
|
||||
```typescript
|
||||
// worker/src/tdlib/reupload.ts
|
||||
|
||||
import path from "path";
|
||||
import { mkdir, unlink, stat } from "fs/promises";
|
||||
import type { Client } from "tdl";
|
||||
import { config } from "../util/config.js";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
import { downloadFile } from "./download.ts";
|
||||
import type { DownloadProgress } from "./download.ts";
|
||||
import { uploadToChannel } from "../upload/channel.js";
|
||||
import type { UploadResult } from "../upload/channel.js";
|
||||
|
||||
const log = childLogger("reupload");
|
||||
|
||||
/** Maximum file size Telegram allows for a single upload (2 GB). */
|
||||
const MAX_UPLOAD_BYTES = 2n * 1024n * 1024n * 1024n;
|
||||
|
||||
export interface ReuploadOptions {
|
||||
/** TDLib client instance (must be authenticated). */
|
||||
client: Client;
|
||||
/** Telegram file ID (numeric string) from the source message. */
|
||||
fileId: string;
|
||||
/** Original file name. */
|
||||
fileName: string;
|
||||
/** Expected file size in bytes. */
|
||||
fileSize: bigint;
|
||||
/** Telegram chat ID of the destination channel. */
|
||||
destChatId: bigint;
|
||||
/** Optional caption for the re-uploaded message. */
|
||||
caption?: string;
|
||||
/** Optional callback for download progress. */
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void;
|
||||
/** Optional subdirectory name inside tempDir (to isolate concurrent operations). */
|
||||
tempSubdir?: string;
|
||||
}
|
||||
|
||||
export interface ReuploadResult {
|
||||
/** Server-assigned message ID in the destination channel. */
|
||||
destMessageId: bigint;
|
||||
/** Actual file size on disk after download (for verification logging). */
|
||||
actualBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a source Telegram channel message and re-upload it
|
||||
* to a destination channel.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validates file size is within Telegram's 2GB upload limit
|
||||
* 2. Downloads via TDLib async download with progress tracking
|
||||
* 3. Verifies file integrity (size match)
|
||||
* 4. Uploads to destination channel, waiting for server confirmation
|
||||
* 5. Cleans up the temp file
|
||||
*
|
||||
* For files larger than 2GB, callers should use the split + multi-part
|
||||
* upload flow in worker.ts instead.
|
||||
*
|
||||
* Docker notes:
|
||||
* - Uses WORKER_TEMP_DIR (/tmp/zips) which is a Docker volume, so large
|
||||
* files don't fill the overlay filesystem.
|
||||
* - Handles cross-device moves between TDLib's file cache (/data/tdlib)
|
||||
* and the temp directory.
|
||||
* - Temp files are always cleaned up, even on failure.
|
||||
*
|
||||
* @throws Error if fileSize exceeds 2GB (callers should split first)
|
||||
* @throws Error if download fails, times out, or produces a size mismatch
|
||||
* @throws Error if upload fails or times out
|
||||
*/
|
||||
export async function downloadAndReupload(
|
||||
opts: ReuploadOptions
|
||||
): Promise<ReuploadResult> {
|
||||
const {
|
||||
client,
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize,
|
||||
destChatId,
|
||||
caption,
|
||||
onDownloadProgress,
|
||||
tempSubdir,
|
||||
} = opts;
|
||||
|
||||
// ── Validate: reject files that exceed Telegram's upload limit ──
|
||||
if (fileSize > MAX_UPLOAD_BYTES) {
|
||||
throw new Error(
|
||||
`File "${fileName}" is ${Number(fileSize / (1024n * 1024n))}MB, ` +
|
||||
`which exceeds Telegram's 2GB upload limit. ` +
|
||||
`Use byteLevelSplit + uploadToChannel for oversized files.`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Prepare temp directory ──
|
||||
const workDir = tempSubdir
|
||||
? path.join(config.tempDir, tempSubdir)
|
||||
: config.tempDir;
|
||||
await mkdir(workDir, { recursive: true });
|
||||
|
||||
const tempPath = path.join(workDir, fileName);
|
||||
|
||||
log.info(
|
||||
{
|
||||
fileId,
|
||||
fileName,
|
||||
fileSize: Number(fileSize),
|
||||
destChatId: Number(destChatId),
|
||||
tempPath,
|
||||
},
|
||||
"Starting download-and-reupload"
|
||||
);
|
||||
|
||||
try {
|
||||
// ── Step 1: Download from source ──
|
||||
//
|
||||
// Uses the existing downloadFile which:
|
||||
// - Starts an async TDLib download (priority 32)
|
||||
// - Listens for updateFile events with progress tracking
|
||||
// - Verifies file size after completion
|
||||
// - Moves from TDLib cache to destPath (handles EXDEV cross-device)
|
||||
// - Times out at max(5min, 10min/GB)
|
||||
log.info({ fileId, fileName }, "Downloading from source channel");
|
||||
|
||||
await downloadFile(
|
||||
client,
|
||||
fileId,
|
||||
tempPath,
|
||||
fileSize,
|
||||
fileName,
|
||||
onDownloadProgress
|
||||
);
|
||||
|
||||
// Extra safety: verify file exists and has the right size
|
||||
const fileStats = await stat(tempPath);
|
||||
const actualBytes = fileStats.size;
|
||||
const expectedBytes = Number(fileSize);
|
||||
|
||||
if (expectedBytes > 0 && actualBytes !== expectedBytes) {
|
||||
throw new Error(
|
||||
`Downloaded file size mismatch for "${fileName}": ` +
|
||||
`expected ${expectedBytes} bytes, got ${actualBytes} bytes`
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ fileId, fileName, actualBytes },
|
||||
"Download complete, starting upload to destination"
|
||||
);
|
||||
|
||||
// ── Step 2: Upload to destination channel ──
|
||||
//
|
||||
// Uses the existing uploadToChannel which:
|
||||
// - Sends via sendMessage with inputFileLocal
|
||||
// - Listens for updateMessageSendSucceeded for the real server message ID
|
||||
// - Handles FLOOD_WAIT rate limits automatically
|
||||
// - Times out at max(10min, 10min/GB)
|
||||
const uploadResult: UploadResult = await uploadToChannel(
|
||||
client,
|
||||
destChatId,
|
||||
[tempPath],
|
||||
caption
|
||||
);
|
||||
|
||||
log.info(
|
||||
{
|
||||
fileId,
|
||||
fileName,
|
||||
destChatId: Number(destChatId),
|
||||
destMessageId: Number(uploadResult.messageId),
|
||||
actualBytes,
|
||||
},
|
||||
"Download-and-reupload completed successfully"
|
||||
);
|
||||
|
||||
return {
|
||||
destMessageId: uploadResult.messageId,
|
||||
actualBytes,
|
||||
};
|
||||
} finally {
|
||||
// ── Always clean up temp file ──
|
||||
// Critical in Docker to prevent /tmp/zips volume from filling up,
|
||||
// especially when processing many large files in sequence.
|
||||
try {
|
||||
await unlink(tempPath);
|
||||
log.debug({ tempPath }, "Cleaned up temp file");
|
||||
} catch {
|
||||
// File may not exist if download failed before writing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that downloads and re-uploads multiple files
|
||||
* (e.g., multipart archive parts) from a source channel to a destination.
|
||||
*
|
||||
* Each file is downloaded and uploaded sequentially with a rate-limit
|
||||
* delay between operations. Returns the message ID of the first upload
|
||||
* (matching the convention in uploadToChannel).
|
||||
*
|
||||
* For multipart sets where individual parts exceed 2GB, the caller
|
||||
* should use the full repack pipeline in worker.ts (concatenate +
|
||||
* byteLevelSplit) instead of this function.
|
||||
*/
|
||||
export async function downloadAndReuploadMultiple(
|
||||
client: Client,
|
||||
files: Array<{
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: bigint;
|
||||
}>,
|
||||
destChatId: bigint,
|
||||
caption?: string,
|
||||
onDownloadProgress?: (fileIndex: number, progress: DownloadProgress) => void,
|
||||
tempSubdir?: string
|
||||
): Promise<ReuploadResult> {
|
||||
if (files.length === 0) {
|
||||
throw new Error("No files provided for download-and-reupload");
|
||||
}
|
||||
|
||||
let firstResult: ReuploadResult | null = null;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Only set caption on the first file (matching uploadToChannel convention)
|
||||
const fileCaption = i === 0 ? caption : undefined;
|
||||
|
||||
const result = await downloadAndReupload({
|
||||
client,
|
||||
fileId: file.fileId,
|
||||
fileName: file.fileName,
|
||||
fileSize: file.fileSize,
|
||||
destChatId,
|
||||
caption: fileCaption,
|
||||
onDownloadProgress: onDownloadProgress
|
||||
? (progress) => onDownloadProgress(i, progress)
|
||||
: undefined,
|
||||
tempSubdir,
|
||||
});
|
||||
|
||||
if (i === 0) {
|
||||
firstResult = result;
|
||||
}
|
||||
|
||||
// Rate limit delay between operations
|
||||
if (i < files.length - 1) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, config.apiDelayMs)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return firstResult!;
|
||||
}
|
||||
```
|
||||
|
||||
## How It Fits Into the Existing Codebase
|
||||
|
||||
The function composes the two existing building blocks:
|
||||
|
||||
- **`downloadFile`** from `worker/src/tdlib/download.ts` -- handles async download with `updateFile` events, progress tracking, timeout, size verification, and cross-device file moves (critical for Docker where `/data/tdlib` and `/tmp/zips` are separate volumes).
|
||||
- **`uploadToChannel`** from `worker/src/upload/channel.ts` -- handles `sendMessage` with `updateMessageSendSucceeded` confirmation, upload progress via `updateFile`, and FLOOD_WAIT retry.
|
||||
|
||||
Both are wrapped with `withFloodWait` internally, so the utility automatically handles Telegram rate limiting.
|
||||
|
||||
The `downloadAndReupload` function is a focused utility that can be called from the worker pipeline or any other part of the system that needs to transfer a file between channels. The existing `processOneArchiveSet` in `worker.ts` does a similar download-then-upload flow but with additional steps (hashing, dedup, metadata extraction, splitting). This utility extracts the core transfer operation for simpler use cases.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import { downloadAndReupload } from "./tdlib/reupload.js";
|
||||
|
||||
// Transfer a single file from source to destination
|
||||
const result = await downloadAndReupload({
|
||||
client,
|
||||
fileId: "12345",
|
||||
fileName: "archive.zip",
|
||||
fileSize: 1_500_000_000n, // ~1.5 GB
|
||||
destChatId: -1001234567890n,
|
||||
caption: "Re-uploaded archive",
|
||||
onDownloadProgress: (progress) => {
|
||||
console.log(`Download: ${progress.percent}%`);
|
||||
},
|
||||
tempSubdir: `reupload_${Date.now()}`,
|
||||
});
|
||||
|
||||
console.log(`Uploaded as message ${result.destMessageId}`);
|
||||
```
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"total_tokens": 54587,
|
||||
"duration_ms": 146695,
|
||||
"total_duration_seconds": 146.7
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: tdlib-telegram
|
||||
description: >
|
||||
Reference guide for building Telegram integrations with TDLib (tdl/node).
|
||||
Covers rate limits, FLOOD_WAIT handling, file size constraints, bot vs user account
|
||||
differences, and safe code patterns. Use this skill whenever writing or modifying code
|
||||
that calls Telegram APIs via TDLib, the Bot API, or any Telegram client library — including
|
||||
sending messages, downloading/uploading files, scanning channels, forwarding messages,
|
||||
managing subscriptions, or handling notifications. Also use when debugging 429 errors,
|
||||
FLOOD_WAIT, or silent message drops.
|
||||
---
|
||||
|
||||
# TDLib / Telegram Development Guide
|
||||
|
||||
This skill provides the rate limits, constraints, and patterns you need to write correct
|
||||
Telegram integrations. The limits below come from official Telegram documentation and
|
||||
well-established community findings (Telegram does not publish exact numbers for all limits).
|
||||
|
||||
## Telegram Rate Limits
|
||||
|
||||
These are approximate safe boundaries. Telegram's actual limits are dynamic and depend on
|
||||
account age, history, and request type. The correct strategy is to respect these as guidelines
|
||||
and always handle FLOOD_WAIT errors gracefully.
|
||||
|
||||
### Bot Accounts
|
||||
|
||||
| Operation | Limit | Notes |
|
||||
|-----------|-------|-------|
|
||||
| Messages to same chat | ~1 msg/sec | Bursts OK, sustained exceeds limit |
|
||||
| Messages in a group | 20 msgs/min | Hard limit per group chat |
|
||||
| Bulk notifications (different users) | ~30 msgs/sec | Global across all chats |
|
||||
| Message edits in a group | ~20 edits/min | Community-observed |
|
||||
| API requests (global) | ~30 req/sec | All request types combined |
|
||||
| Paid broadcasts | up to 1000 msgs/sec | Requires Telegram Stars balance |
|
||||
|
||||
### User Accounts (TDLib)
|
||||
|
||||
| Operation | Limit | Notes |
|
||||
|-----------|-------|-------|
|
||||
| API requests (global) | ~30 req/sec | All request types combined |
|
||||
| Messages in a group | ~20 msgs/min | Same as bot |
|
||||
| Channel history reads | No published limit | But pagination + delay is essential |
|
||||
| Joining groups | Very strict | FLOOD_WAIT often 30-300+ seconds |
|
||||
|
||||
### File Size Limits
|
||||
|
||||
| Context | Upload | Download |
|
||||
|---------|--------|----------|
|
||||
| Bot API (standard) | 50 MB | 20 MB |
|
||||
| Bot API (local server) | 2,000 MB | 2,000 MB |
|
||||
| User account (TDLib) | 2 GB | 2 GB |
|
||||
| Premium user (TDLib) | 4 GB | 4 GB |
|
||||
|
||||
### Message & Content Limits
|
||||
|
||||
| Item | Limit |
|
||||
|------|-------|
|
||||
| Message text length | 4,096 chars |
|
||||
| Media caption | 1,024 chars (4,096 premium) |
|
||||
| Album / media group | 10 items max |
|
||||
| Forwarded messages per request | `forwarded_message_count_max` (TDLib option) |
|
||||
| Inline keyboard buttons | 100 entities |
|
||||
| Formatting entities per message | 100 |
|
||||
| Scheduled messages per chat | 100 |
|
||||
| Bot commands | 100 max |
|
||||
|
||||
### Forum & Group Limits
|
||||
|
||||
| Item | Limit |
|
||||
|------|-------|
|
||||
| Topics per group | 1,000,000 |
|
||||
| Topic title | 128 chars |
|
||||
| Group members | 200,000 |
|
||||
| Admins per group | 50 |
|
||||
| Bots per group | 20 |
|
||||
| Pinned topics | 5 |
|
||||
|
||||
## FLOOD_WAIT — How It Works
|
||||
|
||||
When you exceed rate limits, Telegram returns a `FLOOD_WAIT_X` error (or HTTP 429 with
|
||||
`retry_after`). This is a **mandatory pause** — the value `X` is the number of seconds you
|
||||
must wait before ANY request will succeed. It blocks the entire client, not just the
|
||||
operation that triggered it.
|
||||
|
||||
### The Right Way to Handle It
|
||||
|
||||
```typescript
|
||||
// Extract the wait duration from the error
|
||||
function extractFloodWaitSeconds(err: unknown): number | null {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Pattern 1: FLOOD_WAIT_30
|
||||
const flood = message.match(/FLOOD_WAIT_(\d+)/i);
|
||||
if (flood) return parseInt(flood[1], 10);
|
||||
|
||||
// Pattern 2: "retry after 30"
|
||||
const retry = message.match(/retry after (\d+)/i);
|
||||
if (retry) return parseInt(retry[1], 10);
|
||||
|
||||
// Pattern 3: HTTP 429 without explicit seconds
|
||||
if (String((err as any)?.code) === "429") return 30;
|
||||
|
||||
return null; // Not a rate limit error
|
||||
}
|
||||
|
||||
// Wrap any TDLib call with automatic retry
|
||||
async function withFloodWait<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const wait = extractFloodWaitSeconds(err);
|
||||
if (wait === null || attempt >= maxRetries) throw err;
|
||||
|
||||
// Add 1-5s jitter to prevent thundering herd
|
||||
const jitter = 1000 + Math.random() * 4000;
|
||||
await sleep(wait * 1000 + jitter);
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
```
|
||||
|
||||
### Key Rules
|
||||
|
||||
- **Always respect the wait duration.** Never retry before `retry_after` expires.
|
||||
- **Add jitter.** Without it, multiple clients retry simultaneously and trigger another FLOOD_WAIT.
|
||||
- **Non-rate-limit errors should fail fast.** Only retry on FLOOD_WAIT, not on other errors.
|
||||
- **Don't artificially throttle below ~1 req/sec.** Telegram's own guidance (via grammY docs)
|
||||
is to send requests as fast as you need and handle 429 errors. Fixed low-frequency throttling
|
||||
wastes throughput without preventing floods.
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Pattern: Sequential Send Queue
|
||||
|
||||
When sending notifications to multiple users, use a sequential queue with a per-message delay.
|
||||
Never fire concurrent sends — you will hit the 30 msg/sec global limit instantly.
|
||||
|
||||
```typescript
|
||||
let sendQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function queueSend(chatId: bigint, text: string): void {
|
||||
sendQueue = sendQueue
|
||||
.then(() => withFloodWait(() => sendTextMessage(chatId, text)))
|
||||
.then(() => sleep(50)) // ~20 msgs/sec, well under 30 limit
|
||||
.catch((err) => log.error({ err, chatId }, "Send failed"));
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Paginated Scanning with Delay
|
||||
|
||||
When reading channel history or enumerating topics, always add a delay between pages:
|
||||
|
||||
```typescript
|
||||
while (hasMorePages) {
|
||||
const result = await invokeWithTimeout(client, { _: "getChatHistory", ... });
|
||||
processMessages(result.messages);
|
||||
|
||||
if (result.messages.length < limit) break;
|
||||
|
||||
await sleep(1000); // 1 second between pages — prevents FLOOD_WAIT on large channels
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern: Event Listener Before Action
|
||||
|
||||
When waiting for TDLib async events (upload confirmation, download completion), always
|
||||
attach the event listener BEFORE starting the operation. If you attach after, fast
|
||||
operations can complete before the listener exists, causing the promise to hang forever.
|
||||
|
||||
```typescript
|
||||
// CORRECT: listener first, then action
|
||||
client.on("update", handleUpdate);
|
||||
const tempMsg = await client.invoke({ _: "sendMessage", ... });
|
||||
tempMsgId = tempMsg.id; // handler now knows which message to match
|
||||
|
||||
// WRONG: action first, then listener — race condition!
|
||||
const tempMsg = await client.invoke({ _: "sendMessage", ... });
|
||||
client.on("update", handleUpdate); // may miss updateMessageSendSucceeded
|
||||
```
|
||||
|
||||
### Pattern: Download/Upload Timeouts
|
||||
|
||||
Scale timeouts with file size. TDLib downloads/uploads are asynchronous — without a timeout,
|
||||
a stalled transfer hangs the entire pipeline.
|
||||
|
||||
```typescript
|
||||
const timeoutMs = Math.max(
|
||||
10 * 60_000, // minimum 10 minutes
|
||||
(fileSizeMB / 1024) * 10 * 60_000 // 10 minutes per GB
|
||||
);
|
||||
```
|
||||
|
||||
### Pattern: TDLib Client Lifecycle
|
||||
|
||||
Always close TDLib clients in a `finally` block. Unclosed clients leak memory and file
|
||||
descriptors, and can leave TDLib's internal database locked.
|
||||
|
||||
```typescript
|
||||
const client = await createTdlibClient(account);
|
||||
try {
|
||||
// ... use client ...
|
||||
} finally {
|
||||
await closeTdlibClient(client);
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Never: Concurrent TDLib Sends Without Queue
|
||||
|
||||
```typescript
|
||||
// BAD: fires all sends concurrently — will trigger FLOOD_WAIT immediately
|
||||
await Promise.all(users.map((u) => sendTextMessage(u.chatId, msg)));
|
||||
|
||||
// GOOD: sequential with delay
|
||||
for (const user of users) {
|
||||
await withFloodWait(() => sendTextMessage(user.chatId, msg));
|
||||
await sleep(50);
|
||||
}
|
||||
```
|
||||
|
||||
### Never: Bare client.invoke() Without Retry
|
||||
|
||||
Every `client.invoke()` call can return FLOOD_WAIT at any time. Bare calls will crash
|
||||
on rate limits instead of retrying.
|
||||
|
||||
```typescript
|
||||
// BAD: crashes on FLOOD_WAIT
|
||||
await client.invoke({ _: "sendMessage", ... });
|
||||
|
||||
// GOOD: retries automatically
|
||||
await withFloodWait(() => client.invoke({ _: "sendMessage", ... }));
|
||||
```
|
||||
|
||||
### Never: Retry Without Respecting retry_after
|
||||
|
||||
```typescript
|
||||
// BAD: fixed 1-second retry ignores Telegram's wait requirement
|
||||
catch (err) { await sleep(1000); retry(); }
|
||||
|
||||
// GOOD: extract and respect the actual wait time
|
||||
catch (err) {
|
||||
const wait = extractFloodWaitSeconds(err);
|
||||
if (wait !== null) await sleep(wait * 1000 + jitter);
|
||||
else throw err;
|
||||
}
|
||||
```
|
||||
|
||||
### Never: Ignore FLOOD_WAIT in Bots
|
||||
|
||||
Bot accounts get the same FLOOD_WAIT as user accounts. The bot API's 429 response
|
||||
blocks ALL operations for the specified duration — not just the chat that triggered it.
|
||||
A single unhandled flood in a notification loop can make the entire bot unresponsive.
|
||||
|
||||
## Bot vs User Account Differences
|
||||
|
||||
| Capability | Bot | User (TDLib) |
|
||||
|-----------|-----|-------------|
|
||||
| Read channel history | No (unless admin) | Yes |
|
||||
| Send to users who haven't started bot | No | N/A |
|
||||
| Join groups via invite link | No (must be added) | Yes |
|
||||
| Forward messages (send_copy) | Yes | Yes |
|
||||
| File upload limit | 50 MB (standard API) | 2 GB |
|
||||
| File download limit | 20 MB (standard API) | 2 GB |
|
||||
| Auth method | Bot token | Phone + SMS code |
|
||||
| Rate limit profile | Same FLOOD_WAIT | Same FLOOD_WAIT |
|
||||
|
||||
## TDLib-Specific Notes
|
||||
|
||||
### BigInt Chat IDs
|
||||
|
||||
TDLib uses numeric chat IDs. Supergroups and channels use negative IDs (e.g., `-1001234567890`).
|
||||
When passing to `client.invoke()`, convert with `Number(chatId)` — TDLib's JSON interface
|
||||
doesn't handle BigInt. Be aware that very large IDs may lose precision with `Number()`,
|
||||
though current Telegram IDs are within safe integer range.
|
||||
|
||||
### TDLib Options (Runtime Queryable)
|
||||
|
||||
These are read-only values you can query at runtime via `getOption`:
|
||||
- `message_text_length_max` — max message text length
|
||||
- `message_caption_length_max` — max caption length
|
||||
- `forwarded_message_count_max` — max forwards per request
|
||||
|
||||
### Session State
|
||||
|
||||
TDLib persists session state to disk. Each account needs its own state directory.
|
||||
Running two clients on the same state directory simultaneously will corrupt the database.
|
||||
Use separate directories per account, and separate volumes in Docker for worker vs bot.
|
||||
|
||||
## Docker Considerations
|
||||
|
||||
- **prebuilt-tdlib**: The `prebuilt-tdlib` npm package provides platform-specific TDLib
|
||||
binaries. Container base image must match (e.g., `node:20-bookworm-slim` for Debian x64).
|
||||
- **Volumes**: Mount persistent volumes for TDLib state directories — losing state forces
|
||||
full re-authentication.
|
||||
- **Graceful shutdown**: Wait for active operations to finish before closing DB connections.
|
||||
TDLib operations in flight will fail if the database pool is closed underneath them.
|
||||
- **Health checks**: TDLib services don't expose HTTP — use database connectivity as the
|
||||
health signal instead.
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"skill_name": "tdlib-telegram",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "Add a new bot command /broadcast that sends a text message to ALL users who have a TelegramLink in the database. The admin triggers it from the web app. Add it to the bot's command handler and create an API endpoint that triggers it.",
|
||||
"expected_output": "Code that uses a sequential send queue with withFloodWait wrapping each sendTextMessage call, a delay between sends (~50ms), and does NOT use Promise.all or concurrent sends. Should handle errors per-user without stopping the broadcast.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "The worker keeps crashing with 'FLOOD_WAIT_35' errors when scanning a source channel that has about 10,000 messages. It happens during the getChannelMessages pagination loop. How do I fix this?",
|
||||
"expected_output": "Diagnosis that the apiDelayMs between pages may be too low or the retry logic isn't wrapping the pagination calls. Should recommend ensuring all getChatHistory/searchChatMessages calls go through withFloodWait/invokeWithTimeout, and that sleep(config.apiDelayMs) exists between pages. Should NOT suggest reducing scan frequency as the primary fix.",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "Write a utility function for the worker that downloads a file from a source Telegram channel message and re-uploads it to the destination channel. It needs to handle files up to 2GB and work reliably in Docker.",
|
||||
"expected_output": "Code that: (1) wraps download in withFloodWait with size-scaled timeout, (2) attaches upload event listener BEFORE calling sendMessage, (3) uses temp directory with cleanup in finally block, (4) handles the 2GB Telegram limit correctly, (5) uses try/finally for client cleanup if applicable.",
|
||||
"files": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-and-deploy
|
||||
|
||||
trigger:
|
||||
branch: [main]
|
||||
event: [push]
|
||||
|
||||
steps:
|
||||
- name: build-app
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: git.samagsteribbe.nl/admin/dragonsstash
|
||||
registry: git.samagsteribbe.nl
|
||||
dockerfile: Dockerfile
|
||||
tags:
|
||||
- latest
|
||||
- "${DRONE_COMMIT_SHA:0:8}"
|
||||
build_args:
|
||||
- NEXT_PUBLIC_APP_URL=https://dragonsstash.samagsteribbe.nl
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_password
|
||||
|
||||
- name: build-worker
|
||||
image: plugins/docker
|
||||
depends_on: [clone]
|
||||
settings:
|
||||
repo: git.samagsteribbe.nl/admin/dragonsstash-worker
|
||||
registry: git.samagsteribbe.nl
|
||||
dockerfile: worker/Dockerfile
|
||||
tags:
|
||||
- latest
|
||||
- "${DRONE_COMMIT_SHA:0:8}"
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_password
|
||||
|
||||
- name: build-bot
|
||||
image: plugins/docker
|
||||
depends_on: [clone]
|
||||
settings:
|
||||
repo: git.samagsteribbe.nl/admin/dragonsstash-bot
|
||||
registry: git.samagsteribbe.nl
|
||||
dockerfile: bot/Dockerfile
|
||||
tags:
|
||||
- latest
|
||||
- "${DRONE_COMMIT_SHA:0:8}"
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_password
|
||||
|
||||
- name: build-backup
|
||||
image: plugins/docker
|
||||
depends_on: [clone]
|
||||
settings:
|
||||
repo: git.samagsteribbe.nl/admin/dragonsstash-backup
|
||||
registry: git.samagsteribbe.nl
|
||||
dockerfile: backup/Dockerfile
|
||||
tags:
|
||||
- latest
|
||||
- "${DRONE_COMMIT_SHA:0:8}"
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_password
|
||||
|
||||
- name: deploy
|
||||
image: alpine
|
||||
depends_on: [build-app, build-worker, build-bot, build-backup]
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: ssh_key
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- printf "%s" "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh-keyscan -t ed25519 192.168.68.68 > ~/.ssh/known_hosts 2>/dev/null
|
||||
- ssh sam@192.168.68.68 "cd /opt/stacks/DragonsStash && docker compose pull && docker compose up -d"
|
||||
@@ -36,3 +36,12 @@ TDLIB_STATE_DIR="/data/tdlib"
|
||||
WORKER_MAX_ZIP_SIZE_MB=4096
|
||||
MULTIPART_TIMEOUT_HOURS=0
|
||||
LOG_LEVEL="info"
|
||||
|
||||
# Backup (NAS via SMB/CIFS + restic)
|
||||
NAS_HOST="" # Synology NAS IP or hostname reachable from this host
|
||||
NAS_SHARE="" # SMB share name, e.g. dragonsstash_backups
|
||||
NAS_USERNAME="" # SMB user with read/write on the share
|
||||
NAS_PASSWORD="" # SMB user password (avoid commas — they delimit cifs mount opts)
|
||||
RESTIC_PASSWORD="" # generate with: openssl rand -base64 32
|
||||
KUMA_PUSH_URL="" # optional: Uptime Kuma Push monitor URL; leave empty to disable alerting
|
||||
TZ="Etc/UTC"
|
||||
|
||||
@@ -54,3 +54,4 @@ src/generated
|
||||
# temp files
|
||||
nul
|
||||
tmpclaude-*
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Dragon's Stash is a self-hosted inventory management system for 3D printing filament, SLA resin, miniature paints, and supplies. It includes an integrated Telegram archive worker that scans channels for ZIP/RAR archives, indexes their contents, and a bot that lets users search and receive packages via Telegram.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **App**: Next.js 16 (App Router), TypeScript 5.9 (strict), Tailwind CSS 4, shadcn/ui
|
||||
- **Database**: PostgreSQL 16+ via Prisma v7.4 with `@prisma/adapter-pg`
|
||||
- **Auth**: Auth.js v5 (NextAuth) with credentials + optional GitHub OAuth
|
||||
- **Worker**: TypeScript + TDLib (via `tdl`) for Telegram channel scanning
|
||||
- **Bot**: TypeScript + TDLib for Telegram bot interface
|
||||
- **Forms**: React Hook Form + Zod v4
|
||||
|
||||
## Commands
|
||||
|
||||
### App (root package.json)
|
||||
```bash
|
||||
npm run dev # Next.js dev server with hot reload
|
||||
npm run build # Production build (standalone output)
|
||||
npm run start # Production server
|
||||
npm run lint # ESLint (next/core-web-vitals + TypeScript)
|
||||
```
|
||||
|
||||
### Database
|
||||
```bash
|
||||
npm run db:generate # Generate Prisma client
|
||||
npm run db:migrate # Run migrations (dev mode)
|
||||
npm run db:push # Push schema without migrations
|
||||
npm run db:seed # Seed database with test data
|
||||
npm run db:studio # Prisma Studio UI
|
||||
npx prisma migrate dev --name <description> # Create new migration
|
||||
```
|
||||
|
||||
### Worker & Bot (each in their own directory)
|
||||
```bash
|
||||
cd worker && npm run dev # Dev mode with tsx watch
|
||||
cd worker && npm run build # TypeScript compile to dist/
|
||||
cd bot && npm run dev # Dev mode with tsx watch
|
||||
cd bot && npm run build # TypeScript compile to dist/
|
||||
```
|
||||
|
||||
### Dev Environment Setup
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d # Start PostgreSQL + worker
|
||||
npm run dev # Run app locally
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three-Service Design
|
||||
The project is split into three independent services sharing one PostgreSQL database:
|
||||
1. **App** (root `src/`): Next.js web UI for inventory management and Telegram admin
|
||||
2. **Worker** (`worker/`): Scans Telegram source channels, processes archives, uploads to destination channel
|
||||
3. **Bot** (`bot/`): Telegram bot for user search, package delivery, keyword subscriptions
|
||||
|
||||
Services communicate asynchronously via `pg_notify` (e.g., on-demand channel fetches, bot send requests).
|
||||
|
||||
### App Source Layout (`src/`)
|
||||
- `app/(auth)/` — Login/register pages (public)
|
||||
- `app/(app)/` — Protected routes behind auth middleware (dashboard, filaments, resins, paints, supplies, vendors, locations, settings, stls, telegram, usage)
|
||||
- `app/api/` — API routes (NextAuth, health check, bot endpoints)
|
||||
- `data/` — Server-side Prisma query functions (`*.queries.ts`), one file per domain model
|
||||
- `schemas/` — Zod validation schemas, one file per domain model
|
||||
- `components/ui/` — shadcn/ui primitives
|
||||
- `components/shared/` — Reusable business components (data-table, status-badge, color-swatch, stat-card, page-header)
|
||||
- `components/layout/` — Sidebar and header
|
||||
- `lib/` — Auth config, Prisma singleton, constants, utilities, Telegram query helpers
|
||||
- `hooks/` — Custom React hooks (use-modal, use-debounce, use-current-user)
|
||||
- `types/` — Shared TypeScript types
|
||||
|
||||
### Key Patterns
|
||||
- **Server Components by default** — pages are async server components that fetch data directly. Only interactive components use `"use client"`.
|
||||
- **Server Actions for mutations** — each page directory has an `actions.ts` file with create/update/delete actions.
|
||||
- **Data queries centralized** — all Prisma reads go through `src/data/*.queries.ts`, not inline in components.
|
||||
- **Modal-based CRUD** — add/edit forms use dialog modals, not separate pages.
|
||||
- **TanStack Table** with server-side pagination for all inventory tables.
|
||||
- **All Prisma PKs use `cuid()`** string IDs.
|
||||
|
||||
### Worker Pipeline
|
||||
1. Authenticate Telegram account via TDLib (SMS code flow, managed via admin UI)
|
||||
2. Scan source channels for messages since `lastProcessedMessageId`
|
||||
3. Detect archives (ZIP/RAR), group multipart sets, extract file listings
|
||||
4. Hash for dedup, match preview images, extract creator from filename
|
||||
5. Split files >2GB, upload to destination channel, track progress
|
||||
|
||||
### ESLint Scope
|
||||
ESLint covers `src/` only. The `worker/`, `bot/`, `scripts/`, and `prisma/seed.ts` directories are excluded from linting.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
- `docker-compose.yml` — Production: app + worker + bot + db
|
||||
- `docker-compose.dev.yml` — Dev: db + worker only (app runs locally)
|
||||
- `docker-entrypoint.sh` — Runs migrations, optional seeding, then starts app
|
||||
- Bot service uses Docker Compose profiles (`bot` or `full`) — not started by default
|
||||
|
||||
## Testing
|
||||
|
||||
No test framework is configured. Testing is manual.
|
||||
@@ -17,6 +17,8 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
RUN npm run build
|
||||
|
||||
# --- Production image ---
|
||||
|
||||
@@ -294,5 +294,7 @@ curl http://localhost:3000/api/health
|
||||
5. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM alpine:3.20
|
||||
|
||||
# Note: use busybox's built-in crond (Alpine base), NOT the dcron package —
|
||||
# dcron's crond fails with "setpgid: Operation not permitted" in this runtime.
|
||||
RUN apk add --no-cache restic postgresql16-client curl tzdata tar bash
|
||||
|
||||
COPY backup/backup.sh /backup.sh
|
||||
COPY backup/entrypoint.sh /entrypoint.sh
|
||||
COPY backup/crontab /etc/crontabs/root
|
||||
|
||||
RUN chmod +x /backup.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
report_failure() {
|
||||
[ -n "${KUMA_PUSH_URL:-}" ] || return 0
|
||||
curl -fsS "$KUMA_PUSH_URL" --get \
|
||||
--data-urlencode "status=down" \
|
||||
--data-urlencode "msg=$BASH_COMMAND failed" || true
|
||||
}
|
||||
trap report_failure ERR
|
||||
|
||||
DUMP_FILE=/tmp/dragonsstash.dump
|
||||
TAR_FILE=/tmp/tdlib.tar.gz
|
||||
|
||||
trap 'rm -f "$DUMP_FILE" "$TAR_FILE"' EXIT
|
||||
|
||||
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc -f "$DUMP_FILE"
|
||||
|
||||
# TDLib volumes are tarred live (best-effort, per design). A file changing
|
||||
# mid-read makes GNU tar exit 1 (warning) — that is expected here and must not
|
||||
# abort the backup. Only a genuine error (exit >= 2) is fatal.
|
||||
tar --warning=no-file-changed -czf "$TAR_FILE" -C /data tdlib-worker tdlib-bot \
|
||||
|| { rc=$?; [ "$rc" -le 1 ] || exit "$rc"; }
|
||||
|
||||
restic backup "$DUMP_FILE" "$TAR_FILE"
|
||||
restic forget --keep-daily 14 --prune
|
||||
|
||||
if [ -n "${KUMA_PUSH_URL:-}" ]; then
|
||||
curl -fsS "$KUMA_PUSH_URL" --get \
|
||||
--data-urlencode "status=up" \
|
||||
--data-urlencode "msg=OK"
|
||||
fi
|
||||
@@ -0,0 +1,2 @@
|
||||
0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1
|
||||
0 4 * * 0 restic check >> /proc/1/fd/1 2>&1
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -uo pipefail
|
||||
|
||||
# Ensure the repo exists, but never crash-loop on it: a transient error reading
|
||||
# the repo (CIFS hiccup, stale lock) must not kill PID 1. `restic init` failing
|
||||
# because the repo already exists is expected and harmless here.
|
||||
if ! restic cat config >/dev/null 2>&1; then
|
||||
restic init || echo "restic init skipped (repo already exists or temporarily unreachable)"
|
||||
fi
|
||||
|
||||
exec crond -f -l 2
|
||||
Generated
+65
-29
@@ -12,8 +12,8 @@
|
||||
"@prisma/client": "^7.4.0",
|
||||
"pg": "^8.18.0",
|
||||
"pino": "^9.6.0",
|
||||
"prebuilt-tdlib": "^0.1008050.0",
|
||||
"tdl": "^8.0.0"
|
||||
"prebuilt-tdlib": "^0.1008064.0",
|
||||
"tdl": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
@@ -566,9 +566,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@prebuilt-tdlib/darwin-arm64": {
|
||||
"version": "0.1008050.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008050.0.tgz",
|
||||
"integrity": "sha512-XrWN7M1gfvnzOBRX0YdXVfhSxIDSs/ZJ16QJ0ILDKe+grOFl/cfl7lwB/hK/MlHC6Rev56f5X7xaWnjMh0vktQ==",
|
||||
"version": "0.1008064.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-arm64/-/darwin-arm64-0.1008064.0.tgz",
|
||||
"integrity": "sha512-Oq5us+o0g68Jag74RIV3LdLkZxQxJMcOdrVbgmyE7Unk+WcifqTb/gZw1rS6BrW+2SX2LNeGY4zQqqBTNDr17Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -579,9 +579,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@prebuilt-tdlib/darwin-x64": {
|
||||
"version": "0.1008050.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008050.0.tgz",
|
||||
"integrity": "sha512-a1UfBW0lYx4tUy5viMPtsbqBfBncCAgDu3FPjljfYTHjP8wfkKFxpp5+8wdxhyqdy3QriWaipVtUXQgOeEWMJg==",
|
||||
"version": "0.1008064.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/darwin-x64/-/darwin-x64-0.1008064.0.tgz",
|
||||
"integrity": "sha512-Pz11xjET2Y3uUJKxkWKBc0dmOtlykmBdZ9D6Ahh+EsoLDLIWHm7M91p6nZT396YZ4n2BL+FtDYK65Ae3LDIA5g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -592,9 +592,22 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@prebuilt-tdlib/linux-arm64-glibc": {
|
||||
"version": "0.1008050.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008050.0.tgz",
|
||||
"integrity": "sha512-HRGspdQYzaBkU+W2M8uY5OgOkmgfTkyHkTYan/dn7EE/38QdIFW0YTvmGrl3DoFV2PA+SeJQw0xqK8tMSyHKaA==",
|
||||
"version": "0.1008064.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-arm64-glibc/-/linux-arm64-glibc-0.1008064.0.tgz",
|
||||
"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": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -605,9 +618,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@prebuilt-tdlib/linux-x64-glibc": {
|
||||
"version": "0.1008050.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008050.0.tgz",
|
||||
"integrity": "sha512-Yf6ve3Dzxc66kV1cijFLn7EXKhPN5YHTjtJABEaCR5euetCI2wZp/1uBsXvyYTuFXqQbMfjO3xUCXUIBhLoChw==",
|
||||
"version": "0.1008064.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/linux-x64-glibc/-/linux-x64-glibc-0.1008064.0.tgz",
|
||||
"integrity": "sha512-7fyCp2uk0BdeHKJ9PyQOCditC9vBXeeIjYPAKKBcrkum5bi1e9txy2g5kkGjqwUkN0ntIniS5QfHEyr17Idr9g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -617,10 +630,30 @@
|
||||
"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": {
|
||||
"version": "0.1008050.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008050.0.tgz",
|
||||
"integrity": "sha512-4v8tU5bodMcLhzrWWXzIzqdHBIpq0wim+7sDmQWQIMy3kDeIzVtpuM+vQjxrGoeH9oWr2WXSRKuj93ld7G5NbQ==",
|
||||
"version": "0.1008064.0",
|
||||
"resolved": "https://registry.npmjs.org/@prebuilt-tdlib/win32-x64/-/win32-x64-0.1008064.0.tgz",
|
||||
"integrity": "sha512-rkacZWexQw52/EUaLAmbsu2+P3C1/AtinlCjfiX07oQAEg3327BCEZqrcY0ER83D8+MMf2pfwMPCDJKytr4hcg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1669,16 +1702,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prebuilt-tdlib": {
|
||||
"version": "0.1008050.0",
|
||||
"resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008050.0.tgz",
|
||||
"integrity": "sha512-CfeQE1rG51d2iC6m72fzrbCW4mqI17ugil9pVurWHtfUJi1Fcn7zadpTzDoUl4oc1dEtKgM7S24DVP67gcl4SQ==",
|
||||
"version": "0.1008064.0",
|
||||
"resolved": "https://registry.npmjs.org/prebuilt-tdlib/-/prebuilt-tdlib-0.1008064.0.tgz",
|
||||
"integrity": "sha512-jJLowKZoH4slXYrkTkKlEgyGsIGv61AWjDZcxxVxJYu21X3kmukGwbCpk4ML99cJp2CwRsD41GCEQBkKJAwCUg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@prebuilt-tdlib/darwin-arm64": "0.1008050.0",
|
||||
"@prebuilt-tdlib/darwin-x64": "0.1008050.0",
|
||||
"@prebuilt-tdlib/linux-arm64-glibc": "0.1008050.0",
|
||||
"@prebuilt-tdlib/linux-x64-glibc": "0.1008050.0",
|
||||
"@prebuilt-tdlib/win32-x64": "0.1008050.0"
|
||||
"@prebuilt-tdlib/darwin-arm64": "0.1008064.0",
|
||||
"@prebuilt-tdlib/darwin-x64": "0.1008064.0",
|
||||
"@prebuilt-tdlib/linux-arm64-glibc": "0.1008064.0",
|
||||
"@prebuilt-tdlib/linux-arm64-musl": "0.1008064.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": {
|
||||
@@ -1971,13 +2007,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tdl": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tdl/-/tdl-8.0.2.tgz",
|
||||
"integrity": "sha512-KYxlJ4eao7FUu91U1dCDkaHmK70JAyZ1KqitkKqpPC7rxAiXWhaYxddWvt84UxIYoWbgdd0B70FYJ4p/YqpFCA==",
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tdl/-/tdl-8.1.0.tgz",
|
||||
"integrity": "sha512-idpw60gjJdiJALQg0+6UbxtJTMxVhzZAgCO6QzL81gqBYCkEFjm9zM9HwTTQGeOaAavw4yRHymR68yUUiCoKrA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"debug": "^4.4.3",
|
||||
"node-addon-api": "^7.1.1",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
"@prisma/client": "^7.4.0",
|
||||
"pg": "^8.18.0",
|
||||
"pino": "^9.6.0",
|
||||
"prebuilt-tdlib": "^0.1008050.0",
|
||||
"tdl": "^8.0.0"
|
||||
"prebuilt-tdlib": "^0.1008064.0",
|
||||
"tdl": "^8.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
getSubscriptions,
|
||||
addSubscription,
|
||||
removeSubscription,
|
||||
getGroupById,
|
||||
searchGroups,
|
||||
} from "./db/queries.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
|
||||
|
||||
const log = childLogger("commands");
|
||||
@@ -78,6 +81,12 @@ export async function handleMessage(msg: IncomingMessage): Promise<void> {
|
||||
case "/status":
|
||||
await handleStatus(chatId, userId);
|
||||
break;
|
||||
case "/group":
|
||||
await handleGroup(chatId, args);
|
||||
break;
|
||||
case "/sendgroup":
|
||||
await handleSendGroup(chatId, userId, args);
|
||||
break;
|
||||
default:
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
@@ -117,6 +126,8 @@ async function handleStart(
|
||||
`/search <query> — Search packages`,
|
||||
`/latest [n] — Show latest packages`,
|
||||
`/package <id> — Package details`,
|
||||
`/group <id or name> — View group info and package list`,
|
||||
`/sendgroup <id> — Send all packages in a group to yourself`,
|
||||
`/link <code> — Link your Telegram to your web account`,
|
||||
`/subscribe <keyword> — Get notified for new packages`,
|
||||
`/subscriptions — View your subscriptions`,
|
||||
@@ -136,6 +147,8 @@ async function handleHelp(chatId: bigint): Promise<void> {
|
||||
`/search <query> — Search by filename or creator`,
|
||||
`/latest [n] — Show n most recent packages (default: 5)`,
|
||||
`/package <id> — View package details and file list`,
|
||||
`/group <id or name> — View group info and package list`,
|
||||
`/sendgroup <id> — Send all packages in a group to yourself`,
|
||||
``,
|
||||
`🔗 <b>Account Linking</b>`,
|
||||
`/link <code> — Link Telegram to your web account`,
|
||||
@@ -432,6 +445,168 @@ async function handleStatus(chatId: bigint, userId: bigint): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGroup(chatId: bigint, query: string): Promise<void> {
|
||||
if (!query) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Usage: /group <id or name>\n\nProvide a group ID (starts with 'c') or a name to search.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
// If it looks like a cuid (starts with 'c', ~25 chars), look up by ID directly
|
||||
if (/^c[a-z0-9]{20,}$/i.test(trimmed)) {
|
||||
const group = await getGroupById(trimmed);
|
||||
if (!group) {
|
||||
await sendTextMessage(chatId, "Group not found.", "textParseModeHTML");
|
||||
return;
|
||||
}
|
||||
|
||||
const packageLines = group.packages.slice(0, 20).map((pkg, i) => {
|
||||
const size = formatSize(pkg.fileSize);
|
||||
return ` ${i + 1}. <b>${escapeHtml(pkg.fileName)}</b> (${size}, ${pkg.fileCount} files) — <code>${pkg.id}</code>`;
|
||||
});
|
||||
const more = group.packages.length > 20
|
||||
? `\n ... and ${group.packages.length - 20} more`
|
||||
: "";
|
||||
|
||||
const response = [
|
||||
`📦 <b>Group: ${escapeHtml(group.name)}</b>`,
|
||||
``,
|
||||
`Packages: ${group.packages.length}`,
|
||||
`ID: <code>${group.id}</code>`,
|
||||
``,
|
||||
`<b>Contents:</b>`,
|
||||
...packageLines,
|
||||
more,
|
||||
``,
|
||||
`Use /sendgroup ${group.id} to receive all packages.`,
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
|
||||
await sendTextMessage(chatId, response, "textParseModeHTML");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise search by name
|
||||
const groups = await searchGroups(trimmed, 5);
|
||||
|
||||
if (groups.length === 0) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`No groups found matching "<b>${escapeHtml(trimmed)}</b>".`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = groups.map(
|
||||
(g, i) =>
|
||||
`${i + 1}. <b>${escapeHtml(g.name)}</b> — ${g._count.packages} package(s)\n ID: <code>${g.id}</code>`
|
||||
);
|
||||
|
||||
const response = [
|
||||
`🔍 <b>Groups matching "${escapeHtml(trimmed)}":</b>`,
|
||||
``,
|
||||
...lines,
|
||||
``,
|
||||
`Use /group <id> for full details.`,
|
||||
].join("\n");
|
||||
|
||||
await sendTextMessage(chatId, response, "textParseModeHTML");
|
||||
}
|
||||
|
||||
async function handleSendGroup(
|
||||
chatId: bigint,
|
||||
userId: bigint,
|
||||
args: string
|
||||
): Promise<void> {
|
||||
if (!args) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"Usage: /sendgroup <group-id>",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = args.trim();
|
||||
const group = await getGroupById(groupId);
|
||||
|
||||
if (!group) {
|
||||
await sendTextMessage(chatId, "Group not found.", "textParseModeHTML");
|
||||
return;
|
||||
}
|
||||
|
||||
// Require account linking
|
||||
const link = await findLinkByTelegramUserId(userId);
|
||||
if (!link) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
"You must link your account before receiving packages.\nUse /link <code> to connect.",
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only send packages that have been uploaded to the destination channel
|
||||
const sendable = group.packages.filter(
|
||||
(pkg) => pkg.destChannelId && pkg.destMessageId
|
||||
);
|
||||
|
||||
if (sendable.length === 0) {
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
`No packages in group "<b>${escapeHtml(group.name)}</b>" are ready to send yet.`,
|
||||
"textParseModeHTML"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a BotSendRequest for each sendable package
|
||||
const requests = await Promise.all(
|
||||
sendable.map((pkg) =>
|
||||
db.botSendRequest.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
telegramLinkId: link.id,
|
||||
requestedByUserId: link.userId,
|
||||
status: "PENDING",
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Fire pg_notify for each request so the send listener picks them up
|
||||
for (const req of requests) {
|
||||
await db.$queryRawUnsafe(
|
||||
`SELECT pg_notify('bot_send', $1)`,
|
||||
req.id
|
||||
).catch(() => {
|
||||
// Best-effort — the bot also processes PENDING requests on its send queue
|
||||
});
|
||||
}
|
||||
|
||||
await sendTextMessage(
|
||||
chatId,
|
||||
[
|
||||
`✅ <b>Queued ${requests.length} package(s) from "${escapeHtml(group.name)}"</b>`,
|
||||
``,
|
||||
`You'll receive each archive shortly. Use /package <id> to check individual packages.`,
|
||||
].join("\n"),
|
||||
"textParseModeHTML"
|
||||
);
|
||||
|
||||
log.info(
|
||||
{ groupId, packageCount: requests.length, userId: userId.toString() },
|
||||
"Group send queued"
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
|
||||
+103
-3
@@ -21,7 +21,16 @@ export async function findLinkByUserId(userId: string) {
|
||||
export async function validateLinkCode(code: string): Promise<string | null> {
|
||||
const key = `link_code:${code}`;
|
||||
const setting = await db.globalSetting.findUnique({ where: { key } });
|
||||
return setting?.value ?? null;
|
||||
if (!setting) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(setting.value);
|
||||
if (parsed.expiresAt && new Date(parsed.expiresAt) < new Date()) return null;
|
||||
return parsed.userId ?? null;
|
||||
} catch {
|
||||
// Legacy format: value is the userId directly
|
||||
return setting.value;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLinkCode(code: string): Promise<void> {
|
||||
@@ -44,7 +53,52 @@ export async function createTelegramLink(
|
||||
// ── Package search ──
|
||||
|
||||
export async function searchPackages(query: string, limit = 10) {
|
||||
const packages = await db.package.findMany({
|
||||
// Try full-text search first
|
||||
if (query.length >= 3) {
|
||||
const tsQuery = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 2)
|
||||
.map((w) => w.replace(/[^a-zA-Z0-9]/g, ""))
|
||||
.filter(Boolean)
|
||||
.join(" & ");
|
||||
|
||||
if (tsQuery) {
|
||||
try {
|
||||
const ftsResults = await db.$queryRawUnsafe<{ id: string }[]>(
|
||||
`SELECT id FROM packages
|
||||
WHERE "searchVector" @@ to_tsquery('english', $1)
|
||||
ORDER BY ts_rank("searchVector", to_tsquery('english', $1)) DESC
|
||||
LIMIT $2`,
|
||||
tsQuery,
|
||||
limit
|
||||
);
|
||||
|
||||
if (ftsResults.length > 0) {
|
||||
return db.package.findMany({
|
||||
where: { id: { in: ftsResults.map((r) => r.id) } },
|
||||
orderBy: { indexedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
indexedAt: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// FTS failed — fall back to ILIKE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ILIKE search
|
||||
return db.package.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: query, mode: "insensitive" } },
|
||||
@@ -65,7 +119,44 @@ export async function searchPackages(query: string, limit = 10) {
|
||||
destMessageId: true,
|
||||
},
|
||||
});
|
||||
return packages;
|
||||
}
|
||||
|
||||
// ── Group queries ──
|
||||
|
||||
export async function getGroupById(groupId: string) {
|
||||
return db.packageGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: {
|
||||
packages: {
|
||||
orderBy: { indexedAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchGroups(query: string, limit = 5) {
|
||||
return db.packageGroup.findMany({
|
||||
where: {
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
_count: { select: { packages: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestPackages(limit = 5) {
|
||||
@@ -106,9 +197,18 @@ export async function getPendingSendRequest(requestId: string) {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
tags: true,
|
||||
archiveType: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
destMessageIds: true,
|
||||
isMultipart: true,
|
||||
partCount: true,
|
||||
previewData: true,
|
||||
sourceChannel: { select: { title: true, telegramId: true } },
|
||||
},
|
||||
},
|
||||
telegramLink: true,
|
||||
|
||||
+22
-9
@@ -1,7 +1,7 @@
|
||||
import { config } from "./util/config.js";
|
||||
import { logger } from "./util/logger.js";
|
||||
import { db, pool } from "./db/client.js";
|
||||
import { createBotClient, closeBotClient, onBotUpdate } from "./tdlib/client.js";
|
||||
import { createBotClient, closeBotClient, onBotUpdate, getUser } from "./tdlib/client.js";
|
||||
import { startSendListener, stopSendListener } from "./send-listener.js";
|
||||
import { handleMessage } from "./commands.js";
|
||||
import { mkdir } from "fs/promises";
|
||||
@@ -49,14 +49,27 @@ async function main(): Promise<void> {
|
||||
const userId = senderId.user_id as number;
|
||||
|
||||
if (text && userId) {
|
||||
// Get user info for display name (async but fire-and-forget for perf)
|
||||
handleMessage({
|
||||
chatId: BigInt(chatId),
|
||||
userId: BigInt(userId),
|
||||
text,
|
||||
firstName: "User", // TDLib provides this via a separate getUser call
|
||||
username: undefined,
|
||||
}).catch((err) => {
|
||||
(async () => {
|
||||
let firstName = "User";
|
||||
let lastName: string | undefined;
|
||||
let username: string | undefined;
|
||||
try {
|
||||
const userInfo = await getUser(userId);
|
||||
firstName = userInfo.firstName;
|
||||
lastName = userInfo.lastName;
|
||||
username = userInfo.username;
|
||||
} catch {
|
||||
// Fall back to defaults if getUser fails
|
||||
}
|
||||
await handleMessage({
|
||||
chatId: BigInt(chatId),
|
||||
userId: BigInt(userId),
|
||||
text,
|
||||
firstName,
|
||||
lastName,
|
||||
username,
|
||||
});
|
||||
})().catch((err) => {
|
||||
log.error({ err, chatId, userId }, "Failed to handle message");
|
||||
});
|
||||
}
|
||||
|
||||
+108
-20
@@ -7,34 +7,84 @@ import {
|
||||
findMatchingSubscriptions,
|
||||
getGlobalDestinationChannel,
|
||||
} from "./db/queries.js";
|
||||
import { copyMessageToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
|
||||
import { copyMessageToUser, copyMultipleMessagesToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
|
||||
import { sleep } from "./util/flood-wait.js";
|
||||
|
||||
const log = childLogger("send-listener");
|
||||
|
||||
let pgClient: pg.PoolClient | null = null;
|
||||
let stopped = false;
|
||||
|
||||
/** Delay (ms) before attempting to reconnect after a connection loss. */
|
||||
const RECONNECT_DELAY_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Start listening for pg_notify signals:
|
||||
* - `bot_send` — payload = requestId → send a package to a user
|
||||
* - `new_package` — payload = JSON { packageId, fileName, creator } → notify subscribers
|
||||
*
|
||||
* If the underlying connection is lost, the listener automatically reconnects
|
||||
* so that pg_notify signals are never silently dropped.
|
||||
*/
|
||||
export async function startSendListener(): Promise<void> {
|
||||
pgClient = await pool.connect();
|
||||
await pgClient.query("LISTEN bot_send");
|
||||
await pgClient.query("LISTEN new_package");
|
||||
stopped = false;
|
||||
await connectListener();
|
||||
}
|
||||
|
||||
pgClient.on("notification", (msg) => {
|
||||
if (msg.channel === "bot_send" && msg.payload) {
|
||||
handleBotSend(msg.payload);
|
||||
} else if (msg.channel === "new_package" && msg.payload) {
|
||||
handleNewPackage(msg.payload);
|
||||
async function connectListener(): Promise<void> {
|
||||
try {
|
||||
pgClient = await pool.connect();
|
||||
await pgClient.query("LISTEN bot_send");
|
||||
await pgClient.query("LISTEN new_package");
|
||||
|
||||
pgClient.on("notification", (msg) => {
|
||||
if (msg.channel === "bot_send" && msg.payload) {
|
||||
handleBotSend(msg.payload);
|
||||
} else if (msg.channel === "new_package" && msg.payload) {
|
||||
handleNewPackage(msg.payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Reconnect automatically when the connection ends unexpectedly
|
||||
pgClient.on("end", () => {
|
||||
if (!stopped) {
|
||||
log.warn("Send listener connection lost — reconnecting");
|
||||
pgClient = null;
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
pgClient.on("error", (err) => {
|
||||
log.error({ err }, "Send listener connection error");
|
||||
if (!stopped && pgClient) {
|
||||
try {
|
||||
pgClient.release(true);
|
||||
} catch (releaseErr) {
|
||||
log.debug({ err: releaseErr }, "Failed to release pg client after error");
|
||||
}
|
||||
pgClient = null;
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
log.info("Send listener started (bot_send, new_package)");
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to start send listener — retrying");
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (stopped) return;
|
||||
setTimeout(() => {
|
||||
if (!stopped) {
|
||||
connectListener();
|
||||
}
|
||||
});
|
||||
|
||||
log.info("Send listener started (bot_send, new_package)");
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
export function stopSendListener(): void {
|
||||
stopped = true;
|
||||
if (pgClient) {
|
||||
pgClient.release();
|
||||
pgClient = null;
|
||||
@@ -84,18 +134,45 @@ async function processSendRequest(requestId: string): Promise<void> {
|
||||
throw new Error("No global destination channel configured");
|
||||
}
|
||||
|
||||
// Send preview if available
|
||||
// Send preview with rich caption if available
|
||||
if (pkg.previewData) {
|
||||
const caption = `📦 *${pkg.fileName}*\n\nSent from Dragon's Stash`;
|
||||
const lines: string[] = [];
|
||||
lines.push(`📦 *${escapeMarkdown(pkg.fileName)}*`);
|
||||
if (pkg.creator) lines.push(`👤 ${escapeMarkdown(pkg.creator)}`);
|
||||
if (pkg.fileCount > 0) lines.push(`📁 ${pkg.fileCount} files`);
|
||||
if (pkg.tags && pkg.tags.length > 0) {
|
||||
lines.push(`🏷️ ${pkg.tags.map((t: string) => escapeMarkdown(t)).join(", ")}`);
|
||||
}
|
||||
if (pkg.sourceChannel) {
|
||||
lines.push(`📡 Source: ${escapeMarkdown(pkg.sourceChannel.title)}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("_Sent from Dragon's Stash_");
|
||||
|
||||
const caption = lines.join("\n");
|
||||
await sendPhotoMessage(targetUserId, Buffer.from(pkg.previewData), caption);
|
||||
}
|
||||
|
||||
// Forward the actual archive file(s) from destination channel
|
||||
await copyMessageToUser(
|
||||
destChannel.telegramId,
|
||||
pkg.destMessageId,
|
||||
targetUserId
|
||||
);
|
||||
const messageIds = pkg.destMessageIds as bigint[] | undefined;
|
||||
if (messageIds && messageIds.length > 1) {
|
||||
log.info(
|
||||
{ requestId, parts: messageIds.length },
|
||||
"Sending multi-part archive"
|
||||
);
|
||||
await copyMultipleMessagesToUser(
|
||||
destChannel.telegramId,
|
||||
messageIds,
|
||||
targetUserId
|
||||
);
|
||||
} else {
|
||||
// Single part or legacy (no destMessageIds populated)
|
||||
await copyMessageToUser(
|
||||
destChannel.telegramId,
|
||||
pkg.destMessageId,
|
||||
targetUserId
|
||||
);
|
||||
}
|
||||
|
||||
await updateSendRequest(requestId, "SENT");
|
||||
log.info({ requestId }, "Send request completed successfully");
|
||||
@@ -114,6 +191,7 @@ async function handleNewPackage(payload: string): Promise<void> {
|
||||
packageId: string;
|
||||
fileName: string;
|
||||
creator: string | null;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
const subs = await findMatchingSubscriptions(data.fileName, data.creator);
|
||||
@@ -133,12 +211,15 @@ async function handleNewPackage(payload: string): Promise<void> {
|
||||
userSubs.set(key, patterns);
|
||||
}
|
||||
|
||||
const creator = data.creator ? ` by ${data.creator}` : "";
|
||||
const creator = data.creator ? ` by ${escapeHtml(data.creator)}` : "";
|
||||
for (const [telegramUserId, patterns] of userSubs) {
|
||||
const msg = [
|
||||
`🔔 <b>New package matching your subscriptions:</b>`,
|
||||
``,
|
||||
`📦 <b>${escapeHtml(data.fileName)}</b>${creator}`,
|
||||
...(data.tags && data.tags.length > 0
|
||||
? [`🏷️ ${data.tags.map((t: string) => escapeHtml(t)).join(", ")}`]
|
||||
: []),
|
||||
``,
|
||||
`Matched: ${patterns.map((p) => `"${escapeHtml(p)}"`).join(", ")}`,
|
||||
``,
|
||||
@@ -151,6 +232,9 @@ async function handleNewPackage(payload: string): Promise<void> {
|
||||
"Failed to notify subscriber"
|
||||
);
|
||||
});
|
||||
|
||||
// Rate limit delay between notifications (~20 msgs/sec, under 30 msgs/sec bot limit)
|
||||
await sleep(50);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err, payload }, "Failed to process new_package notification");
|
||||
@@ -160,3 +244,7 @@ async function handleNewPackage(payload: string): Promise<void> {
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeMarkdown(text: string): string {
|
||||
return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
+232
-42
@@ -2,6 +2,7 @@ import tdl from "tdl";
|
||||
import { getTdjson } from "prebuilt-tdlib";
|
||||
import { config } from "../util/config.js";
|
||||
import { childLogger } from "../util/logger.js";
|
||||
import { withFloodWait } from "../util/flood-wait.js";
|
||||
|
||||
const log = childLogger("tdlib-bot");
|
||||
|
||||
@@ -33,7 +34,7 @@ export async function createBotClient(): Promise<tdl.Client> {
|
||||
|
||||
await client.login(() => ({
|
||||
type: "bot",
|
||||
token: config.botToken,
|
||||
getToken: () => Promise.resolve(config.botToken),
|
||||
}));
|
||||
|
||||
log.info("Bot client authenticated successfully");
|
||||
@@ -53,8 +54,14 @@ export async function closeBotClient(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a message from a channel to a user's DM.
|
||||
* Uses copyMessage to make it appear as sent by the bot.
|
||||
* Send a document from a channel to a user's DM.
|
||||
*
|
||||
* Instead of forwardMessages (unreliable for bot accounts with send_copy),
|
||||
* we fetch the original message to get the file's remote ID, then send a
|
||||
* new message with inputFileRemote. This is the documented reliable approach
|
||||
* for bots — the file is already on Telegram's servers so no re-upload is needed.
|
||||
*
|
||||
* Falls back to a plain forward (without send_copy) if getMessage fails.
|
||||
*/
|
||||
export async function copyMessageToUser(
|
||||
fromChatId: bigint,
|
||||
@@ -62,18 +69,156 @@ export async function copyMessageToUser(
|
||||
toUserId: bigint
|
||||
): Promise<void> {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
const c = client;
|
||||
|
||||
// TDLib uses negative chat IDs for channels/supergroups
|
||||
// The telegramId from the DB is the raw Telegram ID; for channels it needs -100 prefix
|
||||
const fromChatIdNum = Number(-100n * 1n) + Number(fromChatId);
|
||||
log.info(
|
||||
{ fromChatId: fromChatId.toString(), messageId: messageId.toString(), toUserId: toUserId.toString() },
|
||||
"Sending file to user"
|
||||
);
|
||||
|
||||
await client.invoke({
|
||||
_: "forwardMessages",
|
||||
chat_id: Number(toUserId),
|
||||
from_chat_id: Number(fromChatId) > 0 ? -Number(fromChatId) : Number(fromChatId),
|
||||
message_ids: [Number(messageId)],
|
||||
send_copy: true,
|
||||
remove_caption: false,
|
||||
// Step 1: Get the original message to extract the file's remote ID
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let message: any;
|
||||
try {
|
||||
message = await withFloodWait(
|
||||
() => c.invoke({
|
||||
_: "getMessage",
|
||||
chat_id: Number(fromChatId),
|
||||
message_id: Number(messageId),
|
||||
}),
|
||||
"getMessage"
|
||||
);
|
||||
} catch (err) {
|
||||
log.error({ err, fromChatId: fromChatId.toString(), messageId: messageId.toString() }, "getMessage failed");
|
||||
throw new Error(`Cannot get source message: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// Step 2: Extract the document's remote file ID
|
||||
const doc = message?.content?.document;
|
||||
if (!doc?.document?.remote?.id) {
|
||||
log.error(
|
||||
{ messageContent: message?.content?._, messageId: messageId.toString() },
|
||||
"Source message has no document with remote file ID"
|
||||
);
|
||||
throw new Error(`Source message is not a document or has no remote file ID (type: ${message?.content?._})`);
|
||||
}
|
||||
|
||||
const remoteFileId: string = doc.document.remote.id;
|
||||
const fileName: string = doc.file_name ?? "file";
|
||||
const caption = message.content?.caption;
|
||||
|
||||
log.info(
|
||||
{ remoteFileId: remoteFileId.slice(0, 20) + "...", fileName, toUserId: toUserId.toString() },
|
||||
"Sending document via inputFileRemote"
|
||||
);
|
||||
|
||||
// Step 3: Send the document to the user using the remote file ID
|
||||
// This doesn't require downloading — Telegram serves the existing file.
|
||||
await waitForSendConfirmation(c, Number(toUserId), {
|
||||
_: "inputMessageDocument",
|
||||
document: { _: "inputFileRemote", id: remoteFileId },
|
||||
caption: caption ?? undefined,
|
||||
}, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send multiple document messages from a channel to a user's DM.
|
||||
* Used for multi-part archives where each part is a separate Telegram message.
|
||||
* Sends parts sequentially with a small delay to avoid rate limits.
|
||||
*/
|
||||
export async function copyMultipleMessagesToUser(
|
||||
fromChatId: bigint,
|
||||
messageIds: bigint[],
|
||||
toUserId: bigint
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < messageIds.length; i++) {
|
||||
await copyMessageToUser(fromChatId, messageIds[i], toUserId);
|
||||
// Small delay between parts to avoid rate limits
|
||||
if (i < messageIds.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and wait for Telegram to confirm delivery.
|
||||
* Returns when updateMessageSendSucceeded fires for the temp message.
|
||||
* Throws if updateMessageSendFailed fires or timeout is reached.
|
||||
*/
|
||||
async function waitForSendConfirmation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
c: any,
|
||||
chatId: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
inputMessageContent: any,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let tempMsgId: number | null = null;
|
||||
|
||||
const TIMEOUT_MS = 5 * 60_000;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Send timed out after 5min for ${label}`));
|
||||
}
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleUpdate = (update: any) => {
|
||||
if (update?._ === "updateMessageSendSucceeded") {
|
||||
if (tempMsgId !== null && update.old_message_id === tempMsgId) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
log.info({ tempMsgId, finalMsgId: update.message?.id, label }, "Send confirmed");
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (update?._ === "updateMessageSendFailed") {
|
||||
if (tempMsgId !== null && update.old_message_id === tempMsgId) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
const errorMsg = update.error?.message ?? "Unknown";
|
||||
const errorCode = update.error?.code ?? 0;
|
||||
log.error({ tempMsgId, errorCode, errorMsg, label }, "Send failed");
|
||||
reject(new Error(`Send failed for ${label}: [${errorCode}] ${errorMsg}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
c.off("update", handleUpdate);
|
||||
};
|
||||
|
||||
// Attach BEFORE sending to avoid race
|
||||
c.on("update", handleUpdate);
|
||||
|
||||
withFloodWait(
|
||||
() => c.invoke({
|
||||
_: "sendMessage",
|
||||
chat_id: chatId,
|
||||
input_message_content: inputMessageContent,
|
||||
}),
|
||||
"sendMessage:copyToUser"
|
||||
)
|
||||
.then((result) => {
|
||||
tempMsgId = (result as { id: number }).id;
|
||||
log.debug({ tempMsgId, label }, "Message queued, waiting for confirmation");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,22 +231,31 @@ export async function sendTextMessage(
|
||||
parseMode: "textParseModeMarkdown" | "textParseModeHTML" = "textParseModeMarkdown"
|
||||
): Promise<void> {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
const c = client;
|
||||
|
||||
// Parse the text first
|
||||
const parsed = await client.invoke({
|
||||
_: "parseTextEntities",
|
||||
text,
|
||||
parse_mode: { _: parseMode, version: parseMode === "textParseModeMarkdown" ? 2 : 0 },
|
||||
});
|
||||
const parsed = await withFloodWait(
|
||||
() =>
|
||||
c.invoke({
|
||||
_: "parseTextEntities",
|
||||
text,
|
||||
parse_mode: { _: parseMode, version: parseMode === "textParseModeMarkdown" ? 2 : 0 },
|
||||
}),
|
||||
"parseTextEntities"
|
||||
);
|
||||
|
||||
await client.invoke({
|
||||
_: "sendMessage",
|
||||
chat_id: Number(chatId),
|
||||
input_message_content: {
|
||||
_: "inputMessageText",
|
||||
text: parsed,
|
||||
},
|
||||
});
|
||||
await withFloodWait(
|
||||
() =>
|
||||
c.invoke({
|
||||
_: "sendMessage",
|
||||
chat_id: Number(chatId),
|
||||
input_message_content: {
|
||||
_: "inputMessageText",
|
||||
text: parsed,
|
||||
},
|
||||
}),
|
||||
"sendTextMessage"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +267,7 @@ export async function sendPhotoMessage(
|
||||
caption: string
|
||||
): Promise<void> {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
const c = client;
|
||||
|
||||
// Write the photo to a temp file
|
||||
const { writeFile, unlink } = await import("fs/promises");
|
||||
@@ -122,28 +277,63 @@ export async function sendPhotoMessage(
|
||||
try {
|
||||
await writeFile(tempPath, photoData);
|
||||
|
||||
const parsedCaption = await client.invoke({
|
||||
_: "parseTextEntities",
|
||||
text: caption,
|
||||
parse_mode: { _: "textParseModeMarkdown", version: 2 },
|
||||
});
|
||||
const parsedCaption = await withFloodWait(
|
||||
() =>
|
||||
c.invoke({
|
||||
_: "parseTextEntities",
|
||||
text: caption,
|
||||
parse_mode: { _: "textParseModeMarkdown", version: 2 },
|
||||
}),
|
||||
"parsePhotoCaption"
|
||||
);
|
||||
|
||||
await client.invoke({
|
||||
_: "sendMessage",
|
||||
chat_id: Number(chatId),
|
||||
input_message_content: {
|
||||
_: "inputMessagePhoto",
|
||||
photo: { _: "inputFileLocal", path: tempPath },
|
||||
caption: parsedCaption,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
await withFloodWait(
|
||||
() =>
|
||||
c.invoke({
|
||||
_: "sendMessage",
|
||||
chat_id: Number(chatId),
|
||||
input_message_content: {
|
||||
_: "inputMessagePhoto",
|
||||
photo: { _: "inputFileLocal", path: tempPath },
|
||||
caption: parsedCaption,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
}),
|
||||
"sendPhotoMessage"
|
||||
);
|
||||
} finally {
|
||||
await unlink(tempPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic info about a Telegram user (name, username).
|
||||
*/
|
||||
export async function getUser(
|
||||
userId: number
|
||||
): Promise<{ firstName: string; lastName?: string; username?: string }> {
|
||||
if (!client) throw new Error("Bot client not initialized");
|
||||
const c = client;
|
||||
const user = (await withFloodWait(
|
||||
() =>
|
||||
c.invoke({
|
||||
_: "getUser",
|
||||
user_id: userId,
|
||||
}),
|
||||
"getUser"
|
||||
)) as {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
usernames?: { editable_username?: string };
|
||||
};
|
||||
return {
|
||||
firstName: user.first_name ?? "User",
|
||||
lastName: user.last_name || undefined,
|
||||
username: user.usernames?.editable_username || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updates from TDLib. The bot listens for new messages this way.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { childLogger } from "./logger.js";
|
||||
|
||||
const log = childLogger("flood-wait");
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the mandatory wait duration (in seconds) from a Telegram
|
||||
* FLOOD_WAIT error. Returns null when the error is not rate-limit related.
|
||||
*/
|
||||
export function extractFloodWaitSeconds(err: unknown): number | null {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Pattern 1: FLOOD_WAIT_30
|
||||
const flood = message.match(/FLOOD_WAIT_(\d+)/i);
|
||||
if (flood) return parseInt(flood[1], 10);
|
||||
|
||||
// Pattern 2: "retry after 30"
|
||||
const retry = message.match(/retry after (\d+)/i);
|
||||
if (retry) return parseInt(retry[1], 10);
|
||||
|
||||
// Pattern 3: HTTP 429 without explicit seconds
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (String((err as any)?.code) === "429") return 30;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap any async Telegram operation with automatic FLOOD_WAIT retry.
|
||||
* Adds random jitter (1-5s) to prevent thundering-herd retries.
|
||||
*
|
||||
* Non-rate-limit errors are re-thrown immediately (fail-fast).
|
||||
*/
|
||||
export async function withFloodWait<T>(
|
||||
fn: () => Promise<T>,
|
||||
context?: string,
|
||||
maxRetries = 5
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const wait = extractFloodWaitSeconds(err);
|
||||
if (wait === null || attempt >= maxRetries) throw err;
|
||||
|
||||
const jitter = 1000 + Math.random() * 4000;
|
||||
log.warn(
|
||||
{ context, wait, attempt: attempt + 1, maxRetries, jitter: Math.round(jitter) },
|
||||
"FLOOD_WAIT received — backing off"
|
||||
);
|
||||
await sleep(wait * 1000 + jitter);
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
export { sleep };
|
||||
+42
-2
@@ -28,6 +28,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
volumes:
|
||||
- manual_uploads:/data/uploads
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
@@ -54,6 +56,7 @@ services:
|
||||
volumes:
|
||||
- tdlib_state:/data/tdlib
|
||||
- tmp_zips:/tmp/zips
|
||||
- manual_uploads:/data/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -94,6 +97,35 @@ services:
|
||||
networks:
|
||||
- backend
|
||||
|
||||
backup:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backup/Dockerfile
|
||||
pull_policy: never
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-dragons}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
|
||||
- RESTIC_REPOSITORY=/backups/restic-repo
|
||||
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
|
||||
- KUMA_PUSH_URL=${KUMA_PUSH_URL:-}
|
||||
- TZ=${TZ:-Etc/UTC}
|
||||
volumes:
|
||||
- tdlib_state:/data/tdlib-worker:ro
|
||||
- tdlib_bot_state:/data/tdlib-bot:ro
|
||||
- nas_backups:/backups
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
networks:
|
||||
- backend
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
@@ -113,14 +145,22 @@ services:
|
||||
limits:
|
||||
memory: 1G
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
frontend: {}
|
||||
backend:
|
||||
aliases:
|
||||
- dragonsstash-db
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
tdlib_state:
|
||||
tdlib_bot_state:
|
||||
tmp_zips:
|
||||
manual_uploads:
|
||||
nas_backups:
|
||||
driver_opts:
|
||||
type: cifs
|
||||
o: "username=${NAS_USERNAME},password=${NAS_PASSWORD},vers=3.0,uid=0,gid=0,file_mode=0660,dir_mode=0770"
|
||||
device: "//${NAS_HOST}/${NAS_SHARE}"
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,964 @@
|
||||
# Multi-Part Send Fix & Kickstarter Package Linking
|
||||
|
||||
> **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:** Fix multi-part package forwarding so all archive parts reach the user, and add UI to link STL packages to kickstarters with "send all" capability.
|
||||
|
||||
**Architecture:** Two independent subsystems. (A) Store all destination message IDs when the worker uploads multi-part archives, then have the bot forward every part. (B) Add a package-linker dialog in the kickstarter UI using the existing `linkPackages` action, plus a "send all" action that queues every linked package.
|
||||
|
||||
**Tech Stack:** Prisma (schema + migration), TypeScript worker/bot services, Next.js App Router (server actions + React client components), shadcn/ui, TanStack Table.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Subsystem A — Multi-Part Send Fix
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Modify | `prisma/schema.prisma` | Add `destMessageIds BigInt[]` to Package |
|
||||
| Create | `prisma/migrations/<ts>_add_dest_message_ids/migration.sql` | Migration SQL |
|
||||
| Modify | `worker/src/upload/channel.ts` | Return all message IDs from `uploadToChannel` |
|
||||
| Modify | `worker/src/db/queries.ts` | Add `destMessageIds` to `CreatePackageInput` and `createPackageWithFiles` |
|
||||
| Modify | `worker/src/worker.ts` | Pass all message IDs when creating package |
|
||||
| Modify | `bot/src/db/queries.ts` | Include `destMessageIds` in `getPendingSendRequest` |
|
||||
| Modify | `bot/src/send-listener.ts` | Forward all parts, not just the first |
|
||||
|
||||
### Subsystem B — Kickstarter Package Linking UI
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `src/app/(app)/kickstarters/_components/package-linker-dialog.tsx` | Dialog with package search + selection for linking |
|
||||
| Modify | `src/app/(app)/kickstarters/_components/kickstarter-columns.tsx` | Add "Link Packages" and "Send All" actions to row menu |
|
||||
| Modify | `src/app/(app)/kickstarters/_components/kickstarter-table.tsx` | Wire up new dialogs + state |
|
||||
| Modify | `src/app/(app)/kickstarters/actions.ts` | Add `sendAllKickstarterPackages` action |
|
||||
| Modify | `src/data/kickstarter.queries.ts` | Add query to search packages for linking |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `destMessageIds` to Prisma Schema + Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `prisma/schema.prisma:470-471`
|
||||
- Create: migration SQL
|
||||
|
||||
- [ ] **Step 1: Add field to schema**
|
||||
|
||||
In `prisma/schema.prisma`, add `destMessageIds` after `destMessageId`:
|
||||
|
||||
```prisma
|
||||
destMessageId BigInt?
|
||||
destMessageIds BigInt[] @default([])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create migration SQL manually**
|
||||
|
||||
Create the migration directory and SQL file. The migration adds the column with a default and backfills existing rows by copying `destMessageId` into the array where it's non-null:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "packages" ADD COLUMN "destMessageIds" BIGINT[] DEFAULT ARRAY[]::BIGINT[];
|
||||
|
||||
-- Backfill: copy existing destMessageId into the array
|
||||
UPDATE "packages"
|
||||
SET "destMessageIds" = ARRAY["destMessageId"]
|
||||
WHERE "destMessageId" IS NOT NULL;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Apply migration to database**
|
||||
|
||||
```bash
|
||||
docker exec dragonsstash-db psql -U dragons -d dragonsstash -f - < migration.sql
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Regenerate Prisma client**
|
||||
|
||||
Use the app container (which has node/prisma) to regenerate:
|
||||
|
||||
```bash
|
||||
docker exec dragonsstash npx prisma generate
|
||||
```
|
||||
|
||||
Or, if running locally with node: `npx prisma generate`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add prisma/schema.prisma prisma/migrations/
|
||||
git commit -m "feat: add destMessageIds field to Package for multi-part forwarding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Worker — Return All Message IDs from Upload
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker/src/upload/channel.ts:10-12,25-74`
|
||||
|
||||
- [ ] **Step 1: Update UploadResult interface**
|
||||
|
||||
In `worker/src/upload/channel.ts`, change the interface to include all IDs:
|
||||
|
||||
```typescript
|
||||
export interface UploadResult {
|
||||
messageId: bigint;
|
||||
messageIds: bigint[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Collect all message IDs in uploadToChannel**
|
||||
|
||||
Replace the upload loop to track all message IDs:
|
||||
|
||||
```typescript
|
||||
export async function uploadToChannel(
|
||||
client: Client,
|
||||
chatId: bigint,
|
||||
filePaths: string[],
|
||||
caption?: string
|
||||
): Promise<UploadResult> {
|
||||
const allMessageIds: bigint[] = [];
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const fileCaption = i === 0 && caption ? caption : undefined;
|
||||
|
||||
const fileName = path.basename(filePath);
|
||||
let fileSizeMB = 0;
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
fileSizeMB = Math.round(s.size / (1024 * 1024));
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ chatId: Number(chatId), fileName, sizeMB: fileSizeMB, part: i + 1, total: filePaths.length },
|
||||
"Uploading file to channel"
|
||||
);
|
||||
|
||||
const serverMsgId = await sendWithRetry(client, chatId, filePath, fileCaption, fileName, fileSizeMB);
|
||||
allMessageIds.push(serverMsgId);
|
||||
|
||||
// Rate limit delay between uploads
|
||||
if (i < filePaths.length - 1) {
|
||||
await sleep(config.apiDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (allMessageIds.length === 0) {
|
||||
throw new Error("Upload failed: no messages sent");
|
||||
}
|
||||
|
||||
log.info(
|
||||
{ chatId: Number(chatId), messageId: Number(allMessageIds[0]), files: filePaths.length },
|
||||
"All uploads confirmed by Telegram"
|
||||
);
|
||||
|
||||
return { messageId: allMessageIds[0], messageIds: allMessageIds };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add worker/src/upload/channel.ts
|
||||
git commit -m "feat: return all message IDs from uploadToChannel for multi-part"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Worker — Store All Message IDs in Database
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker/src/db/queries.ts:104-155`
|
||||
- Modify: `worker/src/worker.ts:1056-1086`
|
||||
|
||||
- [ ] **Step 1: Add destMessageIds to CreatePackageInput**
|
||||
|
||||
In `worker/src/db/queries.ts`, add the field to the interface:
|
||||
|
||||
```typescript
|
||||
export interface CreatePackageInput {
|
||||
// ... existing fields ...
|
||||
destMessageId?: bigint;
|
||||
destMessageIds?: bigint[];
|
||||
// ... rest ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Store destMessageIds in createPackageWithFiles**
|
||||
|
||||
In the `db.package.create` call inside `createPackageWithFiles`, add:
|
||||
|
||||
```typescript
|
||||
destMessageIds: input.destMessageIds ?? (input.destMessageId ? [input.destMessageId] : []),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass messageIds from worker pipeline**
|
||||
|
||||
In `worker/src/worker.ts`, the upload section (around line 1068-1085) currently does:
|
||||
|
||||
```typescript
|
||||
destResult = await uploadToChannel(client, destChannelTelegramId, uploadPaths);
|
||||
```
|
||||
|
||||
After this, when calling `createPackageWithFiles`, add `destMessageIds`:
|
||||
|
||||
```typescript
|
||||
const pkg = await createPackageWithFiles({
|
||||
// ... existing fields ...
|
||||
destMessageId: destResult.messageId,
|
||||
destMessageIds: destResult.messageIds,
|
||||
// ... rest ...
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add worker/src/db/queries.ts worker/src/worker.ts
|
||||
git commit -m "feat: store all multi-part message IDs in package record"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Bot — Forward All Parts
|
||||
|
||||
**Files:**
|
||||
- Modify: `bot/src/db/queries.ts:110-132`
|
||||
- Modify: `bot/src/send-listener.ts:105-169`
|
||||
- Modify: `bot/src/tdlib/client.ts:66-122`
|
||||
|
||||
- [ ] **Step 1: Include destMessageIds in bot query**
|
||||
|
||||
In `bot/src/db/queries.ts`, add `destMessageIds` to the `getPendingSendRequest` select:
|
||||
|
||||
```typescript
|
||||
package: {
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
fileCount: true,
|
||||
creator: true,
|
||||
tags: true,
|
||||
archiveType: true,
|
||||
destChannelId: true,
|
||||
destMessageId: true,
|
||||
destMessageIds: true, // <-- ADD THIS
|
||||
isMultipart: true, // <-- ADD THIS (for logging)
|
||||
partCount: true, // <-- ADD THIS (for logging)
|
||||
previewData: true,
|
||||
sourceChannel: { select: { title: true, telegramId: true } },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add copyMultipleMessagesToUser helper**
|
||||
|
||||
In `bot/src/tdlib/client.ts`, add a new export after `copyMessageToUser`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Send multiple document messages from a channel to a user's DM.
|
||||
* Used for multi-part archives where each part is a separate Telegram message.
|
||||
* Sends parts sequentially with a small delay to avoid rate limits.
|
||||
*/
|
||||
export async function copyMultipleMessagesToUser(
|
||||
fromChatId: bigint,
|
||||
messageIds: bigint[],
|
||||
toUserId: bigint
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < messageIds.length; i++) {
|
||||
await copyMessageToUser(fromChatId, messageIds[i], toUserId);
|
||||
// Small delay between parts to avoid rate limits
|
||||
if (i < messageIds.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update processSendRequest to forward all parts**
|
||||
|
||||
In `bot/src/send-listener.ts`, update the import to include the new function:
|
||||
|
||||
```typescript
|
||||
import { copyMessageToUser, copyMultipleMessagesToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/client.js";
|
||||
```
|
||||
|
||||
Then replace the single `copyMessageToUser` call (around line 157) with logic that forwards all parts:
|
||||
|
||||
```typescript
|
||||
// Forward the actual archive file(s) from destination channel
|
||||
const messageIds = pkg.destMessageIds as bigint[] | undefined;
|
||||
if (messageIds && messageIds.length > 1) {
|
||||
log.info(
|
||||
{ requestId, parts: messageIds.length },
|
||||
"Sending multi-part archive"
|
||||
);
|
||||
await copyMultipleMessagesToUser(
|
||||
destChannel.telegramId,
|
||||
messageIds,
|
||||
targetUserId
|
||||
);
|
||||
} else {
|
||||
// Single part or legacy (no destMessageIds populated)
|
||||
await copyMessageToUser(
|
||||
destChannel.telegramId,
|
||||
pkg.destMessageId,
|
||||
targetUserId
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add bot/src/db/queries.ts bot/src/send-listener.ts bot/src/tdlib/client.ts
|
||||
git commit -m "feat: forward all parts of multi-part archives via bot"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Rebuild & Deploy Worker + Bot
|
||||
|
||||
- [ ] **Step 1: Rebuild worker image**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml build worker
|
||||
docker tag dragonsstash-worker:latest git.samagsteribbe.nl/admin/dragonsstash-worker:latest
|
||||
docker compose -p dragonsstash -f /opt/stacks/DragonsStash/docker-compose.yml up -d worker
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rebuild bot image**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml build bot
|
||||
docker tag dragonsstash-bot:latest git.samagsteribbe.nl/admin/dragonsstash-bot:latest
|
||||
docker compose -p dragonsstash -f /opt/stacks/DragonsStash/docker-compose.yml up -d bot
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify bot startup**
|
||||
|
||||
```bash
|
||||
docker logs dragonsstash-bot --tail=20
|
||||
```
|
||||
|
||||
Expected: Bot starts cleanly, "Send listener started" message.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Kickstarter — Package Search Query
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/data/kickstarter.queries.ts`
|
||||
|
||||
- [ ] **Step 1: Add searchPackagesForLinking query**
|
||||
|
||||
Append to `src/data/kickstarter.queries.ts`:
|
||||
|
||||
```typescript
|
||||
export async function searchPackagesForLinking(query: string, limit = 20) {
|
||||
if (!query || query.length < 2) return [];
|
||||
|
||||
return prisma.package.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: query, mode: "insensitive" } },
|
||||
{ creator: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
orderBy: { indexedAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileSize: true,
|
||||
archiveType: true,
|
||||
creator: true,
|
||||
fileCount: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLinkedPackageIds(kickstarterId: string): Promise<string[]> {
|
||||
const links = await prisma.kickstarterPackage.findMany({
|
||||
where: { kickstarterId },
|
||||
select: { packageId: true },
|
||||
});
|
||||
return links.map((l) => l.packageId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/data/kickstarter.queries.ts
|
||||
git commit -m "feat: add package search query for kickstarter linking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Kickstarter — Package Linker Dialog Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/(app)/kickstarters/_components/package-linker-dialog.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the package linker dialog**
|
||||
|
||||
This component provides a search input to find packages and checkboxes to select/deselect them. It calls the existing `linkPackages` action on save.
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useCallback, useEffect } from "react";
|
||||
import { Search, Package, X, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { linkPackages } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface PackageResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: bigint;
|
||||
archiveType: string;
|
||||
creator: string | null;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
interface PackageLinkerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kickstarterId: string;
|
||||
kickstarterName: string;
|
||||
initialPackageIds: string[];
|
||||
}
|
||||
|
||||
function formatSize(bytes: bigint | number): string {
|
||||
const b = Number(bytes);
|
||||
if (b >= 1024 * 1024 * 1024) return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(0)} MB`;
|
||||
return `${(b / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
export function PackageLinkerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
kickstarterId,
|
||||
kickstarterName,
|
||||
initialPackageIds,
|
||||
}: PackageLinkerDialogProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<PackageResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set(initialPackageIds));
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedIds(new Set(initialPackageIds));
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
}
|
||||
}, [open, initialPackageIds]);
|
||||
|
||||
const doSearch = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await fetch(`/api/packages/search?q=${encodeURIComponent(query)}&limit=20`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults(data.packages ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Ignore search errors
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => doSearch(searchQuery), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, doSearch]);
|
||||
|
||||
function togglePackage(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
startTransition(async () => {
|
||||
const result = await linkPackages(kickstarterId, Array.from(selectedIds));
|
||||
if (result.success) {
|
||||
toast.success(`Linked ${selectedIds.size} package(s) to "${kickstarterName}"`);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link Packages</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search and select STL packages to link to “{kickstarterName}”.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Selected count */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Package className="h-4 w-4" />
|
||||
{selectedIds.size} package(s) selected
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search packages by name or creator..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-2.5 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<ScrollArea className="h-[300px] rounded-md border">
|
||||
<div className="p-2 space-y-1">
|
||||
{searchResults.length === 0 && searchQuery.length >= 2 && !isSearching && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No packages found
|
||||
</p>
|
||||
)}
|
||||
{searchQuery.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
)}
|
||||
{searchResults.map((pkg) => (
|
||||
<label
|
||||
key={pkg.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(pkg.id)}
|
||||
onCheckedChange={() => togglePackage(pkg.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{pkg.fileName}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{pkg.creator && <span>{pkg.creator}</span>}
|
||||
<span>{formatSize(pkg.fileSize)}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1">
|
||||
{pkg.archiveType}
|
||||
</Badge>
|
||||
{pkg.fileCount > 0 && <span>{pkg.fileCount} files</span>}
|
||||
</div>
|
||||
</div>
|
||||
{selectedIds.has(pkg.id) && (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
|
||||
Save ({selectedIds.size})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/(app)/kickstarters/_components/package-linker-dialog.tsx
|
||||
git commit -m "feat: add package linker dialog for kickstarters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Package Search API Route
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/packages/search/route.ts`
|
||||
|
||||
- [ ] **Step 1: Create the API route**
|
||||
|
||||
The package linker dialog needs a client-side fetch for debounced search. Create a lightweight API route:
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { searchPackagesForLinking } from "@/data/kickstarter.queries";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const limit = Math.min(Number(searchParams.get("limit") ?? "20"), 50);
|
||||
|
||||
const packages = await searchPackagesForLinking(query, limit);
|
||||
|
||||
// Serialize BigInt for JSON
|
||||
const serialized = packages.map((p) => ({
|
||||
...p,
|
||||
fileSize: p.fileSize.toString(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ packages: serialized });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/api/packages/search/route.ts
|
||||
git commit -m "feat: add package search API route for kickstarter linking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Kickstarter — Send All Packages Action
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(app)/kickstarters/actions.ts`
|
||||
|
||||
- [ ] **Step 1: Add sendAllKickstarterPackages action**
|
||||
|
||||
Append to `src/app/(app)/kickstarters/actions.ts`:
|
||||
|
||||
```typescript
|
||||
export async function sendAllKickstarterPackages(
|
||||
kickstarterId: string
|
||||
): Promise<ActionResult<{ queued: number }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
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 kickstarter = await prisma.kickstarter.findFirst({
|
||||
where: { id: kickstarterId, userId: session.user.id },
|
||||
select: {
|
||||
packages: {
|
||||
select: {
|
||||
package: {
|
||||
select: { id: true, destChannelId: true, destMessageId: true, fileName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!kickstarter) {
|
||||
return { success: false, error: "Kickstarter not found" };
|
||||
}
|
||||
|
||||
const sendablePackages = kickstarter.packages
|
||||
.map((lnk) => lnk.package)
|
||||
.filter((p) => p.destChannelId && p.destMessageId);
|
||||
|
||||
if (sendablePackages.length === 0) {
|
||||
return { success: false, error: "No linked packages are available for sending" };
|
||||
}
|
||||
|
||||
let queued = 0;
|
||||
for (const pkg of sendablePackages) {
|
||||
const existing = await prisma.botSendRequest.findFirst({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
telegramLinkId: telegramLink.id,
|
||||
status: { in: ["PENDING", "SENDING"] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const sendRequest = await prisma.botSendRequest.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
telegramLinkId: telegramLink.id,
|
||||
requestedByUserId: session.user.id,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('bot_send', $1)`,
|
||||
sendRequest.id
|
||||
);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { queued } };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to send packages" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/(app)/kickstarters/actions.ts
|
||||
git commit -m "feat: add sendAllKickstarterPackages action"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Kickstarter Table — Wire Up Link & Send Actions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(app)/kickstarters/_components/kickstarter-columns.tsx`
|
||||
- Modify: `src/app/(app)/kickstarters/_components/kickstarter-table.tsx`
|
||||
|
||||
- [ ] **Step 1: Add actions to column menu**
|
||||
|
||||
In `kickstarter-columns.tsx`, add `Link2` and `Send` imports from lucide-react, add `onLinkPackages` and `onSendAll` to props, and add menu items:
|
||||
|
||||
```typescript
|
||||
import { MoreHorizontal, Pencil, Trash2, ExternalLink, Link2, Send } from "lucide-react";
|
||||
|
||||
// Update interface:
|
||||
interface KickstarterColumnsProps {
|
||||
onEdit: (kickstarter: KickstarterRow) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onLinkPackages: (kickstarter: KickstarterRow) => void;
|
||||
onSendAll: (kickstarter: KickstarterRow) => void;
|
||||
}
|
||||
```
|
||||
|
||||
In the actions column dropdown, add between Edit and the separator:
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => onLinkPackages(row.original)}>
|
||||
<Link2 className="mr-2 h-3.5 w-3.5" />
|
||||
Link Packages
|
||||
</DropdownMenuItem>
|
||||
{row.original._count.packages > 0 && (
|
||||
<DropdownMenuItem onClick={() => onSendAll(row.original)}>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Send All ({row.original._count.packages})
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
```
|
||||
|
||||
Update the function signature to destructure the new props:
|
||||
|
||||
```typescript
|
||||
export function getKickstarterColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onLinkPackages,
|
||||
onSendAll,
|
||||
}: KickstarterColumnsProps): ColumnDef<KickstarterRow, unknown>[] {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire up state in kickstarter-table.tsx**
|
||||
|
||||
Add imports and state for the new dialogs:
|
||||
|
||||
```typescript
|
||||
import { PackageLinkerDialog } from "./package-linker-dialog";
|
||||
import { sendAllKickstarterPackages } from "../actions";
|
||||
|
||||
// Inside KickstarterTable:
|
||||
const [linkTarget, setLinkTarget] = useState<KickstarterRow | null>(null);
|
||||
const [sendAllTarget, setSendAllTarget] = useState<KickstarterRow | null>(null);
|
||||
```
|
||||
|
||||
Update the columns call:
|
||||
|
||||
```typescript
|
||||
const columns = getKickstarterColumns({
|
||||
onEdit: (kickstarter) => {
|
||||
setEditKickstarter(kickstarter);
|
||||
setModalOpen(true);
|
||||
},
|
||||
onDelete: (id) => setDeleteId(id),
|
||||
onLinkPackages: (kickstarter) => setLinkTarget(kickstarter),
|
||||
onSendAll: (kickstarter) => {
|
||||
startTransition(async () => {
|
||||
const result = await sendAllKickstarterPackages(kickstarter.id);
|
||||
if (result.success) {
|
||||
toast.success(`Queued ${result.data!.queued} package(s) for delivery`);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Add the `PackageLinkerDialog` before the closing `</div>` of the component's return:
|
||||
|
||||
```tsx
|
||||
{linkTarget && (
|
||||
<PackageLinkerDialog
|
||||
open={!!linkTarget}
|
||||
onOpenChange={(open) => !open && setLinkTarget(null)}
|
||||
kickstarterId={linkTarget.id}
|
||||
kickstarterName={linkTarget.name}
|
||||
initialPackageIds={[]}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
Note: `initialPackageIds` is `[]` because the table doesn't fetch linked packages. The dialog will start empty but preserve selections during the session. For a better UX, we fetch the linked IDs when the dialog opens — see step 3.
|
||||
|
||||
- [ ] **Step 3: Fetch initial linked packages when dialog opens**
|
||||
|
||||
To populate the dialog with already-linked packages, add an API route or use a server action. The simplest approach: modify the `PackageLinkerDialog` to fetch linked IDs on mount.
|
||||
|
||||
In `package-linker-dialog.tsx`, add to the `useEffect` that runs when `open` changes:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
// Fetch currently linked packages
|
||||
fetch(`/api/packages/linked?kickstarterId=${kickstarterId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.packageIds) {
|
||||
setSelectedIds(new Set(data.packageIds));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [open, kickstarterId]);
|
||||
```
|
||||
|
||||
Create the API route at `src/app/api/packages/linked/route.ts`:
|
||||
|
||||
```typescript
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getLinkedPackageIds } from "@/data/kickstarter.queries";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const kickstarterId = searchParams.get("kickstarterId");
|
||||
if (!kickstarterId) {
|
||||
return NextResponse.json({ error: "kickstarterId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const packageIds = await getLinkedPackageIds(kickstarterId);
|
||||
return NextResponse.json({ packageIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/(app)/kickstarters/_components/ src/app/api/packages/
|
||||
git commit -m "feat: wire up package linking and send-all in kickstarter table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Rebuild & Deploy App
|
||||
|
||||
- [ ] **Step 1: Rebuild app image**
|
||||
|
||||
```bash
|
||||
docker compose build app # or equivalent for the production compose
|
||||
docker tag dragonsstash:latest git.samagsteribbe.nl/admin/dragonsstash:latest
|
||||
docker compose -p dragonsstash -f /opt/stacks/DragonsStash/docker-compose.yml up -d app
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify app startup**
|
||||
|
||||
```bash
|
||||
docker logs dragonsstash --tail=20
|
||||
```
|
||||
|
||||
Expected: App starts cleanly, health check passes.
|
||||
|
||||
- [ ] **Step 3: Manual test**
|
||||
|
||||
1. Go to Kickstarters tab
|
||||
2. Open a kickstarter's row menu → "Link Packages"
|
||||
3. Search for a package, select it, save
|
||||
4. Verify the package count column updates
|
||||
5. Use "Send All" to queue all linked packages for Telegram delivery
|
||||
@@ -0,0 +1,472 @@
|
||||
# Dragonstash Grouping System Audit & Enhancement Report
|
||||
|
||||
## Appendix: Real-World Failure Cases (2026-03-29/30)
|
||||
|
||||
These skipped packages reveal two concrete issues:
|
||||
|
||||
### Issue A: `WORKER_MAX_ZIP_SIZE_MB` was 4 GB — blocking all large multipart archives
|
||||
|
||||
| File | Parts | Total Size | Status |
|
||||
|------|-------|-----------|--------|
|
||||
| DM-Stash - Guide to Tharador - Complete STL | 19 | 70.5 GB | SIZE_LIMIT |
|
||||
| DM-Stash - 2023-05 - Greywinds All-in | 16 | 58.9 GB | SIZE_LIMIT |
|
||||
| Axolote Gaming - Castle of the Vampire Lord | 10 | 18 GB | SIZE_LIMIT |
|
||||
| Dungeon Blocks - THE ULTIMATE DUNGEON | 5 | 7.6 GB | SIZE_LIMIT |
|
||||
| Dungeon Blocks - The Toxic sewer | 4 | 6.2 GB | SIZE_LIMIT |
|
||||
| Soulmist | 4 | 6.3 GB | SIZE_LIMIT |
|
||||
| Medieval Town PT1 | 3 | 5.7 GB | SIZE_LIMIT |
|
||||
| Knight Models - Game Of Thrones | 3 | 5.5 GB | SIZE_LIMIT |
|
||||
| Dungeon Blocks - The Lost Cave | 3 | 4.9 GB | SIZE_LIMIT |
|
||||
| El Miniaturista 2025-05 Fulgrim Part II and III | 5 | 4.7 GB | SIZE_LIMIT |
|
||||
|
||||
**Root cause:** Production env had `WORKER_MAX_ZIP_SIZE_MB=4096`. The default in code is 204800 (200 GB), but docker-compose.yml defaulted to 4096.
|
||||
|
||||
**Fix applied:** Raised to 204800 in `/opt/stacks/DragonsStash/.env`. Worker restarted. These archives will be retried on the next ingestion cycle. The worker downloads parts individually (each under 2-4 GB), concatenates, re-splits at 1950 MiB for upload. Peak temp disk usage for the 70.5 GB archive: ~211 GB (353 GB available).
|
||||
|
||||
**Code fix:** `MAX_PART_SIZE` is now configurable via `MAX_PART_SIZE_MB` env var (was hardcoded at 1950). Set to 3900 for Telegram Premium accounts to avoid unnecessary splitting.
|
||||
|
||||
### Issue B: Download failure at 98% (DE1-Supported.7z)
|
||||
|
||||
| File | Size | Error |
|
||||
|------|------|-------|
|
||||
| DE1-Supported.7z | 1.9 GB | Download stopped unexpectedly at 2043674624/2078338541 bytes (98%) |
|
||||
|
||||
**Root cause:** Download stalled near completion with no retry mechanism.
|
||||
|
||||
**Fix applied:** Earlier in this session, download retry logic was added (max 3 retries with `cancelDownloadFile` before each retry). This file will be retried automatically on next ingestion cycle.
|
||||
|
||||
---
|
||||
|
||||
## Deliverable 1: Audit Report — Current State
|
||||
|
||||
### 1.1 Grouping Signal Stack (Current)
|
||||
|
||||
The system currently uses exactly **one automatic grouping signal**:
|
||||
|
||||
| Priority | Signal | Status | Location |
|
||||
|----------|--------|--------|----------|
|
||||
| 1 | `mediaAlbumId` | Implemented | `worker/src/grouping.ts:26-33` |
|
||||
| 2 | Manual override | Implemented | `src/lib/telegram/queries.ts:606-639` |
|
||||
|
||||
**How it works:**
|
||||
- `processAlbumGroups()` in `worker/src/grouping.ts` groups indexed packages by `mediaAlbumId` (filtering out "0" and null)
|
||||
- For albums with 2+ members: creates `PackageGroup`, links packages, assigns name from album photo caption or first filename
|
||||
- Manual grouping via UI: select 2+ packages, enter name, creates group in `createManualGroup()`
|
||||
|
||||
**What does NOT exist:**
|
||||
- No `message_thread_id` (forum topic) scoping
|
||||
- No project/month pattern extraction from filenames
|
||||
- No creator/sender grouping
|
||||
- No time-window + sender clustering
|
||||
- No reply chain analysis
|
||||
- No ZIP internal path prefix matching
|
||||
- No caption fuzzy matching
|
||||
- No staging queue for ungrouped files
|
||||
|
||||
### 1.2 Multipart Archive Detection (`worker/src/archive/multipart.ts`)
|
||||
|
||||
This is a **separate system** from display grouping. `groupArchiveSets()` groups Telegram messages into `ArchiveSet[]` based on filename patterns:
|
||||
|
||||
- `.zip.001`, `.zip.002` → ZIP_NUMBERED
|
||||
- `.z01`, `.z02`, `.zip` → ZIP_LEGACY
|
||||
- `.part1.rar`, `.part2.rar` → RAR_PART
|
||||
- `.r00`, `.r01`, `.rar` → RAR_LEGACY
|
||||
|
||||
These are grouped by `format:baseName.toLowerCase()` key. This is about **reassembling split archives**, not UI grouping. An `ArchiveSet` becomes a single `Package` in the database.
|
||||
|
||||
### 1.3 TDLib Ingestion Handler
|
||||
|
||||
**Pipeline in `worker/src/worker.ts:801-1197`:**
|
||||
```
|
||||
processOneArchiveSet():
|
||||
1. Early skip check (source message ID)
|
||||
2. Size guard (maxZipSizeMB)
|
||||
3. Download all parts
|
||||
4. Compute SHA-256 hash
|
||||
5. Check hash dedup
|
||||
6. Read archive metadata
|
||||
7. Split/repack if needed
|
||||
8. Upload to destination
|
||||
9. Download preview
|
||||
10. Extract fallback preview
|
||||
11. Resolve creator
|
||||
12. Index in database
|
||||
13. Cleanup temp files
|
||||
```
|
||||
|
||||
**Post-indexing:** `processAlbumGroups()` is called once per channel/topic scan to create album-based groups.
|
||||
|
||||
**Gaps:**
|
||||
- Messages are never "dropped" silently — failures go to `SkippedPackage` table with reason
|
||||
- Watermark only advances past successfully processed sets (failed sets block advancement)
|
||||
- No messages are missed within a channel, but there's no audit to verify completeness after the fact
|
||||
|
||||
### 1.4 Hash Verification
|
||||
|
||||
**What IS verified:**
|
||||
| Check | Where | When |
|
||||
|-------|-------|------|
|
||||
| Download file size | `download.ts:verifyAndMove()` | After each file download |
|
||||
| SHA-256 content hash | `worker.ts:952` | After download, used for dedup |
|
||||
| Telegram upload confirmation | `channel.ts:updateMessageSendSucceeded` | Waits for server ACK |
|
||||
|
||||
**What is NOT verified:**
|
||||
| Gap | Impact |
|
||||
|-----|--------|
|
||||
| No hash after upload | Can't detect Telegram-side corruption |
|
||||
| No hash after split | Split files could be silently corrupted |
|
||||
| CRC-32 extracted but never checked | ZIP/RAR per-file integrity not validated |
|
||||
| No end-to-end hash | Split files have different hash than original |
|
||||
| No periodic audit job | Stale/missing data never detected |
|
||||
|
||||
### 1.5 File Size Limit
|
||||
|
||||
| Setting | Value | Configurable? | Location |
|
||||
|---------|-------|---------------|----------|
|
||||
| `MAX_PART_SIZE` | 1950 MiB | **Hardcoded** | `worker/src/archive/split.ts:14` |
|
||||
| `MAX_UPLOAD_SIZE` | 1950 MiB | **Hardcoded** | `worker/src/worker.ts:1023` |
|
||||
| `maxZipSizeMB` | 200 GB | `WORKER_MAX_ZIP_SIZE_MB` env var | `worker/src/util/config.ts:6` |
|
||||
|
||||
The 1950 MiB limit is deliberately below 2 GiB to avoid TDLib's `FILE_PARTS_INVALID` error. There is **no Premium awareness** — all accounts are treated as non-Premium.
|
||||
|
||||
### 1.6 Search Implementation
|
||||
|
||||
- **No fuzzy search** — uses Prisma's `contains` with `mode: "insensitive"` (translates to PostgreSQL `ILIKE`)
|
||||
- **No full-text search infrastructure** — no `tsvector`, no GiST/GIN indexes
|
||||
- **Indexes:** B-tree on `fileName`, `creator`, `archiveType`, `indexedAt`, plus `PackageFile.fileName` and `extension`
|
||||
- Search works for substring matching but won't match typos or similar names
|
||||
|
||||
### 1.7 Notification Infrastructure
|
||||
|
||||
- **pg_notify channels:** `bot_send`, `new_package` (bot), plus 7 worker channels
|
||||
- **Bot subscriptions:** pattern-match (case-insensitive substring) on `fileName` and `creator`
|
||||
- **UI notifications:** Sonner toast (ephemeral only)
|
||||
- **No persistent notification store** — no database model for notifications
|
||||
- **No notification UI panel** in the web app
|
||||
- **No alerts for:** grouping conflicts, hash mismatches, missing parts, upload failures (beyond SkippedPackage table)
|
||||
|
||||
---
|
||||
|
||||
## Deliverable 2: Revised Grouping Signal Stack
|
||||
|
||||
### Recommended Implementation Plan
|
||||
|
||||
I recommend an **incremental approach** — implement signals in phases, starting with highest-value/lowest-risk.
|
||||
|
||||
### Phase 1: Foundation (Required Before Other Signals)
|
||||
|
||||
#### Signal 9: Manual Override Persistence
|
||||
**Status:** Partially implemented. Manual groups exist but don't influence future auto-grouping.
|
||||
|
||||
**Implementation:**
|
||||
- Add `groupingSource` field to `PackageGroup`: `"ALBUM" | "MANUAL" | "AUTO_PATTERN" | "AUTO_TIME" | "AUTO_REPLY" | "AUTO_ZIP" | "AUTO_CAPTION"`
|
||||
- Manual groups already persist. What's missing is the **training feedback** where a manual grouping teaches the system to auto-group similar future files.
|
||||
- This requires a `GroupingRule` model (see schema diff below) that stores learned patterns from manual overrides.
|
||||
|
||||
#### Ungrouped Staging Queue
|
||||
**Implementation:**
|
||||
- After ingestion, packages without a `packageGroupId` are naturally "ungrouped"
|
||||
- Add a filter/tab to the STL page: "Ungrouped" showing packages where `packageGroupId IS NULL`
|
||||
- No schema change needed — just a query filter
|
||||
|
||||
### Phase 2: High-Value Automatic Signals
|
||||
|
||||
#### Signal 1: `mediaAlbumId` (Already Implemented)
|
||||
No changes needed. This is working correctly.
|
||||
|
||||
#### Signal 2: `message_thread_id` Forum Topic Scoping
|
||||
**Status:** Already used for scan scoping (worker scans by topic), but not used as a grouping signal.
|
||||
|
||||
**Implementation:**
|
||||
- `sourceTopicId` is already stored on `Package` (schema line 469)
|
||||
- Use it as a **scoping constraint** for all other signals: time-window, caption matching, etc. only apply within the same topic
|
||||
- No additional schema changes needed
|
||||
|
||||
#### Signal 5: Time Window + Sender Grouping
|
||||
**Implementation:**
|
||||
- After album grouping, find ungrouped packages from the same source channel + topic
|
||||
- Within a configurable window (default 5 min), cluster by proximity
|
||||
- Since we don't have `sender_id` from the source channel (TDLib `searchChatMessages` doesn't return it for channels), this becomes **time-window within topic/channel**
|
||||
- New config: `AUTO_GROUP_TIME_WINDOW_MINUTES` (default: 5)
|
||||
|
||||
#### Signal 3: Project/Month Pattern Extraction
|
||||
**Implementation:**
|
||||
- Extract date patterns from filenames/captions: `YYYY-MM`, `YYYY_MM`, `MonthName Year`
|
||||
- Extract project slugs: common prefix before separator (e.g., "ProjectName - File1.zip" and "ProjectName - File2.zip")
|
||||
- Group packages with matching patterns from the same channel
|
||||
- This should run as a **post-processing pass** after time-window grouping, merging small time-window groups that share a pattern
|
||||
|
||||
#### Signal 4: Creator Grouping
|
||||
**Implementation:**
|
||||
- The `creator` field is already extracted from filenames and stored per-package
|
||||
- Within a channel, if multiple ungrouped packages have the same `creator` and were indexed within the same ingestion run, auto-group them
|
||||
- Lower priority than time-window (might create overly broad groups)
|
||||
|
||||
### Phase 3: Advanced Signals
|
||||
|
||||
#### Signal 6: Reply Chain
|
||||
**Implementation:**
|
||||
- TDLib messages have `reply_to_message_id` but this isn't currently captured during scanning
|
||||
- Would need to modify `getChannelMessages()` in `download.ts` to extract `reply_to_message_id`
|
||||
- Then: if message B replies to message A, and both are archives, group them
|
||||
- **Moderate complexity**, deferred to Phase 3
|
||||
|
||||
#### Signal 7: ZIP Internal Path Prefix
|
||||
**Implementation:**
|
||||
- Already have `PackageFile.path` stored for each file inside an archive
|
||||
- After indexing, find the common root folder across all files
|
||||
- If two packages share the same root prefix and same channel, suggest grouping
|
||||
- This is a **post-hoc analysis** that could run as a background job
|
||||
|
||||
#### Signal 8: Caption Fuzzy Match
|
||||
**Implementation:**
|
||||
- Currently captions from source messages are NOT stored (only photo captions for preview matching)
|
||||
- Would need to capture `msg.content?.caption?.text` during scanning and store on Package
|
||||
- Then: fuzzy-match captions from nearby messages in same channel
|
||||
- **Requires schema change + scan modification**, deferred to Phase 3
|
||||
|
||||
---
|
||||
|
||||
## Deliverable 3: Schema Diff
|
||||
|
||||
All changes are **additive** — no columns dropped, no types changed.
|
||||
|
||||
```prisma
|
||||
// ── PackageGroup additions ──
|
||||
model PackageGroup {
|
||||
// ... existing fields ...
|
||||
groupingSource GroupingSource @default(MANUAL) // NEW: how this group was created
|
||||
}
|
||||
|
||||
// NEW enum
|
||||
enum GroupingSource {
|
||||
ALBUM // From Telegram mediaAlbumId
|
||||
MANUAL // User-created via UI
|
||||
AUTO_PATTERN // Filename/date pattern matching
|
||||
AUTO_TIME // Time-window clustering
|
||||
AUTO_REPLY // Reply chain
|
||||
AUTO_ZIP // ZIP path prefix
|
||||
AUTO_CAPTION // Caption fuzzy match
|
||||
}
|
||||
|
||||
// ── Package additions ──
|
||||
model Package {
|
||||
// ... existing fields ...
|
||||
sourceCaption String? // NEW: caption text from source Telegram message
|
||||
}
|
||||
|
||||
// ── New model: GroupingRule (training from manual overrides) ──
|
||||
model GroupingRule {
|
||||
id String @id @default(cuid())
|
||||
sourceChannelId String
|
||||
pattern String // Regex or glob pattern learned from manual grouping
|
||||
signalType GroupingSource // Which signal this rule applies to
|
||||
confidence Float @default(1.0)
|
||||
createdAt DateTime @default(now())
|
||||
createdByGroupId String? // The manual group that spawned this rule
|
||||
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sourceChannelId])
|
||||
@@map("grouping_rules")
|
||||
}
|
||||
|
||||
// ── New model: SystemNotification ──
|
||||
model SystemNotification {
|
||||
id String @id @default(cuid())
|
||||
type NotificationType
|
||||
severity NotificationSeverity @default(INFO)
|
||||
title String
|
||||
message String
|
||||
context Json? // Structured data: packageId, groupId, sourceMessageId, etc.
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([isRead, createdAt])
|
||||
@@index([type])
|
||||
@@map("system_notifications")
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
HASH_MISMATCH
|
||||
MISSING_PART
|
||||
UPLOAD_FAILED
|
||||
DOWNLOAD_FAILED
|
||||
GROUPING_CONFLICT
|
||||
INTEGRITY_AUDIT
|
||||
}
|
||||
|
||||
enum NotificationSeverity {
|
||||
INFO
|
||||
WARNING
|
||||
ERROR
|
||||
}
|
||||
|
||||
// ── Config additions (worker/src/util/config.ts) ──
|
||||
// maxPartSizeMB: parseInt(process.env.MAX_PART_SIZE_MB ?? "1950", 10)
|
||||
// autoGroupTimeWindowMinutes: parseInt(process.env.AUTO_GROUP_TIME_WINDOW_MINUTES ?? "5", 10)
|
||||
// telegramPremium: process.env.TELEGRAM_PREMIUM === "true"
|
||||
```
|
||||
|
||||
**Migration notes:**
|
||||
- All new fields are optional/have defaults — zero-risk to existing data
|
||||
- `GroupingSource` enum added with `@default(MANUAL)` — existing groups unaffected
|
||||
- `GroupingRule` and `SystemNotification` are new tables — no impact on existing
|
||||
- Backfill: set `groupingSource = ALBUM` for groups where `mediaAlbumId IS NOT NULL`
|
||||
|
||||
---
|
||||
|
||||
## Deliverable 4: Notification Contract
|
||||
|
||||
### Event Shape
|
||||
|
||||
```typescript
|
||||
interface SystemNotificationEvent {
|
||||
type: NotificationType;
|
||||
severity: "INFO" | "WARNING" | "ERROR";
|
||||
title: string;
|
||||
message: string;
|
||||
context: {
|
||||
packageId?: string;
|
||||
groupId?: string;
|
||||
sourceChannelId?: string;
|
||||
sourceMessageId?: bigint;
|
||||
fileName?: string;
|
||||
partNumber?: number;
|
||||
totalParts?: number;
|
||||
expectedHash?: string;
|
||||
actualHash?: string;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Where Notifications Fire
|
||||
|
||||
| Event | Where | Trigger |
|
||||
|-------|-------|---------|
|
||||
| `HASH_MISMATCH` | `worker/src/worker.ts` after split | SHA-256 of concatenated split parts != original hash |
|
||||
| `MISSING_PART` | Periodic audit job (new) | Group has `partCount > 1` but fewer than `partCount` dest messages exist |
|
||||
| `UPLOAD_FAILED` | `worker/src/worker.ts` catch block | Upload fails after all retries exhausted |
|
||||
| `DOWNLOAD_FAILED` | `worker/src/worker.ts` catch block | Download fails after all retries |
|
||||
| `GROUPING_CONFLICT` | Auto-grouping pass (new) | Two signals suggest different groups for the same package |
|
||||
| `INTEGRITY_AUDIT` | Periodic job (new) | Scheduled check finds inconsistencies |
|
||||
|
||||
### Delivery
|
||||
|
||||
1. **Database:** Always persisted to `SystemNotification` table
|
||||
2. **pg_notify:** `SELECT pg_notify('system_notification', jsonPayload)` for real-time
|
||||
3. **Web UI:** Notification bell/panel that polls or listens for new notifications
|
||||
4. **Telegram (optional):** Forward critical notifications to admin via bot
|
||||
|
||||
---
|
||||
|
||||
## Deliverable 5: Feature Flag Plan
|
||||
|
||||
### Runtime Configuration (Environment Variables)
|
||||
|
||||
| Flag | Type | Default | Purpose |
|
||||
|------|------|---------|---------|
|
||||
| `TELEGRAM_PREMIUM` | boolean | `false` | Enable 4GB upload limit |
|
||||
| `MAX_PART_SIZE_MB` | number | `1950` | Split threshold in MiB (overrides hardcoded value) |
|
||||
| `AUTO_GROUP_ENABLED` | boolean | `false` | Enable automatic grouping beyond album |
|
||||
| `AUTO_GROUP_TIME_WINDOW_MINUTES` | number | `5` | Time-window clustering threshold |
|
||||
| `AUTO_GROUP_PATTERN_ENABLED` | boolean | `false` | Enable filename/date pattern grouping |
|
||||
| `INTEGRITY_AUDIT_ENABLED` | boolean | `false` | Enable periodic integrity audit |
|
||||
| `INTEGRITY_AUDIT_INTERVAL_HOURS` | number | `24` | How often to run the audit |
|
||||
|
||||
### Premium Mode Behavior
|
||||
|
||||
When `TELEGRAM_PREMIUM=true`:
|
||||
1. `MAX_PART_SIZE_MB` defaults to `3900` (safely under 4 GiB) instead of `1950`
|
||||
2. Files under 4 GB: uploaded as-is (no splitting)
|
||||
3. Files over 4 GB: split using existing `byteLevelSplit()` at the new threshold
|
||||
4. Existing split/rejoin logic is **kept as fallback** — never removed
|
||||
5. `isMultipart` and `partCount` continue to track actual upload state
|
||||
|
||||
### Implementation in `split.ts`:
|
||||
|
||||
```typescript
|
||||
// Replace hardcoded constant with config-driven:
|
||||
const MAX_PART_SIZE = BigInt(config.maxPartSizeMB) * 1024n * 1024n;
|
||||
```
|
||||
|
||||
And in `config.ts`:
|
||||
```typescript
|
||||
maxPartSizeMB: parseInt(
|
||||
process.env.MAX_PART_SIZE_MB ??
|
||||
(process.env.TELEGRAM_PREMIUM === "true" ? "3900" : "1950"),
|
||||
10
|
||||
),
|
||||
```
|
||||
|
||||
### Rollout Strategy
|
||||
|
||||
1. **All flags default to off** — zero behavior change on deploy
|
||||
2. Enable `TELEGRAM_PREMIUM` first (simple, well-understood)
|
||||
3. Enable `AUTO_GROUP_ENABLED` on a **per-channel basis** (see test plan) before globally
|
||||
4. Enable `INTEGRITY_AUDIT_ENABLED` after manual validation
|
||||
5. Pattern-based grouping enabled last (highest complexity)
|
||||
|
||||
---
|
||||
|
||||
## Deliverable 6: Test Plan
|
||||
|
||||
### Phase 0: Pre-Implementation Validation
|
||||
|
||||
Before touching any code, verify the current system baseline:
|
||||
|
||||
1. **Pick one test channel** with known content (a mix of albums, single files, and multipart archives)
|
||||
2. Run an ingestion cycle and record: number of packages, groups, skipped
|
||||
3. Verify all album-based groups are correct
|
||||
4. Note any ungrouped files that "should" be grouped
|
||||
5. This becomes the **regression baseline**
|
||||
|
||||
### Phase 1: Premium Mode Testing
|
||||
|
||||
1. Set `TELEGRAM_PREMIUM=true` and `MAX_PART_SIZE_MB=3900`
|
||||
2. Manually upload a 3 GB test file to a source channel
|
||||
3. Trigger ingestion — verify it uploads as a single message (not split)
|
||||
4. Manually upload a 5 GB test file
|
||||
5. Trigger ingestion — verify it splits at ~3.9 GB threshold
|
||||
6. Verify `isMultipart`, `partCount`, `destMessageIds` are correct
|
||||
7. Send the package via bot — verify all parts arrive
|
||||
|
||||
### Phase 2: Time-Window Grouping Testing
|
||||
|
||||
1. Enable `AUTO_GROUP_ENABLED=true` on the test channel only
|
||||
2. Post 3 files to the channel within 2 minutes (no album)
|
||||
3. Trigger ingestion — verify they auto-group
|
||||
4. Post 2 files 10 minutes apart
|
||||
5. Trigger ingestion — verify they stay ungrouped
|
||||
6. Manually group them — verify `GroupingRule` is created
|
||||
7. Post similar files — verify auto-grouping kicks in
|
||||
|
||||
### Phase 3: Manual QA via API
|
||||
|
||||
Add a **test endpoint** (dev-only) that accepts a fake message payload and runs it through the grouping pipeline without hitting Telegram:
|
||||
|
||||
```
|
||||
POST /api/dev/test-grouping
|
||||
Body: { messages: [...], channelId: "..." }
|
||||
Response: { suggestedGroups: [...] }
|
||||
```
|
||||
|
||||
This allows testing grouping logic against crafted scenarios without waiting for real Telegram messages.
|
||||
|
||||
### Phase 4: Integrity Audit Testing
|
||||
|
||||
1. Enable `INTEGRITY_AUDIT_ENABLED=true`
|
||||
2. Manually corrupt a record (set wrong `contentHash` in DB)
|
||||
3. Run audit — verify `HASH_MISMATCH` notification is created
|
||||
4. Delete one `destMessageId` from a multipart package's `destMessageIds`
|
||||
5. Run audit — verify `MISSING_PART` notification is created
|
||||
6. Check notification UI shows both
|
||||
|
||||
### Regression Checks After Each Phase
|
||||
|
||||
- Re-run ingestion on test channel — same number of packages/groups as baseline
|
||||
- Search for known filenames — still returns correct results
|
||||
- Send a package via bot — still delivers correctly
|
||||
- Album groups unchanged
|
||||
- Manual groups unchanged
|
||||
@@ -0,0 +1,67 @@
|
||||
# Grouping Phase 1: Foundation + Time-Window Grouping
|
||||
|
||||
> **For agentic workers:** Use superpowers:subagent-driven-development to implement this plan.
|
||||
|
||||
**Goal:** Add grouping infrastructure (schema, enums, notifications model), an ungrouped staging queue in the UI, and time-window auto-grouping as the first automatic signal beyond album grouping.
|
||||
|
||||
**Architecture:** Schema changes lay the foundation. Ungrouped tab is a query filter. Time-window grouping runs as a post-processing pass after album grouping in the worker pipeline.
|
||||
|
||||
**Tech Stack:** Prisma schema + migration, worker TypeScript, Next.js App Router.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Schema Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `prisma/schema.prisma`
|
||||
- Create: migration SQL
|
||||
|
||||
Add:
|
||||
1. `GroupingSource` enum: `ALBUM`, `MANUAL`, `AUTO_TIME`, `AUTO_PATTERN`, `AUTO_REPLY`, `AUTO_ZIP`, `AUTO_CAPTION`
|
||||
2. `groupingSource GroupingSource @default(MANUAL)` on `PackageGroup`
|
||||
3. `SystemNotification` model with `type`, `severity`, `title`, `message`, `context` (Json), `isRead`
|
||||
4. `NotificationType` enum: `HASH_MISMATCH`, `MISSING_PART`, `UPLOAD_FAILED`, `DOWNLOAD_FAILED`, `GROUPING_CONFLICT`, `INTEGRITY_AUDIT`
|
||||
5. `NotificationSeverity` enum: `INFO`, `WARNING`, `ERROR`
|
||||
|
||||
Backfill: `UPDATE package_groups SET "groupingSource" = 'ALBUM' WHERE "mediaAlbumId" IS NOT NULL`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Ungrouped Staging Tab in STL Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/telegram/queries.ts` — add `listUngroupedPackages()` query
|
||||
- Modify: `src/app/(app)/stls/page.tsx` — add tab parameter support
|
||||
- Modify: `src/app/(app)/stls/_components/stl-table.tsx` — add "Ungrouped" tab
|
||||
|
||||
Add a tab next to the existing "Skipped" tab that shows packages where `packageGroupId IS NULL`. Uses the existing `PackageListItem` type and table rendering. This gives users a clear view of files that need manual grouping.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Time-Window Auto-Grouping in Worker
|
||||
|
||||
**Files:**
|
||||
- Create: `worker/src/grouping.ts` — add `processTimeWindowGroups()` after existing `processAlbumGroups()`
|
||||
- Modify: `worker/src/worker.ts` — call time-window grouping after album grouping
|
||||
- Modify: `worker/src/util/config.ts` — add `autoGroupTimeWindowMinutes` config
|
||||
|
||||
After album grouping completes, find remaining ungrouped packages from the same channel scan. Cluster packages whose `sourceMessageId` timestamps are within the configured window (default 5 minutes). Create groups for clusters of 2+ with `groupingSource = AUTO_TIME` and name derived from the common filename prefix or first file's base name.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Hash Verification After Split
|
||||
|
||||
**Files:**
|
||||
- Modify: `worker/src/worker.ts` — add hash re-check after concat+split
|
||||
- Modify: `worker/src/archive/hash.ts` — (no changes needed, reuse `hashParts`)
|
||||
|
||||
After `concatenateFiles()` + `byteLevelSplit()`, re-hash the split parts and compare to the original `contentHash`. If mismatch, log error and create a `SystemNotification` (once that table exists). This closes the integrity gap identified in the audit.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Build & Deploy
|
||||
|
||||
Rebuild worker and app images. Deploy. Verify:
|
||||
- Worker logs show `maxPartSizeMB` and new `autoGroupTimeWindowMinutes` in config
|
||||
- Ungrouped tab visible in STL page
|
||||
- Previously-skipped large archives begin processing
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -0,0 +1,555 @@
|
||||
# NAS Backup for Postgres + TDLib State 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 `backup` container to the DragonsStash stack that takes daily, encrypted, deduplicated backups of the Postgres database and both TDLib state volumes, and ships them to a Synology NAS over NFS.
|
||||
|
||||
**Architecture:** A small Alpine-based image (restic + postgresql16-client + curl + dcron) runs as its own compose service. A crontab fires `backup.sh` daily at 03:00, which dumps Postgres, tars the TDLib volumes, hands both to `restic backup` against an NFS-backed Docker volume, prunes with `restic forget --keep-daily 14`, and reports success/failure to an Uptime Kuma push monitor. Matches the existing `worker`/`bot` pattern: build context in the repo's `docker-compose.yml`, prebuilt image in `/opt/stacks/DragonsStash/docker-compose.yml`, built and pushed by `.drone.yml`.
|
||||
|
||||
**Tech Stack:** Alpine 3.20, restic 0.16, postgresql16-client, dcron, bash, Docker Compose NFS volume driver.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Retention: `restic forget --keep-daily 14 --prune` (14-day window, per approved spec).
|
||||
- Schedule: daily backup at 03:00, weekly `restic check` at 04:00 Sunday.
|
||||
- No Docker socket mount, no `privileged: true` — the backup container must not be able to control sibling containers.
|
||||
- No host-level mount — NFS access only via Docker's native `driver_opts: type: nfs` volume, never `/etc/fstab`.
|
||||
- Encryption and retention are restic's job — no hand-rolled `age`/`gpg`/`find -mtime` logic.
|
||||
- TDLib volumes are tarred live (best-effort) — never pause `worker`/`bot` for the backup.
|
||||
- Restore is a manual, documented procedure only — never scripted/automated.
|
||||
|
||||
**Required user input before Task 5 can run:** `NAS_HOST` and `NAS_EXPORT_PATH` (the Synology NFS share details) and a Kuma Push-monitor URL (`KUMA_PUSH_URL`, created manually in the existing Uptime Kuma instance, ~26h expected heartbeat interval). Tasks 1–4 need none of these and can proceed immediately; do not substitute placeholder values for them in Task 5 — stop and ask the user instead.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backup image (Dockerfile + entrypoint)
|
||||
|
||||
**Files:**
|
||||
- Create: `backup/Dockerfile`
|
||||
- Create: `backup/entrypoint.sh`
|
||||
- Create: `backup/crontab`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a buildable image tagged `dragonsstash-backup:test` locally, with `/entrypoint.sh` as `ENTRYPOINT`, `/backup.sh` present at the image root (written in Task 2 — this task only needs the `COPY` line and a placeholder-free stub isn't acceptable, so create an empty‑body-but-real `backup/backup.sh` here containing just `#!/bin/bash` + `exit 0`, and Task 2 replaces its contents), `restic`, `pg_dump`/`pg_restore`, `curl`, `tar`, `bash`, `dcron` all on `PATH`.
|
||||
- Consumes: nothing from earlier tasks.
|
||||
|
||||
- [ ] **Step 1: Write `backup/backup.sh` stub**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
exit 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write `backup/entrypoint.sh`**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
if ! restic snapshots >/dev/null 2>&1; then
|
||||
restic init
|
||||
fi
|
||||
|
||||
exec crond -f -l 2
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write `backup/crontab`**
|
||||
|
||||
```
|
||||
0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1
|
||||
0 4 * * 0 restic check >> /proc/1/fd/1 2>&1
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write `backup/Dockerfile`**
|
||||
|
||||
```dockerfile
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache restic postgresql16-client curl tzdata dcron tar bash
|
||||
|
||||
COPY backup/backup.sh /backup.sh
|
||||
COPY backup/entrypoint.sh /entrypoint.sh
|
||||
COPY backup/crontab /etc/crontabs/root
|
||||
|
||||
RUN chmod +x /backup.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build the image**
|
||||
|
||||
Run: `cd /home/sam/Documents/DragonsStash && docker build -t dragonsstash-backup:test -f backup/Dockerfile .`
|
||||
Expected: build completes with `Successfully tagged dragonsstash-backup:test` (or Buildkit's equivalent final `naming to docker.io/library/dragonsstash-backup:test done`), no errors.
|
||||
|
||||
- [ ] **Step 6: Verify the tools are present**
|
||||
|
||||
Run: `docker run --rm dragonsstash-backup:test restic version && docker run --rm dragonsstash-backup:test pg_dump --version`
|
||||
Expected: `restic 0.16.x ...` and `pg_dump (PostgreSQL) 16.x` printed, both commands exit 0.
|
||||
|
||||
- [ ] **Step 7: Verify the entrypoint initializes an empty repo and starts cron**
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/backup-repo-smoke
|
||||
docker run -d --name backup-smoke \
|
||||
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=smoketest \
|
||||
-v /tmp/backup-repo-smoke:/backups \
|
||||
dragonsstash-backup:test
|
||||
sleep 2
|
||||
docker logs backup-smoke
|
||||
docker exec backup-smoke restic snapshots
|
||||
docker rm -f backup-smoke
|
||||
rm -rf /tmp/backup-repo-smoke
|
||||
```
|
||||
|
||||
Expected: `docker logs` shows no errors (restic init ran silently); `restic snapshots` prints an empty snapshot list (repo exists, header row only, no error).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add backup/Dockerfile backup/entrypoint.sh backup/backup.sh backup/crontab
|
||||
git commit -m "Add backup service image (Dockerfile, entrypoint, crontab)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `backup.sh` script
|
||||
|
||||
**Files:**
|
||||
- Modify: `backup/backup.sh` (replace Task 1's stub with the real script)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the image built in Task 1 (`dragonsstash-backup:test`), rebuilt after this change.
|
||||
- Produces: `/backup.sh`, invoked by cron in Task 1's `crontab` and manually in Task 6's verification. Reads env vars `POSTGRES_USER`, `PGPASSWORD`, `POSTGRES_DB`, `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, `KUMA_PUSH_URL`. Assumes network hostname `dragonsstash-db:5432` for Postgres and mounts `/data/tdlib-worker`, `/data/tdlib-bot` (read-only) for TDLib state.
|
||||
|
||||
- [ ] **Step 1: Replace `backup/backup.sh` with the real script**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
report_failure() {
|
||||
curl -fsS "$KUMA_PUSH_URL" --get \
|
||||
--data-urlencode "status=down" \
|
||||
--data-urlencode "msg=$BASH_COMMAND failed" || true
|
||||
}
|
||||
trap report_failure ERR
|
||||
|
||||
DUMP_FILE=/tmp/dragonsstash.dump
|
||||
TAR_FILE=/tmp/tdlib.tar.gz
|
||||
|
||||
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc -f "$DUMP_FILE"
|
||||
|
||||
tar czf "$TAR_FILE" -C /data tdlib-worker tdlib-bot
|
||||
|
||||
restic backup "$DUMP_FILE" "$TAR_FILE"
|
||||
restic forget --keep-daily 14 --prune
|
||||
|
||||
rm -f "$DUMP_FILE" "$TAR_FILE"
|
||||
|
||||
curl -fsS "$KUMA_PUSH_URL" --get \
|
||||
--data-urlencode "status=up" \
|
||||
--data-urlencode "msg=OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rebuild the image**
|
||||
|
||||
Run: `docker build -t dragonsstash-backup:test -f backup/Dockerfile .`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Stand up a scratch Postgres aliased as `dragonsstash-db`**
|
||||
|
||||
```bash
|
||||
docker network create backup-test-net 2>/dev/null || true
|
||||
docker run -d --name test-pg --network backup-test-net --network-alias dragonsstash-db \
|
||||
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=stash -e POSTGRES_DB=dragonsstash \
|
||||
postgres:16-alpine
|
||||
sleep 5
|
||||
docker exec test-pg pg_isready -U dragons -d dragonsstash
|
||||
```
|
||||
|
||||
Expected: `accepting connections`.
|
||||
|
||||
- [ ] **Step 4: Stand up a mock Kuma push endpoint**
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/mock-kuma-root && touch /tmp/mock-kuma-root/push
|
||||
docker run -d --name mock-kuma --network backup-test-net \
|
||||
-v /tmp/mock-kuma-root:/srv -w /srv python:3-alpine \
|
||||
python3 -m http.server 8000
|
||||
sleep 1
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Prepare fake TDLib state and a local restic repo dir**
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/backup-test/tdlib-worker /tmp/backup-test/tdlib-bot /tmp/backup-test/repo
|
||||
echo "fake-session" > /tmp/backup-test/tdlib-worker/state.bin
|
||||
echo "fake-session" > /tmp/backup-test/tdlib-bot/state.bin
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Initialize the test restic repo and run `backup.sh` (success path)**
|
||||
|
||||
```bash
|
||||
docker run --rm --network backup-test-net \
|
||||
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
|
||||
-v /tmp/backup-test/repo:/backups \
|
||||
--entrypoint restic dragonsstash-backup:test init
|
||||
|
||||
docker run --rm --network backup-test-net \
|
||||
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=stash -e PGPASSWORD=stash -e POSTGRES_DB=dragonsstash \
|
||||
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
|
||||
-e KUMA_PUSH_URL=http://mock-kuma:8000/push \
|
||||
-v /tmp/backup-test/tdlib-worker:/data/tdlib-worker:ro \
|
||||
-v /tmp/backup-test/tdlib-bot:/data/tdlib-bot:ro \
|
||||
-v /tmp/backup-test/repo:/backups \
|
||||
--entrypoint /backup.sh dragonsstash-backup:test
|
||||
echo "exit code: $?"
|
||||
```
|
||||
|
||||
Expected: exit code `0`, restic prints a line like `snapshot xxxxxxxx saved`, no error output.
|
||||
|
||||
- [ ] **Step 7: Verify the snapshot landed and contains both files**
|
||||
|
||||
```bash
|
||||
docker run --rm -v /tmp/backup-test/repo:/backups \
|
||||
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
|
||||
--entrypoint restic dragonsstash-backup:test snapshots
|
||||
|
||||
docker run --rm -v /tmp/backup-test/repo:/backups \
|
||||
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
|
||||
--entrypoint restic dragonsstash-backup:test ls latest
|
||||
```
|
||||
|
||||
Expected: `snapshots` shows exactly one entry; `ls latest` lists `/tmp/dragonsstash.dump` and `/tmp/tdlib.tar.gz`.
|
||||
|
||||
- [ ] **Step 8: Verify the failure path reports to Kuma**
|
||||
|
||||
```bash
|
||||
docker run --rm --network backup-test-net \
|
||||
-e POSTGRES_USER=dragons -e POSTGRES_PASSWORD=wrongpass -e PGPASSWORD=wrongpass -e POSTGRES_DB=dragonsstash \
|
||||
-e RESTIC_REPOSITORY=/backups/restic-repo -e RESTIC_PASSWORD=testpassword \
|
||||
-e KUMA_PUSH_URL=http://mock-kuma:8000/push \
|
||||
-v /tmp/backup-test/tdlib-worker:/data/tdlib-worker:ro \
|
||||
-v /tmp/backup-test/tdlib-bot:/data/tdlib-bot:ro \
|
||||
-v /tmp/backup-test/repo:/backups \
|
||||
--entrypoint /backup.sh dragonsstash-backup:test
|
||||
echo "exit code: $?"
|
||||
docker logs mock-kuma | tail -5
|
||||
```
|
||||
|
||||
Expected: exit code nonzero (pg_dump auth failure trips `set -e`); `docker logs mock-kuma` shows a GET request line containing `status=down`.
|
||||
|
||||
- [ ] **Step 9: Clean up test resources**
|
||||
|
||||
```bash
|
||||
docker rm -f test-pg mock-kuma
|
||||
docker network rm backup-test-net
|
||||
rm -rf /tmp/backup-test /tmp/mock-kuma-root
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add backup/backup.sh
|
||||
git commit -m "Implement backup.sh: pg_dump + tdlib tar + restic backup/forget + Kuma reporting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire the `backup` service into the repo's `docker-compose.yml`
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.yml`
|
||||
- Modify: `.env.example`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `backup/Dockerfile` (Task 1), `backup/backup.sh` (Task 2).
|
||||
- Produces: a `backup` compose service buildable via `docker compose build backup`, and a `nas_backups` named volume other tasks (5) will mirror into the production compose file.
|
||||
|
||||
- [ ] **Step 1: Add the `backup` service to `docker-compose.yml`**
|
||||
|
||||
Insert after the existing `bot` service (before `db`):
|
||||
|
||||
```yaml
|
||||
backup:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backup/Dockerfile
|
||||
pull_policy: never
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-dragons}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
|
||||
- RESTIC_REPOSITORY=/backups/restic-repo
|
||||
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
|
||||
- KUMA_PUSH_URL=${KUMA_PUSH_URL:?Set KUMA_PUSH_URL in .env}
|
||||
- TZ=${TZ:-Etc/UTC}
|
||||
volumes:
|
||||
- tdlib_state:/data/tdlib-worker:ro
|
||||
- tdlib_bot_state:/data/tdlib-bot:ro
|
||||
- nas_backups:/backups
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
networks:
|
||||
- backend
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `nas_backups` volume to the `volumes:` block**
|
||||
|
||||
```yaml
|
||||
nas_backups:
|
||||
driver_opts:
|
||||
type: nfs
|
||||
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
|
||||
device: ":${NAS_EXPORT_PATH}"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Document the new env vars in `.env.example`**
|
||||
|
||||
Append:
|
||||
|
||||
```
|
||||
# Backup (NAS via NFS + restic)
|
||||
NAS_HOST="" # Synology NAS IP or hostname reachable from this host
|
||||
NAS_EXPORT_PATH="" # NFS export path, e.g. /volume1/dragonsstash-backups
|
||||
RESTIC_PASSWORD="" # generate with: openssl rand -base64 32
|
||||
KUMA_PUSH_URL="" # Uptime Kuma Push monitor URL (create the monitor first)
|
||||
TZ="Etc/UTC"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate the compose file parses**
|
||||
|
||||
Run (with dummy values so the `:?` guards don't fail parsing — `AUTH_SECRET` is required by the existing `app` service, not by this change, but `config` validates the whole file):
|
||||
```bash
|
||||
RESTIC_PASSWORD=dummy KUMA_PUSH_URL=http://dummy NAS_HOST=dummy NAS_EXPORT_PATH=/dummy AUTH_SECRET=dummy \
|
||||
docker compose -f docker-compose.yml config --quiet
|
||||
```
|
||||
Expected: no output, exit code 0 (a syntax/interpolation error would print to stderr and exit nonzero).
|
||||
|
||||
- [ ] **Step 5: Validate the service actually builds through Compose**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
RESTIC_PASSWORD=dummy KUMA_PUSH_URL=http://dummy NAS_HOST=dummy NAS_EXPORT_PATH=/dummy AUTH_SECRET=dummy \
|
||||
docker compose -f docker-compose.yml build backup
|
||||
```
|
||||
Expected: build succeeds (reuses Task 1's image layers).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml .env.example
|
||||
git commit -m "Add backup service and nas_backups volume to docker-compose.yml"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CI — build and push the backup image
|
||||
|
||||
**Files:**
|
||||
- Modify: `.drone.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `backup/Dockerfile` (Task 1).
|
||||
- Produces: `git.samagsteribbe.nl/admin/dragonsstash-backup:latest` (and `:<short-sha>`), pushed on every push to `main`. Task 5's production compose file references this image tag.
|
||||
|
||||
- [ ] **Step 1: Add a `build-backup` step, mirroring `build-worker`/`build-bot`**
|
||||
|
||||
Insert after the existing `build-bot` step in `.drone.yml`:
|
||||
|
||||
```yaml
|
||||
- name: build-backup
|
||||
image: plugins/docker
|
||||
depends_on: [clone]
|
||||
settings:
|
||||
repo: git.samagsteribbe.nl/admin/dragonsstash-backup
|
||||
registry: git.samagsteribbe.nl
|
||||
dockerfile: backup/Dockerfile
|
||||
tags:
|
||||
- latest
|
||||
- "${DRONE_COMMIT_SHA:0:8}"
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
from_secret: gitea_password
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `build-backup` to the `deploy` step's `depends_on`**
|
||||
|
||||
Change:
|
||||
```yaml
|
||||
- name: deploy
|
||||
image: alpine
|
||||
depends_on: [build-app, build-worker, build-bot]
|
||||
```
|
||||
to:
|
||||
```yaml
|
||||
- name: deploy
|
||||
image: alpine
|
||||
depends_on: [build-app, build-worker, build-bot, build-backup]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Validate YAML syntax**
|
||||
|
||||
Run: `python3 -c "import yaml; yaml.safe_load(open('.drone.yml')); print('OK')"`
|
||||
Expected: `OK` printed, no exception.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .drone.yml
|
||||
git commit -m "Add build-backup CI step, include it in deploy dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire production (`/opt/stacks/DragonsStash`) — requires NAS + Kuma details from the user
|
||||
|
||||
**Do not start this task until the user has supplied `NAS_HOST` and `NAS_EXPORT_PATH` for the Synology NFS share, and has created an Uptime Kuma Push monitor (name it `dragonsstash-backup`, ~26h expected heartbeat interval) and shared its push URL. If any of these are missing, stop and ask — do not substitute placeholder values here, since this file drives the real deployment.**
|
||||
|
||||
**Files:**
|
||||
- Modify: `/opt/stacks/DragonsStash/docker-compose.yml`
|
||||
- Modify: `/opt/stacks/DragonsStash/.env`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `git.samagsteribbe.nl/admin/dragonsstash-backup:latest` (published by Task 4's CI step once merged/pushed), the real `NAS_HOST`/`NAS_EXPORT_PATH`/`KUMA_PUSH_URL` values gathered above.
|
||||
- Produces: a running `dragonsstash-backup` container on the production host, verified in Task 6.
|
||||
|
||||
- [ ] **Step 1: Generate `RESTIC_PASSWORD` and add all four new vars to `/opt/stacks/DragonsStash/.env`**
|
||||
|
||||
```bash
|
||||
cd /opt/stacks/DragonsStash
|
||||
printf '\n# Backup (NAS via NFS + restic)\nNAS_HOST="<value from user>"\nNAS_EXPORT_PATH="<value from user>"\nRESTIC_PASSWORD="%s"\nKUMA_PUSH_URL="<value from user>"\nTZ="Etc/UTC"\n' "$(openssl rand -base64 32)" >> .env
|
||||
```
|
||||
|
||||
Replace the two `<value from user>` placeholders with the real NAS details and Kuma push URL before saving — this step cannot be completed with the literal placeholder text left in place.
|
||||
|
||||
- [ ] **Step 2: Add the `backup` service to `/opt/stacks/DragonsStash/docker-compose.yml`**
|
||||
|
||||
Insert after the existing `bot` service (before `db`):
|
||||
|
||||
```yaml
|
||||
backup:
|
||||
image: git.samagsteribbe.nl/admin/dragonsstash-backup:latest
|
||||
container_name: dragonsstash-backup
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-dragons}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-stash}
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD:-stash}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-dragonsstash}
|
||||
- RESTIC_REPOSITORY=/backups/restic-repo
|
||||
- RESTIC_PASSWORD=${RESTIC_PASSWORD:?Set RESTIC_PASSWORD in .env}
|
||||
- KUMA_PUSH_URL=${KUMA_PUSH_URL:?Set KUMA_PUSH_URL in .env}
|
||||
- TZ=${TZ:-Etc/UTC}
|
||||
volumes:
|
||||
- tdlib_state:/data/tdlib-worker:ro
|
||||
- tdlib_bot_state:/data/tdlib-bot:ro
|
||||
- nas_backups:/backups
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
networks:
|
||||
- internal
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the `nas_backups` volume**
|
||||
|
||||
```yaml
|
||||
nas_backups:
|
||||
driver_opts:
|
||||
type: nfs
|
||||
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
|
||||
device: ":${NAS_EXPORT_PATH}"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Validate the production compose file parses with the real `.env`**
|
||||
|
||||
Run: `cd /opt/stacks/DragonsStash && docker compose config --quiet`
|
||||
Expected: no output, exit code 0.
|
||||
|
||||
- [ ] **Step 5: Commit is not applicable here** — `/opt/stacks/DragonsStash` is a deployed copy, not the git repo (confirm with `git -C /opt/stacks/DragonsStash status` — expect "not a git repository"). Skip committing; Task 6 deploys these file changes directly.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Deploy and verify
|
||||
|
||||
**Files:** none (operational task)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: everything from Tasks 1–5.
|
||||
- Produces: a running, verified backup on the real NAS, and one completed restore drill.
|
||||
|
||||
- [ ] **Step 1: Confirm with the user before pushing/deploying**
|
||||
|
||||
Pushing to `main` triggers Drone CI to build all four images and deploy to the production host via SSH (`docker compose pull && docker compose up -d`). Confirm the user wants this to happen now before proceeding — this affects the live stack.
|
||||
|
||||
- [ ] **Step 2: Push to `main`**
|
||||
|
||||
```bash
|
||||
cd /home/sam/Documents/DragonsStash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: Drone pipeline runs `build-app`, `build-worker`, `build-bot`, `build-backup`, then `deploy`, all green. Check via the Drone UI or `drone build info admin/DragonsStash <build-number>` if the `drone` CLI is configured.
|
||||
|
||||
- [ ] **Step 3: Confirm the container is up on the production host**
|
||||
|
||||
```bash
|
||||
ssh sam@192.168.68.68 "docker ps --filter name=dragonsstash-backup --format '{{.Names}}\t{{.Status}}'"
|
||||
```
|
||||
|
||||
Expected: `dragonsstash-backup Up ...`.
|
||||
|
||||
- [ ] **Step 4: Trigger one manual backup run and confirm a snapshot lands on the NAS**
|
||||
|
||||
```bash
|
||||
ssh sam@192.168.68.68 "docker exec dragonsstash-backup /backup.sh"
|
||||
ssh sam@192.168.68.68 "docker exec dragonsstash-backup restic snapshots"
|
||||
```
|
||||
|
||||
Expected: `backup.sh` exits 0; `restic snapshots` lists exactly one entry.
|
||||
|
||||
- [ ] **Step 5: Confirm the Uptime Kuma push monitor shows green**
|
||||
|
||||
Open the Uptime Kuma dashboard and check the `dragonsstash-backup` monitor's status is up with a recent heartbeat.
|
||||
|
||||
- [ ] **Step 6: Restore drill — prove the backup is actually restorable**
|
||||
|
||||
```bash
|
||||
ssh sam@192.168.68.68 "docker exec dragonsstash-backup restic restore latest --target /tmp/restore-drill"
|
||||
ssh sam@192.168.68.68 "docker exec dragonsstash-backup ls -la /tmp/restore-drill/tmp"
|
||||
```
|
||||
|
||||
Expected: `/tmp/restore-drill/tmp/dragonsstash.dump` and `/tmp/restore-drill/tmp/tdlib.tar.gz` both present with nonzero size. Then, on a scratch Postgres (not the live `dragonsstash-db`), confirm the dump restores cleanly:
|
||||
|
||||
```bash
|
||||
ssh sam@192.168.68.68 "docker run -d --rm --name restore-drill-pg --network dragonsstash_internal \
|
||||
-e POSTGRES_USER=drill -e POSTGRES_PASSWORD=drill -e POSTGRES_DB=drill postgres:16-alpine"
|
||||
ssh sam@192.168.68.68 "docker cp dragonsstash-backup:/tmp/restore-drill/tmp/dragonsstash.dump /tmp/dragonsstash.dump"
|
||||
ssh sam@192.168.68.68 "docker cp /tmp/dragonsstash.dump restore-drill-pg:/tmp/dragonsstash.dump"
|
||||
ssh sam@192.168.68.68 "docker exec -e PGPASSWORD=drill restore-drill-pg pg_restore -U drill -d drill --clean --if-exists /tmp/dragonsstash.dump"
|
||||
ssh sam@192.168.68.68 "docker exec -e PGPASSWORD=drill restore-drill-pg psql -U drill -d drill -c '\\dt' | head -20"
|
||||
ssh sam@192.168.68.68 "docker rm -f restore-drill-pg"
|
||||
```
|
||||
|
||||
Expected: `pg_restore` completes without fatal errors; `\dt` lists the app's tables (e.g. `Package`, `User`, `TelegramLink`).
|
||||
|
||||
- [ ] **Step 7: Clean up drill artifacts**
|
||||
|
||||
```bash
|
||||
ssh sam@192.168.68.68 "docker exec dragonsstash-backup rm -rf /tmp/restore-drill"
|
||||
ssh sam@192.168.68.68 "rm -f /tmp/dragonsstash.dump"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+241
@@ -0,0 +1,241 @@
|
||||
# Design: Search Match Indicators, Size Limit Increase, Skipped/Failed Files Overview
|
||||
|
||||
**Date:** 2026-03-24
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Three related improvements to the STL packages system:
|
||||
|
||||
1. **Search match indicators** — Show which internal files matched a search query, with highlighted files in the drawer
|
||||
2. **Size limit increase** — Raise the ingestion limit from 4 GB to 200 GB so large multipart archives aren't skipped
|
||||
3. **Skipped/failed files overview** — Track and display archives that were skipped or failed, with retry capability
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Size Limit Increase
|
||||
|
||||
### Change
|
||||
|
||||
`worker/src/util/config.ts` line 6 — change default from `"4096"` to `"204800"`.
|
||||
|
||||
One-line change. The split/upload pipeline already handles arbitrary sizes. The 2 GB per-part Telegram API limit is a separate hard-coded constant and stays as-is.
|
||||
|
||||
### Impact
|
||||
|
||||
- Archives up to 200 GB will now be attempted
|
||||
- Multipart archives where individual parts are under 2 GB (but total exceeds 4 GB) will no longer be skipped — these upload directly without any splitting
|
||||
- Single files over 2 GB are automatically split into 2 GB parts (existing behavior)
|
||||
- Temp disk usage during processing can now reach up to ~200 GB per archive
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Search Match Indicators
|
||||
|
||||
### Backend Changes
|
||||
|
||||
**File:** `src/lib/telegram/queries.ts` — `searchPackages()`
|
||||
|
||||
When `searchIn` is `"files"` or `"both"`, change the PackageFile query from `distinct` to a **grouped count**:
|
||||
|
||||
```typescript
|
||||
// Current: findMany with select: { packageId }, distinct: ["packageId"]
|
||||
// New: groupBy packageId with _count
|
||||
const fileMatches = await prisma.packageFile.groupBy({
|
||||
by: ["packageId"],
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: q, mode: "insensitive" } },
|
||||
{ path: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
```
|
||||
|
||||
This returns `{ packageId: string, _count: { _all: number } }[]`.
|
||||
|
||||
Note: `PackageRow` in `package-columns.tsx` mirrors `PackageListItem` and must also receive the two new fields.
|
||||
|
||||
**File:** `src/lib/telegram/types.ts` — `PackageListItem`
|
||||
|
||||
Add two fields:
|
||||
- `matchedFileCount: number` — how many files inside matched (0 if matched by package name only)
|
||||
- `matchedByContent: boolean` — true if any files inside matched
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
**File:** `src/app/(app)/stls/page.tsx`
|
||||
|
||||
Pass the search term to `StlTable` as a new prop.
|
||||
|
||||
**File:** `src/app/(app)/stls/_components/stl-table.tsx`
|
||||
|
||||
Pass search term to columns via TanStack Table column meta.
|
||||
|
||||
**File:** `src/app/(app)/stls/_components/package-columns.tsx`
|
||||
|
||||
When search is active and `matchedByContent` is true, render a clickable badge below the filename: e.g., "3 file matches". Clicking opens the `PackageFilesDrawer` with a `highlightTerm` prop set to the search term.
|
||||
|
||||
**File:** `src/app/(app)/stls/_components/package-files-drawer.tsx`
|
||||
|
||||
- Accept optional `highlightTerm: string` prop
|
||||
- Render full file tree as normal (all files visible)
|
||||
- Files whose `fileName` or `path` case-insensitively contains `highlightTerm` get a subtle highlight (amber/yellow background on the row)
|
||||
- Auto-expand folders that contain highlighted files
|
||||
- The drawer's own search input remains independent
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. User types search term in STL table search input
|
||||
2. URL updates with `?search=value`, page reloads
|
||||
3. `page.tsx` calls `searchPackages()` with `searchIn: "both"`
|
||||
4. Query returns packages with `matchedFileCount` and `matchedByContent`
|
||||
5. Table renders "N file matches" badge on content-matched rows
|
||||
6. User clicks badge -> drawer opens with full tree, matching files highlighted
|
||||
7. Folders containing matches auto-expanded
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Skipped/Failed Files Overview
|
||||
|
||||
### Database Schema
|
||||
|
||||
New model in `prisma/schema.prisma`:
|
||||
|
||||
```prisma
|
||||
enum SkipReason {
|
||||
SIZE_LIMIT
|
||||
DOWNLOAD_FAILED
|
||||
EXTRACT_FAILED
|
||||
UPLOAD_FAILED
|
||||
}
|
||||
|
||||
model SkippedPackage {
|
||||
id String @id @default(cuid())
|
||||
fileName String
|
||||
fileSize BigInt
|
||||
reason SkipReason
|
||||
errorMessage String?
|
||||
sourceChannelId String
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade)
|
||||
sourceMessageId BigInt
|
||||
sourceTopicId BigInt?
|
||||
isMultipart Boolean @default(false)
|
||||
partCount Int @default(1)
|
||||
accountId String
|
||||
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([sourceChannelId, sourceMessageId])
|
||||
@@index([reason])
|
||||
@@index([accountId])
|
||||
@@map("skipped_packages")
|
||||
}
|
||||
```
|
||||
|
||||
Reverse relations must be added to `TelegramChannel` and `TelegramAccount` models:
|
||||
```prisma
|
||||
// In TelegramChannel:
|
||||
skippedPackages SkippedPackage[]
|
||||
|
||||
// In TelegramAccount:
|
||||
skippedPackages SkippedPackage[]
|
||||
```
|
||||
|
||||
### Worker Changes
|
||||
|
||||
**File:** `worker/src/worker.ts`
|
||||
|
||||
Extend `PipelineContext` interface to include `accountId` (derived from the ingestion run's account).
|
||||
|
||||
At each skip/failure point, upsert a `SkippedPackage` record:
|
||||
|
||||
- **Size limit skip** (line 784): reason `SIZE_LIMIT`, no error message
|
||||
- **Download failure** (catch in download loop): reason `DOWNLOAD_FAILED` + error text
|
||||
- **Extract/metadata failure** (catch in extract): reason `EXTRACT_FAILED` + error text
|
||||
- **Upload failure** (catch in upload): reason `UPLOAD_FAILED` + error text
|
||||
|
||||
On **successful ingestion** of a package, delete any existing `SkippedPackage` with the same `(sourceChannelId, sourceMessageId)` — so successful retries clean up after themselves.
|
||||
|
||||
**File:** `worker/src/db/queries.ts`
|
||||
|
||||
Add functions:
|
||||
- `upsertSkippedPackage(data)` — create or update skip record
|
||||
- `deleteSkippedPackage(sourceChannelId, sourceMessageId)` — remove on success
|
||||
|
||||
### Retry Mechanism
|
||||
|
||||
Retrying a skipped package:
|
||||
1. Delete the `SkippedPackage` record
|
||||
2. Find the `AccountChannelMap` record using both `accountId` and `sourceChannelId`, then reset its `lastProcessedMessageId` to `sourceMessageId - 1` (only if less than current watermark)
|
||||
3. If `sourceTopicId` is non-null, also reset the corresponding `TopicProgress.lastProcessedMessageId` for that topic
|
||||
4. The next ingestion cycle picks up the message and re-attempts processing
|
||||
|
||||
For "Retry All" (e.g., all `SIZE_LIMIT` skips after raising the limit):
|
||||
- Delete all matching `SkippedPackage` records
|
||||
- For each affected (account, channel) pair, reset `AccountChannelMap` watermark to the minimum `sourceMessageId - 1` among deleted records
|
||||
- For each affected (account, channel, topic) triple, reset `TopicProgress` watermark similarly
|
||||
|
||||
**Note on behavioral distinction:** `DOWNLOAD_FAILED`, `EXTRACT_FAILED`, and `UPLOAD_FAILED` archives already naturally retry because the worker does not advance the watermark past failed sets. The `SkippedPackage` record provides visibility into these failures. The explicit retry/watermark reset is only strictly needed for `SIZE_LIMIT` skips (where the watermark does advance past the skipped message). The UI should present both types but the retry button is most impactful for `SIZE_LIMIT` skips.
|
||||
|
||||
**Performance note:** "Retry All" can cause the worker to re-scan large message ranges. The existing dedup logic (`packageExistsBySourceMessage`) ensures already-ingested packages are skipped quickly, but there is a scanning cost proportional to the number of messages between the reset watermark and the current position.
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
**File:** `src/app/(app)/stls/_components/stl-table.tsx`
|
||||
|
||||
Add a "Skipped / Failed" tab alongside the main packages table.
|
||||
|
||||
**New file:** `src/app/(app)/stls/_components/skipped-packages-tab.tsx`
|
||||
|
||||
Table columns:
|
||||
- **fileName** — archive name
|
||||
- **fileSize** — formatted size
|
||||
- **reason** — color-coded badge: `SIZE_LIMIT` (yellow), `DOWNLOAD_FAILED` (red), `EXTRACT_FAILED` (red), `UPLOAD_FAILED` (red)
|
||||
- **errorMessage** — truncated with expandable tooltip/popover for full text
|
||||
- **channel** — source channel title
|
||||
- **createdAt** — when the skip/failure was recorded
|
||||
|
||||
Actions:
|
||||
- **Retry** button per row — server action that deletes record + resets watermark
|
||||
- **Retry All** button in the header — bulk retry, filterable by reason
|
||||
|
||||
**File:** `src/app/(app)/stls/page.tsx`
|
||||
|
||||
Fetch skipped packages count (for tab badge) alongside existing queries.
|
||||
|
||||
**File:** `src/data/` or `src/lib/telegram/queries.ts`
|
||||
|
||||
Add query functions:
|
||||
- `listSkippedPackages(options)` — paginated list with reason filter
|
||||
- `countSkippedPackages()` — for tab badge
|
||||
- `retrySkippedPackage(id)` — delete record + reset watermark
|
||||
- `retryAllSkippedPackages(reason?)` — bulk retry
|
||||
|
||||
**File:** `src/app/(app)/stls/actions.ts`
|
||||
|
||||
Add server actions:
|
||||
- `retrySkippedPackageAction(id)`
|
||||
- `retryAllSkippedPackagesAction(reason?)`
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### Create
|
||||
- `src/app/(app)/stls/_components/skipped-packages-tab.tsx` — skipped packages table UI
|
||||
- Prisma migration for `SkippedPackage` model
|
||||
|
||||
### Modify
|
||||
- `worker/src/util/config.ts` — raise default max size
|
||||
- `worker/src/worker.ts` — record skips/failures, clean up on success
|
||||
- `worker/src/db/queries.ts` — add skip record CRUD functions
|
||||
- `prisma/schema.prisma` — add `SkippedPackage` model and `SkipReason` enum
|
||||
- `src/lib/telegram/queries.ts` — modify `searchPackages()` for match counts, add skipped package queries
|
||||
- `src/lib/telegram/types.ts` — add `matchedFileCount`/`matchedByContent` to `PackageListItem`, add skipped package types
|
||||
- `src/app/(app)/stls/page.tsx` — pass search term, fetch skipped count, add tab
|
||||
- `src/app/(app)/stls/_components/stl-table.tsx` — accept search prop, render tabs
|
||||
- `src/app/(app)/stls/_components/package-columns.tsx` — render match badge
|
||||
- `src/app/(app)/stls/_components/package-files-drawer.tsx` — accept highlightTerm, highlight matching files, auto-expand matched folders
|
||||
- `src/app/(app)/stls/actions.ts` — add retry server actions
|
||||
@@ -0,0 +1,246 @@
|
||||
# Package Grouping Design
|
||||
|
||||
## Overview
|
||||
|
||||
Add the ability to group related packages that were posted together in a Telegram channel (e.g., "DUNGEON BLOCKS - Colossal Dungeon" with 6 separate archive files). Groups appear as collapsible rows in the STL files table, with support for both automatic detection via Telegram album IDs and manual grouping through the UI.
|
||||
|
||||
## Goals
|
||||
|
||||
- Automatically detect and group files posted together in Telegram (same `media_album_id`)
|
||||
- Display groups as collapsed rows in the STL table with aggregated metadata
|
||||
- Allow manual grouping/ungrouping of packages via the UI
|
||||
- Support editable group names and preview images
|
||||
- Enable "Send All" to deliver every package in a group via the bot
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Merging grouped packages into a single Package record (each stays independent)
|
||||
- Time-proximity heuristics for grouping (too error-prone)
|
||||
- Grouping across different source channels
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### New `PackageGroup` Table
|
||||
|
||||
```prisma
|
||||
model PackageGroup {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
mediaAlbumId String?
|
||||
sourceChannelId String
|
||||
previewData Bytes?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
packages Package[]
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([mediaAlbumId, sourceChannelId])
|
||||
@@index([sourceChannelId])
|
||||
@@map("package_groups")
|
||||
}
|
||||
```
|
||||
|
||||
### Package Model Changes
|
||||
|
||||
Add optional group membership:
|
||||
|
||||
```prisma
|
||||
model Package {
|
||||
// ... existing fields ...
|
||||
packageGroupId String?
|
||||
packageGroup PackageGroup? @relation(fields: [packageGroupId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([packageGroupId])
|
||||
}
|
||||
```
|
||||
|
||||
### TelegramChannel Model Changes
|
||||
|
||||
Add back-relation for the new `PackageGroup` model:
|
||||
|
||||
```prisma
|
||||
model TelegramChannel {
|
||||
// ... existing fields and relations ...
|
||||
packageGroups PackageGroup[]
|
||||
}
|
||||
```
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- `mediaAlbumId` is `String?` (TDLib int64 stringified) — only used for dedup lookups, avoids BigInt complexity
|
||||
- `@@unique([mediaAlbumId, sourceChannelId])` prevents duplicate album-derived groups when re-scanning. PostgreSQL treats NULLs as distinct in unique constraints, so manually-created groups (with `mediaAlbumId = null`) are not constrained by this — which is correct behavior
|
||||
- Idempotency for album groups uses `findFirst({ where: { mediaAlbumId, sourceChannelId } })` + conditional `create`, not `upsert`, because Prisma does not support `upsert` on compound unique keys with nullable fields
|
||||
- `onDelete: SetNull` on `Package.packageGroup` means dissolving a group automatically unlinks all members
|
||||
- `onDelete: Cascade` on `PackageGroup.sourceChannel` means deleting a channel cleans up its groups
|
||||
- `sourceTopicId` is omitted from `PackageGroup` — it can be inferred from member packages, and manual groups may span topics
|
||||
- `@@map("package_groups")` follows the project's snake_case table naming convention
|
||||
- `previewData` stores JPEG thumbnail bytes directly on the group (same pattern as Package)
|
||||
|
||||
---
|
||||
|
||||
## Worker Changes
|
||||
|
||||
### TelegramMessage Interface
|
||||
|
||||
Add optional `mediaAlbumId` field:
|
||||
|
||||
```typescript
|
||||
export interface TelegramMessage {
|
||||
id: bigint;
|
||||
fileName: string;
|
||||
fileId: string;
|
||||
fileSize: bigint;
|
||||
date: Date;
|
||||
mediaAlbumId?: string; // Absent or "0" when not part of an album
|
||||
}
|
||||
```
|
||||
|
||||
The field is optional to minimize call-site changes. The grouping step treats `undefined` and `"0"` equivalently as "not part of an album."
|
||||
|
||||
### TelegramPhoto Interface
|
||||
|
||||
Add optional `mediaAlbumId` field:
|
||||
|
||||
```typescript
|
||||
export interface TelegramPhoto {
|
||||
id: bigint;
|
||||
date: Date;
|
||||
caption: string;
|
||||
fileId: string;
|
||||
fileSize: number;
|
||||
mediaAlbumId?: string; // For album-to-preview correlation
|
||||
}
|
||||
```
|
||||
|
||||
### Channel Scanning
|
||||
|
||||
In `getChannelMessages()`, read `media_album_id` from the TDLib message object (already present in TDLib responses, just not captured today). Add `media_album_id?: string` to the `TdMessage` interface and pass through to both `TelegramMessage` and `TelegramPhoto`.
|
||||
|
||||
The document pass and photo pass already run as separate loops over `searchChatMessages`. Both loops capture `media_album_id` independently. Correlation happens at grouping time: album photos are matched to album documents by comparing their `mediaAlbumId` values, not at scan time.
|
||||
|
||||
### Group Creation (Post-Processing)
|
||||
|
||||
After each scan cycle's packages are individually processed (downloaded, hashed, uploaded, indexed), a post-processing step handles grouping:
|
||||
|
||||
1. Collect all packages from the current scan batch that share the same non-zero `mediaAlbumId`
|
||||
2. For each distinct `mediaAlbumId`, check if a `PackageGroup` already exists via `findFirst({ where: { mediaAlbumId, sourceChannelId } })`
|
||||
3. If no group exists, create one:
|
||||
- **Name:** caption of the first message in the album (falls back to first file's base name)
|
||||
- **Preview:** find a `TelegramPhoto` from the scan's `photos[]` array with the same `mediaAlbumId`. If found, download via `downloadPhotoThumbnail`. If not, the group starts with no preview (can be added in UI later)
|
||||
4. Link all member packages via an idempotent `updateMany` — sets `packageGroupId` on all packages whose `sourceMessageId` is in the album's message set. This handles both newly-indexed packages and previously-indexed ones that were created in an earlier partial scan (e.g., if one package failed and was retried later)
|
||||
|
||||
The per-package pipeline is unchanged — each file is still downloaded, hashed, deduped, split, uploaded, and indexed independently. Grouping is a layer on top.
|
||||
|
||||
---
|
||||
|
||||
## Query Layer
|
||||
|
||||
### Paginated Listing with Groups
|
||||
|
||||
The STL table shows "display items" — either a group (collapsed) or a standalone package. Pagination operates on display items so that a group occupies exactly one slot regardless of member count.
|
||||
|
||||
**Two-step query approach** (handles filters correctly):
|
||||
|
||||
**Step 1 — Find matching display item IDs:**
|
||||
|
||||
```sql
|
||||
-- Find all group IDs and standalone package IDs where at least one member matches filters
|
||||
SELECT DISTINCT COALESCE(p."packageGroupId", p.id) AS display_id,
|
||||
CASE WHEN p."packageGroupId" IS NOT NULL THEN 'group' ELSE 'package' END AS display_type,
|
||||
MAX(p."indexedAt") AS sort_date
|
||||
FROM packages p
|
||||
LEFT JOIN package_groups pg ON pg.id = p."packageGroupId"
|
||||
WHERE 1=1
|
||||
-- Optional filters applied here (creator, tags, search text, channelId)
|
||||
GROUP BY COALESCE(p."packageGroupId", p.id),
|
||||
CASE WHEN p."packageGroupId" IS NOT NULL THEN 'group' ELSE 'package' END
|
||||
ORDER BY sort_date DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
```
|
||||
|
||||
**Step 2 — Fetch full data:**
|
||||
|
||||
For groups on the current page, fetch all member packages (including those that didn't match filters — the group appears because at least one member matched, but the expanded view shows all members). For standalone packages, fetch the full package data.
|
||||
|
||||
**Count query** (for pagination total):
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT COALESCE(p."packageGroupId", p.id)
|
||||
FROM packages p
|
||||
WHERE 1=1
|
||||
-- Same filters as step 1
|
||||
) AS display_items
|
||||
```
|
||||
|
||||
### Group Row Aggregates
|
||||
|
||||
Computed in the step 2 fetch: total file size (sum), total file count (sum), combined tags (array union), member package count per group. These populate the collapsed group row.
|
||||
|
||||
### Search
|
||||
|
||||
`searchPackages` adds `PackageGroup.name` to search targets via a `LEFT JOIN` to `package_groups`. If any package in a group matches by name/file content, or the group name matches, the whole group appears.
|
||||
|
||||
### Filtering
|
||||
|
||||
Creator/tag filters apply to member packages. A group appears if any member matches the filter. The group row shows aggregates of all members (not just matching ones).
|
||||
|
||||
### New Query Functions
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `listDisplayItems(page, limit, filters)` | Two-step paginated query returning groups + standalone packages |
|
||||
| `getDisplayItemCount(filters)` | Count of display items for pagination total |
|
||||
| `getPackageGroup(groupId)` | Group metadata + all member packages |
|
||||
| `updatePackageGroupName(groupId, name)` | Rename group |
|
||||
| `updatePackageGroupPreview(groupId, previewData)` | Replace group preview |
|
||||
| `addPackagesToGroup(packageIds, groupId)` | Manual grouping — add to existing group |
|
||||
| `removePackageFromGroup(packageId)` | Ungroup single package |
|
||||
| `createManualGroup(name, packageIds)` | Create new group from UI |
|
||||
| `dissolveGroup(groupId)` | Ungroup all members, delete group record |
|
||||
|
||||
For manual grouping of packages that already belong to different groups: the UI first dissolves empty source groups (groups where all members were moved), then links the selected packages to the target group. Non-selected members of source groups remain in their original group.
|
||||
|
||||
---
|
||||
|
||||
## UI Changes
|
||||
|
||||
### STL Table — Group Rows
|
||||
|
||||
- **Collapsed (default):** Single row showing preview thumbnail, group name (editable inline), archive type badge ("Mixed" if heterogeneous), combined size, combined file count, combined tags (editable), source channel, latest `indexedAt`, actions
|
||||
- **Expanded:** Chevron toggle reveals member packages as indented sub-rows with their existing columns and per-package actions
|
||||
- Chevron icon on the left of the row toggles expand/collapse
|
||||
|
||||
**Loading strategy:** Member packages for all groups on the current page are prefetched in a single batched query during the step 2 fetch. This means expand/collapse is instant (no on-demand loading) and avoids per-row loading states.
|
||||
|
||||
### Group Row Actions
|
||||
|
||||
- **Send All** — Queues bot send requests for every package in the group. Checks for existing PENDING/SENDING requests per package to avoid duplicates.
|
||||
- **View Files** — Opens file drawer showing all member packages' files, separated by package name headers
|
||||
- **Dissolve Group** — Ungroups all members (confirmation required)
|
||||
|
||||
### Individual Package Actions (Within a Group)
|
||||
|
||||
- Existing: Send, View Files
|
||||
- New: "Remove from group" in dropdown menu
|
||||
|
||||
### Manual Grouping
|
||||
|
||||
- Checkbox selection column on package rows
|
||||
- When 2+ packages selected, a "Group Selected" button appears in the table toolbar
|
||||
- Prompts for a group name, creates the group
|
||||
- If selected packages belong to existing groups, those packages are moved to the new group. Source groups that become empty are automatically dissolved.
|
||||
|
||||
### Preview Editing
|
||||
|
||||
- Click the group's preview thumbnail to upload a replacement image
|
||||
- Same upload flow as individual packages (existing component reuse)
|
||||
|
||||
### No Changes To
|
||||
|
||||
- Skipped/failed packages tab
|
||||
- Package detail drawer internals
|
||||
- Search UI (just broader matching behind the scenes)
|
||||
@@ -0,0 +1,184 @@
|
||||
# Worker Improvements Design
|
||||
|
||||
**Date:** 2026-05-02
|
||||
**Status:** Approved
|
||||
**Scope:** Dragon's Stash Telegram ingestion worker
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Three issues to address:
|
||||
|
||||
1. **Double-uploads**: The same archive occasionally appears twice in the destination Telegram channel. Root causes: (a) the worker crashes between `uploadToChannel()` confirming success and `createPackageWithFiles()` writing to the DB — no DB record means `recoverIncompleteUploads()` can't detect the orphaned Telegram message, and the next cycle re-uploads; (b) two accounts scanning the same source channel can both pass the hash dedup check before either creates a DB record, racing to upload the same file.
|
||||
|
||||
2. **Sequential account processing**: Both Telegram accounts are processed one after another via `withTdlibMutex`, even though TDLib fully supports multiple concurrent clients in the same process (each with separate `databaseDirectory` and `filesDirectory`). This halves throughput unnecessarily.
|
||||
|
||||
3. **Premium upload limit not used**: The Premium account can upload up to 4 GB per file, but `MAX_UPLOAD_SIZE` is hardcoded at ~1,950 MB. This causes unnecessary file splitting and expensive repack operations for files that could upload directly.
|
||||
|
||||
## Solution Overview
|
||||
|
||||
Three targeted changes, no architectural overhaul:
|
||||
|
||||
1. Two-phase DB write + hash advisory lock (fixes double-uploads)
|
||||
2. Remove TDLib mutex from the scheduler loop (enables parallel accounts)
|
||||
3. Per-account `maxUploadSize` from `getMe().is_premium` (enables 4 GB for Premium)
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Double-Upload Fix
|
||||
|
||||
### 1a. Two-Phase DB Write
|
||||
|
||||
**Current flow:**
|
||||
```
|
||||
uploadToChannel() → preview download → metadata extraction → createPackageWithFiles()
|
||||
```
|
||||
|
||||
If the worker crashes anywhere between upload confirmation and `createPackageWithFiles()`, no DB record exists. `recoverIncompleteUploads()` only checks packages with an existing `destMessageId` in the DB — it cannot find an orphaned Telegram message with no corresponding row.
|
||||
|
||||
**New flow:**
|
||||
```
|
||||
uploadToChannel()
|
||||
→ createPackageStub() ← minimal record, destMessageId set immediately
|
||||
→ preview download
|
||||
→ metadata extraction
|
||||
→ updatePackageWithMetadata() ← adds file list, preview, creator, tags
|
||||
```
|
||||
|
||||
`createPackageStub()` writes: `contentHash`, `fileName`, `fileSize`, `archiveType`, `sourceChannelId`, `sourceMessageId`, `destChannelId`, `destMessageId`, `isMultipart`, `partCount`, `ingestionRunId`. File list and preview are left empty.
|
||||
|
||||
If the worker crashes after the stub is written:
|
||||
- `recoverIncompleteUploads()` finds the record (has `destMessageId`), verifies the Telegram message exists, keeps it.
|
||||
- Next cycle: `packageExistsByHash()` returns true → skips re-upload.
|
||||
- The stub has `fileCount = 0` and no file listing. The UI shows "metadata pending" rather than failing silently.
|
||||
|
||||
Stubs with `fileCount = 0` are valid deliverable packages (the bot can still send the file). Backfilling metadata on stubs is out of scope for this change — the crash case is rare and the stub is functional.
|
||||
|
||||
### 1b. Hash Advisory Lock
|
||||
|
||||
**The race (two accounts, shared source channel):**
|
||||
```
|
||||
Worker A: packageExistsByHash(X) → false (no record yet)
|
||||
Worker B: packageExistsByHash(X) → false (no record yet)
|
||||
Worker A: uploads file → destMessageId_A
|
||||
Worker B: uploads file → destMessageId_B ← duplicate Telegram message
|
||||
Worker A: createPackageStub() → succeeds (contentHash @unique satisfied)
|
||||
Worker B: createPackageStub() → fails unique constraint on contentHash
|
||||
```
|
||||
Result: two Telegram messages, one DB record. Worker B's upload is wasted.
|
||||
|
||||
**Fix:** Before calling `uploadToChannel()`, acquire a PostgreSQL session advisory lock keyed on the content hash:
|
||||
|
||||
```sql
|
||||
SELECT pg_try_advisory_lock(hash_bigint)
|
||||
```
|
||||
|
||||
Where `hash_bigint` is the first 8 bytes of the SHA-256 content hash interpreted as a signed bigint.
|
||||
|
||||
- `pg_try_advisory_lock` is non-blocking. If another worker holds the lock (same file, shared channel), return `false` → treat as duplicate, skip.
|
||||
- After acquiring the lock, **re-run `packageExistsByHash()`** before uploading. This catches the case where another worker finished and released the lock between the first check and this one — without the re-check, the current worker would proceed to re-upload.
|
||||
- The lock is session-scoped: released automatically on DB session end. No manual cleanup needed on crash.
|
||||
- The lock is released explicitly after `createPackageStub()` completes (or on any error path).
|
||||
|
||||
**Implementation location:** New helper `tryAcquireHashLock(contentHash)` / `releaseHashLock(contentHash)` in `worker/src/db/locks.ts`, reusing the existing DB client pattern.
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Parallel Account Processing
|
||||
|
||||
### Current Constraint
|
||||
|
||||
`withTdlibMutex` in `scheduler.ts` serializes all TDLib operations across accounts. This was a conservative guard, but TDLib explicitly supports multiple concurrent clients in the same process provided each has its own `databaseDirectory` and `filesDirectory`.
|
||||
|
||||
The codebase already satisfies this requirement:
|
||||
```typescript
|
||||
// worker/src/tdlib/client.ts
|
||||
const dbPath = path.join(config.tdlibStateDir, account.id);
|
||||
const client = createClient({
|
||||
databaseDirectory: dbPath,
|
||||
filesDirectory: path.join(dbPath, "files"),
|
||||
});
|
||||
```
|
||||
|
||||
Each account gets `<TDLIB_STATE_DIR>/<account.id>/` — fully isolated.
|
||||
|
||||
### Change
|
||||
|
||||
Replace the sequential `for` loop in `scheduler.ts` with `Promise.allSettled()`:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
for (const account of accounts) {
|
||||
await withTdlibMutex(`ingest:${account.phone}`, () => runWorkerForAccount(account));
|
||||
}
|
||||
|
||||
// After
|
||||
await Promise.allSettled(accounts.map((account) => runWorkerForAccount(account)));
|
||||
```
|
||||
|
||||
The per-account PostgreSQL advisory lock in `db/locks.ts` already prevents any account from being processed twice simultaneously. `Promise.allSettled()` ensures one account's failure doesn't abort the other.
|
||||
|
||||
The `withTdlibMutex` wrapper can be removed from the ingest path entirely. The auth path (`authenticateAccount`) should also be run in parallel but may remain guarded if TDLib auth flows have ordering dependencies — verify during implementation.
|
||||
|
||||
**No Docker Compose changes needed.** Both accounts run in the same container.
|
||||
|
||||
### Speed Limit Notifications
|
||||
|
||||
TDLib fires `updateSpeedLimitNotification` when an account's upload or download speed is throttled (non-Premium accounts). Log this event at `warn` level in the client update handler so it's visible in logs without being actionable.
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Per-Account Premium Upload Limit
|
||||
|
||||
### Premium Detection
|
||||
|
||||
After successful authentication, call `getMe()` and read `is_premium: bool` from the returned `user` object. Store this on `TelegramAccount.isPremium` (new boolean field, default `false`, updated on each successful auth).
|
||||
|
||||
```typescript
|
||||
const me = await client.invoke({ _: 'getMe' }) as { is_premium?: boolean };
|
||||
await updateAccountPremiumStatus(account.id, me.is_premium ?? false);
|
||||
```
|
||||
|
||||
### Upload Size Limits
|
||||
|
||||
| Account type | `maxUploadSize` | Effect |
|
||||
|---|---|---|
|
||||
| Premium | 3,950 MB | Parts ≤ 3.95 GB upload as-is; repack only for parts >3.95 GB (extremely rare) |
|
||||
| Non-Premium | 1,950 MB | Current behavior unchanged |
|
||||
|
||||
Pass `maxUploadSize` into `processOneArchiveSet()` as a parameter (currently hardcoded as `MAX_UPLOAD_SIZE` at `worker.ts:1023` and in `archive/split.ts`).
|
||||
|
||||
The `hasOversizedPart` check and `byteLevelSplit` call both use this value, so the repack step is effectively eliminated for Premium accounts in practice — no separate "skip repack" flag needed.
|
||||
|
||||
### Migration
|
||||
|
||||
```prisma
|
||||
model TelegramAccount {
|
||||
// ... existing fields
|
||||
isPremium Boolean @default(false)
|
||||
}
|
||||
```
|
||||
|
||||
One migration, one new query `updateAccountPremiumStatus(accountId, isPremium)`.
|
||||
|
||||
---
|
||||
|
||||
## Files to Change
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `prisma/schema.prisma` | Add `isPremium Boolean @default(false)` to `TelegramAccount` |
|
||||
| `worker/src/db/queries.ts` | Add `updateAccountPremiumStatus()`, `createPackageStub()`, `updatePackageWithMetadata()` |
|
||||
| `worker/src/db/locks.ts` | Add `tryAcquireHashLock()`, `releaseHashLock()` |
|
||||
| `worker/src/tdlib/client.ts` | Call `getMe()` after auth, return `isPremium` from `createTdlibClient()` |
|
||||
| `worker/src/worker.ts` | Two-phase write, hash lock acquire/release, pass `maxUploadSize` per account |
|
||||
| `worker/src/archive/split.ts` | Accept `maxPartSize` parameter instead of hardcoded constant |
|
||||
| `worker/src/scheduler.ts` | Replace sequential loop with `Promise.allSettled()`, remove `withTdlibMutex` from ingest path |
|
||||
|
||||
---
|
||||
|
||||
## What Is Explicitly Out of Scope
|
||||
|
||||
- Backfilling metadata on stub records (rare crash case, functional without it)
|
||||
- Download pre-fetching / pipeline parallelism within one account
|
||||
- Two separate worker containers (single container is sufficient)
|
||||
- Bot or app changes (worker-only)
|
||||
@@ -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,205 @@
|
||||
# NAS Backup for Postgres + TDLib State — Design
|
||||
|
||||
**Date:** 2026-07-23
|
||||
**Status:** Approved for planning
|
||||
|
||||
## Summary
|
||||
|
||||
Add a dedicated `backup` container to the DragonsStash stack that takes daily,
|
||||
encrypted, deduplicated backups of the two things that can't be regenerated —
|
||||
the Postgres database (inventory/STL metadata, users, everything the app
|
||||
manages) and the two TDLib state volumes (Telegram session/auth state for the
|
||||
worker and bot) — and ships them to a Synology NAS over NFS. STL archive
|
||||
contents themselves are explicitly out of scope: they only live on this host
|
||||
temporarily and are not backed up.
|
||||
|
||||
Backups are stored via [restic](https://restic.net/), which provides
|
||||
encryption-at-rest, block-level dedup, and retention pruning natively, so no
|
||||
custom encryption or pruning scripts need to be written or maintained.
|
||||
|
||||
## Context
|
||||
|
||||
Current state (as of this design):
|
||||
|
||||
- Production stack runs from `/opt/stacks/DragonsStash/docker-compose.yml` on
|
||||
this Dockge-managed host, pulling prebuilt images from
|
||||
`git.samagsteribbe.nl`. The `docker-compose.yml` in this repo is the
|
||||
build/dev reference and should be kept in sync.
|
||||
- Named volumes in use: `postgres_data` (Postgres 16 data directory),
|
||||
`tdlib_state` (worker's TDLib session), `tdlib_bot_state` (bot's TDLib
|
||||
session), `tmp_zips` and `manual_uploads` (both transient, explicitly out of
|
||||
scope here).
|
||||
- No backup mechanism, NFS mount, or host cron currently exists anywhere in
|
||||
this deployment.
|
||||
- The host already runs Uptime Kuma (used here for backup alerting) and Loki
|
||||
(container logs are presumably already collected there).
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Daily backup of the Postgres database and both TDLib state volumes.
|
||||
2. Backups stored on a Synology NAS via NFS, not on local disk.
|
||||
3. 14-day retention, oldest snapshots pruned automatically.
|
||||
4. Backups encrypted at rest (Postgres dumps and TDLib session files both
|
||||
contain sensitive material — password hashes, Telegram API secrets, live
|
||||
session state).
|
||||
5. Postgres backups must be transactionally consistent regardless of live app
|
||||
traffic. TDLib state backups are best-effort (see Decisions below) — this
|
||||
is an accepted trade-off, not a defect.
|
||||
6. Alert (via existing Uptime Kuma) if a backup run fails or doesn't happen.
|
||||
7. No new host-level state (no `/etc/fstab` entries, no host crontab) — the
|
||||
backup mechanism should be a container, consistent with how everything
|
||||
else on this host is deployed and versioned.
|
||||
8. No new privileged access — specifically, the backup container must not
|
||||
have Docker socket access or any ability to control sibling containers.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **NFS mounted via Docker's native NFS volume driver** (`driver_opts: type:
|
||||
nfs`), not a host-level mount. Keeps all backup-related state inside the
|
||||
compose file instead of split across host config.
|
||||
- **Restic, not hand-rolled tar+age+find.** Restic already solves encryption,
|
||||
dedup, and retention correctly; hand-rolled scripts would be reinventing
|
||||
that logic with more room for bugs.
|
||||
- **TDLib state is tarred live (best-effort), not paused.** Pausing the
|
||||
worker/bot for a clean snapshot would require mounting the Docker socket
|
||||
into the backup container so it could stop/start sibling containers — a
|
||||
real privilege escalation (a compromised backup container could then
|
||||
control any container on the host). The downside of a best-effort tar is
|
||||
bounded: worst case, a bad TDLib restore means redoing the Telegram SMS
|
||||
auth flow, which is the same outcome as having no backup at all. That
|
||||
bounded, low-severity downside doesn't justify the privilege escalation.
|
||||
- **Fixed-time cron (`crond`), not a sleep-loop.** A `sleep 86400` loop drifts
|
||||
on every container restart; a real crontab entry fires at a fixed time of
|
||||
day regardless of restarts, for negligible extra complexity.
|
||||
- **Restore is manual, not automated.** A script capable of restoring can
|
||||
overwrite live state; that should always require a human deliberately
|
||||
running it, not run unattended.
|
||||
|
||||
## Design
|
||||
|
||||
### New service: `backup`
|
||||
|
||||
Added to both `/opt/stacks/DragonsStash/docker-compose.yml` (production) and
|
||||
this repo's `docker-compose.yml` (dev/build reference).
|
||||
|
||||
- **Image**: custom, `FROM alpine:3.20`, `apk add --no-cache restic
|
||||
postgresql16-client curl tzdata dcron tar bash`. No dependency on app
|
||||
source — independent Dockerfile, e.g. `backup/Dockerfile`.
|
||||
- **Scheduling**: `crond -f` in the foreground as the container's entrypoint,
|
||||
with a crontab installed at build time:
|
||||
```
|
||||
0 3 * * * /backup.sh >> /proc/1/fd/1 2>&1
|
||||
0 4 * * 0 restic check >> /proc/1/fd/1 2>&1
|
||||
```
|
||||
(daily dump/backup at 03:00, weekly repo integrity check at 04:00 Sunday).
|
||||
`restic init` runs once at container startup (entrypoint, before `crond`
|
||||
starts), swallowing the "already initialized" error on subsequent
|
||||
container (re)starts.
|
||||
- **Network**: `internal` only — reaches `dragonsstash-db:5432` for
|
||||
`pg_dump`. No ports exposed.
|
||||
- **Volumes**:
|
||||
- `tdlib_state:/data/tdlib-worker:ro`
|
||||
- `tdlib_bot_state:/data/tdlib-bot:ro`
|
||||
- `nas_backups:/backups`, a named volume defined with:
|
||||
```yaml
|
||||
nas_backups:
|
||||
driver_opts:
|
||||
type: nfs
|
||||
o: "addr=${NAS_HOST},rw,nfsvers=4,soft,timeo=100"
|
||||
device: ":${NAS_EXPORT_PATH}"
|
||||
```
|
||||
- **New `.env` entries**: `NAS_HOST`, `NAS_EXPORT_PATH` (NFS share details),
|
||||
`RESTIC_PASSWORD` (repo encryption key), `KUMA_PUSH_URL` (Uptime Kuma push
|
||||
monitor URL). All four are inputs to gather during implementation, not
|
||||
hardcoded.
|
||||
- `restart: unless-stopped`, no `privileged`, no Docker socket mount.
|
||||
|
||||
### `backup.sh`
|
||||
|
||||
```
|
||||
set -euo pipefail
|
||||
trap 'curl -fsS "$KUMA_PUSH_URL" --get --data-urlencode "status=down" \
|
||||
--data-urlencode "msg=$BASH_COMMAND failed"' ERR
|
||||
|
||||
pg_dump -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||
-Fc -f /tmp/dragonsstash.dump
|
||||
|
||||
tar czf /tmp/tdlib.tar.gz -C /data tdlib-worker tdlib-bot
|
||||
|
||||
restic backup /tmp/dragonsstash.dump /tmp/tdlib.tar.gz
|
||||
restic forget --keep-daily 14 --prune
|
||||
|
||||
rm -f /tmp/dragonsstash.dump /tmp/tdlib.tar.gz
|
||||
|
||||
curl -fsS "$KUMA_PUSH_URL" --get --data-urlencode "status=up" \
|
||||
--data-urlencode "msg=OK"
|
||||
```
|
||||
|
||||
`PGPASSWORD` and `RESTIC_REPOSITORY=/backups/restic-repo` are set as
|
||||
container environment variables (from `.env`), not inline in the script.
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
crond (daily 03:00)
|
||||
→ pg_dump (consistent snapshot via Postgres MVCC) → /tmp/dragonsstash.dump
|
||||
→ tar tdlib_state + tdlib_bot_state (best-effort, live) → /tmp/tdlib.tar.gz
|
||||
→ restic backup (encrypt + dedup) → NFS-mounted repo on Synology NAS
|
||||
→ restic forget --keep-daily 14 --prune
|
||||
→ curl Uptime Kuma push monitor (up on success, down + reason on any failure)
|
||||
```
|
||||
|
||||
### Restore (manual, documented procedure — not scripted/automated)
|
||||
|
||||
```
|
||||
restic -r /backups/restic-repo restore latest --target /tmp/restore
|
||||
pg_restore -h dragonsstash-db -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||
--clean --if-exists /tmp/restore/tmp/dragonsstash.dump
|
||||
# untar /tmp/restore/tmp/tdlib.tar.gz back into the tdlib_state /
|
||||
# tdlib_bot_state volumes (via a throwaway container mounting both)
|
||||
```
|
||||
|
||||
## Alerting
|
||||
|
||||
- One Uptime Kuma **Push** monitor, created manually in the existing Kuma
|
||||
instance, with an expected heartbeat interval of ~26 hours (slack past the
|
||||
24h schedule so one slow run doesn't false-positive). Whatever notification
|
||||
channels are already configured on that monitor fire automatically — no new
|
||||
alerting integration.
|
||||
- `backup.sh` pushes `status=up` on success and `status=down` (with the
|
||||
failing command in `msg`) on any failure, via the `ERR` trap.
|
||||
- Container logs go to stdout, collected the same way every other container's
|
||||
logs already are on this host.
|
||||
|
||||
## Testing
|
||||
|
||||
This repo has no automated test framework (documented convention: manual
|
||||
testing). For this infra change:
|
||||
|
||||
- After deploy: manually run `docker exec dragonsstash-backup /backup.sh`
|
||||
once, confirm a snapshot appears (`restic snapshots`), confirm the Kuma
|
||||
monitor goes green.
|
||||
- **Restore drill** (once, during setup): actually restore the dump into a
|
||||
scratch Postgres and untar the TDLib archive into scratch volumes, to prove
|
||||
the backup is really restorable. Not automated or recurring for now.
|
||||
|
||||
## Out of scope / non-goals
|
||||
|
||||
- Backing up `tmp_zips` or `manual_uploads` — both transient by design.
|
||||
- Automated/scheduled restore testing.
|
||||
- Backing up any other stack on this host (this design is DragonsStash-only,
|
||||
though the pattern — Docker-native NFS volume + restic — could be reused
|
||||
for other stacks later).
|
||||
- Pausing worker/bot for a guaranteed-consistent TDLib snapshot (see
|
||||
Decisions).
|
||||
|
||||
## Files touched
|
||||
|
||||
- `docker-compose.yml` (this repo) and
|
||||
`/opt/stacks/DragonsStash/docker-compose.yml` (production) — add `backup`
|
||||
service, `nas_backups` volume.
|
||||
- `backup/Dockerfile` — new.
|
||||
- `backup/backup.sh` — new.
|
||||
- `backup/crontab` — new.
|
||||
- `.env.example` / `.env` — add `NAS_HOST`, `NAS_EXPORT_PATH`,
|
||||
`RESTIC_PASSWORD`, `KUMA_PUSH_URL`.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Provenance Backfill on Re-Index — Design
|
||||
|
||||
**Date:** 2026-07-23
|
||||
**Status:** Approved for planning
|
||||
|
||||
## Summary
|
||||
|
||||
Recover the true origin of packages whose recorded "source" is a placeholder —
|
||||
specifically the manual-upload and `rebuild.ts`-created records whose
|
||||
`sourceChannelId` points at the destination (archive) channel itself. When a
|
||||
real source channel is re-indexed and the worker encounters an archive that
|
||||
matches such a placeholder package, it backfills the real
|
||||
`sourceChannelId` / `sourceMessageId` / `sourceTopicId` / `sourceCaption` /
|
||||
`creator` (and, for records that never had one, the file listing and preview)
|
||||
onto the existing package — **without downloading the full archive**.
|
||||
|
||||
Matching is a two-stage process: a zero-download candidate lookup by
|
||||
`fileName` + total `fileSize`, confirmed by a CRC32 fingerprint read from just
|
||||
the archive's central directory via a **ranged (tail) download** of a few
|
||||
KB–MB, instead of the multi-GB whole file.
|
||||
|
||||
This is the first of four linked sub-projects. The others (creator
|
||||
normalization, provenance display, missing-files) are out of scope here and get
|
||||
their own spec → plan cycles. "Missing files" is explicitly deferred until this
|
||||
lands.
|
||||
|
||||
## Context
|
||||
|
||||
Current state (as of this design):
|
||||
|
||||
- `Package` records their origin via `sourceChannelId`, `sourceMessageId`,
|
||||
`sourceTopicId`, `sourceCaption`, and (for content dedup) `contentHash` and
|
||||
`remoteUniqueId`. `creator` is a free-text string extracted at ingestion.
|
||||
- Two ingestion paths create records with **placeholder provenance**, where the
|
||||
"source" is the destination channel, not a real origin:
|
||||
- **Manual uploads** (`worker/src/manual-upload.ts`) set
|
||||
`sourceChannelId = destChannel.id` and `sourceMessageId = destResult.messageId`.
|
||||
They *do* populate `PackageFile` (with `crc32`) from a local central-directory
|
||||
read at upload time.
|
||||
- **`rebuild.ts`** scans the destination channel and creates minimal records
|
||||
with `fileCount == 0` and **no** `PackageFile` rows.
|
||||
- The main worker upload path (`worker/src/worker.ts` → `uploadToChannel`) sends
|
||||
archives to the destination channel with **no caption**, so provenance cannot
|
||||
be recovered by re-reading destination messages — it has to come from matching
|
||||
against real source channels.
|
||||
- The scan/dedup ladder in `processOneArchiveSet` (`worker/src/worker.ts:1521`)
|
||||
is entirely **source-channel-scoped**:
|
||||
1. `findPackageByRemoteUniqueId(channel.id, …)` — same channel only
|
||||
2. `packageExistsBySourceMessage(channel.id, …)` — same channel only
|
||||
3. `findRepostedPackage(channel.id, fileName, size)` — same channel only
|
||||
4. …then full download → `packageExistsByHash(contentHash)` — global, but only
|
||||
*after* downloading the whole archive.
|
||||
- Because a placeholder package's `sourceChannelId` is the destination, checks
|
||||
1–3 never match it when a real source channel is scanned. Today the worker
|
||||
therefore downloads the entire archive, hits check 4, finds it is a duplicate,
|
||||
and skips — **wasting the download and leaving the wrong provenance in place.**
|
||||
- There is already an in-production precedent for "match an already-known
|
||||
archive during scan and enrich its metadata": when `findRepostedPackage`
|
||||
matches, the worker backfills richer *topic* context onto the existing package
|
||||
via `updatePackageTopicContext` (`worker/src/worker.ts:1608`+). Provenance
|
||||
backfill is the same move, widened from same-channel to cross-channel.
|
||||
- `remote.unique_id` is **not** a reliable cross-channel key: it identifies a
|
||||
stored file object on Telegram's servers, not the content. The same archive
|
||||
independently uploaded to two channels gets different `unique_id`s; it only
|
||||
matches for forwarded messages (same underlying file object). This is why the
|
||||
existing dedup scopes it to a single channel.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- Attribute true origin to placeholder-provenance packages during normal
|
||||
re-index scans, opportunistically (no separate pass).
|
||||
- Do it without downloading whole archives (ranged central-directory read only).
|
||||
- Be non-destructive: only ever touch packages that currently have placeholder
|
||||
provenance; never overwrite a real, non-placeholder source.
|
||||
- Be idempotent: re-running a re-index does not re-mutate or duplicate.
|
||||
|
||||
**Non-Goals (separate sub-projects / deferred)**
|
||||
|
||||
- Creator name normalization / canonical creator entity.
|
||||
- UI changes to display provenance.
|
||||
- Recovering "missing files."
|
||||
- Choosing a *preferred* origin when an archive genuinely exists in several
|
||||
source channels (first confirmed source wins).
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Candidate definition
|
||||
|
||||
> A package is a backfill candidate iff **`sourceChannelId == destChannelId`
|
||||
> OR `sourceMessageId == 0`**.
|
||||
|
||||
Two placeholder shapes exist (verified against live data 2026-07-23):
|
||||
- **Manual uploads** (`manual-upload.ts`): `sourceChannelId == destChannelId`,
|
||||
real `contentHash`, real `sourceMessageId`, has a `PackageFile` listing.
|
||||
- **Rebuild records** (`rebuild.ts`): `sourceMessageId == 0n` (deliberate
|
||||
"unknown" sentinel), synthetic `contentHash = "rebuild:<destChannelId>:<destMessageId>"`,
|
||||
`fileCount == 0`, and `sourceChannelId` set to an **arbitrary fallback source
|
||||
channel** (`sourceChannels[0]`) — NOT the destination. (This is the common
|
||||
case: e.g. 59,893 records after a destination rebuild.)
|
||||
|
||||
Normal ingestion always sets a real `sourceMessageId` (> 0) and a real source
|
||||
channel, so neither marker matches a genuinely-sourced package. Both markers are
|
||||
overwritten on backfill (source channel + message become real), so a record
|
||||
stops being a candidate once fixed — this is what makes re-scans idempotent.
|
||||
|
||||
**Known limitation:** backfill does NOT rewrite a rebuild record's synthetic
|
||||
`"rebuild:"` `contentHash` (the true content hash would require a full download,
|
||||
which this feature avoids). That is acceptable — dedup after backfill relies on
|
||||
`remoteUniqueId` + name/size within the source channel, not on `contentHash`.
|
||||
|
||||
### 2. Where it hooks
|
||||
|
||||
A new step is inserted into `processOneArchiveSet` **between check #3
|
||||
(`findRepostedPackage`) and the full download**. It runs only after the
|
||||
same-channel checks have missed (so genuine same-channel reposts keep their
|
||||
existing fast paths).
|
||||
|
||||
Flow for the scanned archive set:
|
||||
|
||||
1. Stage A — **candidate lookup (zero download).** Query for a package where
|
||||
`(sourceChannelId == destChannelId OR sourceMessageId == 0)` AND
|
||||
`fileName == archiveName` AND `fileSize == totalArchiveSize`.
|
||||
(`Package` has `@@index([fileName])`.)
|
||||
- No candidate → fall through to normal ingestion unchanged.
|
||||
2. Stage B — **fingerprint confirmation (tiny download).** See §3.
|
||||
3. On confirmation → **backfill** (see §4) and return `null` (treated as a
|
||||
duplicate; no full download, no new package). Increment a `zipsBackfilled`
|
||||
counter.
|
||||
4. On mismatch / failure / ambiguity → fall through to normal ingestion (see §6).
|
||||
|
||||
### 3. Fingerprint confirmation
|
||||
|
||||
The confirmation signal is the **multiset of internal CRC32s** of the archive's
|
||||
entries (CRC32 of each entry's *uncompressed* data). This value is a property of
|
||||
the archive contents and is identical regardless of which channel hosts the
|
||||
file.
|
||||
|
||||
- **Candidate side:** for manual uploads, `PackageFile.crc32` is already
|
||||
populated from the local central-directory read — zero cost. For **rebuild
|
||||
records** (`fileCount == 0`, no `crc32`), there is nothing stored to compare
|
||||
against; obtain the candidate's fingerprint with a **second ranged tail read
|
||||
of its destination copy** (ZIP/7z — still no full download). RAR candidates
|
||||
cannot be tail-read on either side, so they fall back to name+size (see §5).
|
||||
- **Scanned-source side:** read via a **ranged (tail) download** of the central
|
||||
directory:
|
||||
- **ZIP** (incl. multipart): the End-of-Central-Directory record + central
|
||||
directory live at the tail of the last part. Download only that tail and
|
||||
parse entries. (Reuses the lightweight-listing mechanism that is
|
||||
sub-project 4's core.)
|
||||
- **7z**: a start header at byte 0 points to an end header at the tail; fetch
|
||||
both small pieces.
|
||||
- **RAR**: headers are scattered through the file — no cheap tail read. See §5.
|
||||
- **Match rule:** confirmed iff the sorted CRC32 multisets are equal **and** file
|
||||
counts are equal.
|
||||
|
||||
The comparison logic (CRC32 multiset equality) and the candidate-match predicate
|
||||
are implemented as **pure functions** with no TDLib dependency, so they are unit
|
||||
testable (see §7).
|
||||
|
||||
### 4. What gets written
|
||||
|
||||
On a confirmed match, update the candidate package in a single transaction,
|
||||
overwriting only placeholder/empty fields:
|
||||
|
||||
| Field | New value | Condition |
|
||||
|---|---|---|
|
||||
| `sourceChannelId` | scanned `channel.id` | always |
|
||||
| `sourceMessageId` | scanned `parts[0].id` | always |
|
||||
| `sourceTopicId` | `ctx.sourceTopicId` | always |
|
||||
| `sourceCaption` | scanned message caption | always |
|
||||
| `remoteUniqueId` | scanned `firstRemoteUniqueId` | always (enables future same-channel dedup via check #1) |
|
||||
| `creator` | re-derived from source (topic > filename > channel) | **always** — un-normalized; the later creator-normalization sub-project cleans it up |
|
||||
| `fileCount` + `PackageFile[]` | from the central-directory read | only if candidate had none (`fileCount == 0`) — folds in the sub-project 4 outcome for rebuild records |
|
||||
| `previewData` / `previewMsgId` | matched preview from scan | only if candidate has none |
|
||||
|
||||
**Left untouched:** `contentHash`, `destChannelId`, `destMessageId`,
|
||||
`destMessageIds` — the bytes physically live in the destination channel; that is
|
||||
correct and must not change.
|
||||
|
||||
**Transaction safety:** re-check `sourceChannelId == destChannelId` *inside* the
|
||||
transaction before writing, so a concurrent worker that already backfilled the
|
||||
record causes this one to no-op (mirrors the existing `backfill.ts` guard).
|
||||
|
||||
### 5. RAR handling
|
||||
|
||||
RAR sources cannot be tail-read, so no cheap CRC fingerprint is available.
|
||||
Decision: **backfill RAR matches on `fileName` + `fileSize` alone**, flagged as
|
||||
lower-confidence in logs and via a `SystemNotification`, so they can be audited.
|
||||
No full download.
|
||||
|
||||
This name+size-only fallback applies to any candidate that cannot produce a
|
||||
CRC32 fingerprint cheaply: a RAR **source**, a RAR **candidate**, or a rebuild
|
||||
candidate whose destination copy is RAR. ZIP/7z rebuild candidates are still
|
||||
confirmed by fingerprint via the second tail read described in §3.
|
||||
|
||||
### 6. Conflict, mismatch, ambiguity
|
||||
|
||||
- **Fingerprint mismatch** → the scanned archive is genuinely different content
|
||||
that merely shares name+size. Fall through to **normal ingestion** (download +
|
||||
index) — it is a new package for this source.
|
||||
- **Ambiguous candidates** (2+ match name+size and the fingerprint cannot
|
||||
disambiguate) → log a `SystemNotification`, backfill nothing, and fall through
|
||||
to normal ingestion; the post-download `packageExistsByHash` check still
|
||||
dedups it safely.
|
||||
- **Same archive in multiple source channels** → the **first re-indexed source
|
||||
that confirms wins.** After backfill the package has a real source, so it is no
|
||||
longer a candidate; later scans treat it as an ordinary duplicate via checks #1
|
||||
(the `remoteUniqueId` we set) or #3.
|
||||
|
||||
### 7. Idempotency
|
||||
|
||||
Falls out of the candidate definition. Once backfilled:
|
||||
- `sourceChannelId` is the real channel → no longer a candidate.
|
||||
- A re-scan of that source hits check #1 (`remoteUniqueId`, now set) or check #3
|
||||
(`findRepostedPackage`) → normal dedup skip. No re-mutation, no duplicate.
|
||||
|
||||
### 8. Error handling
|
||||
|
||||
- **Tail-download failure** (network / `FLOOD_WAIT`) → cannot confirm this round.
|
||||
Do **not** fall back to a full download and do **not** guess: leave the
|
||||
candidate untouched and let the next re-index retry. Wrap the ranged read in
|
||||
`withFloodWait` (per the TDLib skill).
|
||||
- All new TDLib calls follow the skill's patterns: `withFloodWait`, listener
|
||||
attached before the async op, client closed in `finally`.
|
||||
|
||||
### 9. Visibility
|
||||
|
||||
- Add a `zipsBackfilled` counter to `IngestionRun` activity so a re-index run
|
||||
reports "N provenance backfills" instead of silently mutating records.
|
||||
- Info log per backfill (candidate id, old vs new source, confidence:
|
||||
fingerprint | name+size-RAR).
|
||||
- `SystemNotification` for ambiguous-candidate cases.
|
||||
|
||||
## Testing
|
||||
|
||||
The repo currently has no test framework (`CLAUDE.md`: "testing is manual"). This
|
||||
work introduces a **lightweight test harness** for the worker (e.g. `vitest` or
|
||||
node's built-in `node:test`) and unit-tests the correctness-critical pure logic:
|
||||
|
||||
- **Unit (automated):**
|
||||
- CRC32 multiset fingerprint equality (equal sets, different order, differing
|
||||
counts, disjoint sets).
|
||||
- Candidate-match predicate (name+size+placeholder true/false cases).
|
||||
- Field-merge rules (which fields overwrite, which only fill-if-empty).
|
||||
- **Manual integration checklist:**
|
||||
1. Re-index a real source channel containing a known manually-uploaded pack →
|
||||
verify `sourceChannelId`/`sourceMessageId`/`sourceCaption`/`creator` are
|
||||
backfilled and no full download occurs.
|
||||
2. A rebuild-created record (`fileCount == 0`) → verify listing + provenance
|
||||
are both populated from the single tail read.
|
||||
3. A RAR pack → verify name+size backfill with the lower-confidence log/notice.
|
||||
4. Re-run the same re-index → verify it is a no-op (idempotent).
|
||||
5. A genuine name+size collision (different content) → verify it ingests as a
|
||||
new package rather than being mis-attributed.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None blocking. Preferred-origin selection among multiple real sources is
|
||||
deliberately out of scope (first confirmed wins).
|
||||
|
||||
## Affected Code (indicative, for planning)
|
||||
|
||||
- `worker/src/worker.ts` — `processOneArchiveSet`: new Stage A/B step; new counter.
|
||||
- `worker/src/archive/` — ranged central-directory reader (ZIP/7z tail); shared
|
||||
with sub-project 4. Pure CRC32-fingerprint compare helper.
|
||||
- `worker/src/tdlib/download.ts` — ranged/partial download support (`offset`/`limit`).
|
||||
- `worker/src/db/queries.ts` — candidate lookup + transactional backfill update.
|
||||
- `prisma/schema.prisma` — `IngestionRun.zipsBackfilled` (and run-counter plumbing).
|
||||
- Worker test harness + first unit tests.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Ranged inner-file listing for RAR & 7z — design
|
||||
|
||||
**Date:** 2026-07-27
|
||||
**Status:** Approved (design), pending spec review → implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
The reindex/provenance-backfill path (`worker/src/provenance-backfill.ts`) can index an
|
||||
archive's inner files *without* re-downloading it, by reading the file listing from a small
|
||||
ranged read of the copy already in the source/destination channel. This works **only for
|
||||
ZIP** today (ZIP keeps its central directory in a tail that `parseZipCentralDirectoryFromTail`
|
||||
reads). For **RAR and 7z**, `tryProvenanceBackfill` backfills provenance (creator, source
|
||||
channel, `remoteUniqueId`) so the file is skipped on re-scan and never re-downloaded — but it
|
||||
leaves the inner listing empty (`fileCount = 0`), because `scannedEntries` is only computed for
|
||||
ZIP.
|
||||
|
||||
Scope of the gap (rebuild placeholders with `fileCount = 0`, as of 2026-07-27):
|
||||
|
||||
| Type | Count | Total | Avg | Max | Multipart |
|
||||
|---|---|---|---|---|---|
|
||||
| 7z | 19,646 | 14 TB | 0.73 GB | 3.9 GB | 0 |
|
||||
| RAR | 12,780 | 13 TB | 1.04 GB | 116 GB | 1,016 |
|
||||
|
||||
A "just download the whole archive" fallback for all of these means ~27 TB of re-downloads —
|
||||
the exact cost this path exists to avoid.
|
||||
|
||||
## Goals
|
||||
|
||||
- Index the inner files (names + sizes; CRCs where cheaply available) of RAR and 7z
|
||||
placeholders **without** downloading the whole archive in the common case.
|
||||
- Reuse the existing, battle-tested CLI listing parsers (`parse7zOutput`,
|
||||
`parseUnrarTechnical`) rather than reimplementing filename/size/CRC extraction.
|
||||
- Keep cost proportional to **file count**, not archive size (so even the 116 GB RAR is cheap).
|
||||
- Guarantee a listing for the rare archives the cheap path can't handle, via a full-download
|
||||
fallback that respects the existing max-size guard.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No change to ingestion of genuinely-new files (those are downloaded in full to be re-uploaded
|
||||
regardless, so a cheap listing does not help there). This feature only affects the
|
||||
provenance-backfill / skip path.
|
||||
- No new ZIP behaviour — the existing ZIP tail reader stays as-is.
|
||||
- Not attempting to list password-encrypted-header archives from ranged reads (no password);
|
||||
those take the fallback and, if oversized, are flagged.
|
||||
|
||||
## Approach (chosen: "harvest header regions → sparse file → native CLI")
|
||||
|
||||
Do the *minimum* binary parsing needed to locate an archive's header bytes, fetch only those
|
||||
via ranged reads, write them into a sparse temp file at their true offsets (data regions left
|
||||
as unwritten zero holes → ~no disk use), then run the real `7z l` / `unrar lt` and reuse the
|
||||
existing parsers. The native tools do the hard parsing (7z's LZMA-encoded headers, RAR's two
|
||||
format versions, Unicode names) — we only compute where the headers are.
|
||||
|
||||
Rejected alternatives: full native TS parsers (most custom binary code, highest risk);
|
||||
RAR5-quick-open-only (most community RARs lack it → collapses to ~13 TB of RAR downloads).
|
||||
|
||||
## Components
|
||||
|
||||
New directory `worker/src/archive/ranged/`, one focused module per concern, all returning the
|
||||
existing `FileEntry[]` type from `zip-reader.ts`:
|
||||
|
||||
- `sparse-list.ts` — `listFromSparse(parts, runner, parse) → FileEntry[] | null`, where each
|
||||
`part` is `{ fileName, size, regions: {offset, bytes}[] }`. For each part it writes a sparse
|
||||
temp file (`truncate` to `size`, then write only the header `regions` at their offsets),
|
||||
co-locates all parts in one temp dir under their real names, invokes the supplied CLI runner
|
||||
(`7z l` / `unrar lt`) on the first part, feeds stdout to the supplied `parse` fn
|
||||
(`parse7zOutput` / `parseUnrarTechnical`), and cleans up. Single-part archives are just the
|
||||
one-element case. Returns `null` on CLI error / empty parse.
|
||||
- `sevenz-ranged.ts` — `readSevenZListingRanged(client, parts) → FileEntry[] | null`.
|
||||
- `rar-ranged.ts` — `readRarListingRanged(client, parts) → FileEntry[] | null`.
|
||||
- Dispatcher in `provenance-backfill.ts`: `readScannedListingRanged(archiveType, client, parts)`
|
||||
replacing the current `if (archiveType === "ZIP")` branch; the destination-copy read in
|
||||
`resolveCandidateFingerprintEntries` gets the same dispatch.
|
||||
|
||||
`FileEntry` shape (unchanged): `{ path, fileName, extension, compressedSize, uncompressedSize, crc32 }`.
|
||||
|
||||
### 7z ranged listing
|
||||
|
||||
7z layout: 32-byte signature header at offset 0 → packed streams → end header (lists files) at
|
||||
the end; the signature header stores the end header's location.
|
||||
|
||||
1. Ranged-read `[0, 32)`; validate magic `37 7A BC AF 27 1C`. Read LE `uint64`
|
||||
`NextHeaderOffset` (byte 12) and `NextHeaderSize` (byte 20). End header is at absolute offset
|
||||
`32 + NextHeaderOffset`, length `NextHeaderSize`.
|
||||
2. Ranged-read `[32 + NextHeaderOffset, NextHeaderSize)` — the "next header".
|
||||
3. Branch on the next header's first byte (a 7z property id):
|
||||
- **`0x01` (kHeader, plain/uncompressed header):** two regions suffice —
|
||||
`{0: sigHeader}` and `{32+NextHeaderOffset: endHeader}`.
|
||||
- **`0x17` (kEncodedHeader, LZMA-compressed header):** the next header is only a *descriptor*
|
||||
whose `PackInfo` points at a packed header stream stored **in the middle** of the file (not
|
||||
at EOF). Parse the descriptor's `StreamsInfo → kPackInfo (0x06)` to read `PackPos` and the
|
||||
`PackSize`s (7z variable-length "numbers"; sum them). Ranged-read the contiguous packed
|
||||
region `[32 + PackPos, Σ PackSize)` and add it as a **third** sparse region. `7z l` then
|
||||
decodes the header from that region.
|
||||
- **anything else:** return `null` (→ fallback).
|
||||
4. `listFromSparse` with the 2 or 3 regions, runner = `7z l`. `parse7zOutput` yields
|
||||
names+sizes (`crc32: null`, as today). Return `null` on bad magic / read failure / CLI error.
|
||||
|
||||
**Why the third region is required (spike finding, 2026-07-27):** the original two-region
|
||||
(start+end) reconstruction was proven insufficient in a live test — `7z l` rejected it with
|
||||
"Cannot open the file as [7z] archive" because these archives use an *encoded* header whose
|
||||
compressed bytes live in a packed stream in the file body (a sparse hole), not at EOF. The
|
||||
`0x17` branch fetches exactly that packed region. `7z l` still never touches the file-data
|
||||
packed streams (it only lists), so those gaps stay sparse. The `read7zNumber` reader (7z's
|
||||
base-128-ish variable-length integer with a first-byte length mask) and the minimal
|
||||
`kPackInfo` walk are the only new 7z binary parsing; `7z l` still does the actual file listing.
|
||||
All 7z placeholders are single-part.
|
||||
|
||||
### RAR ranged listing
|
||||
|
||||
RAR has no index; walk the block chain, parsing only each block's **size fields** to step
|
||||
forward and harvest header bytes.
|
||||
|
||||
1. Read first ~16 bytes; detect **RAR4** (`52 61 72 21 1A 07 00`) vs **RAR5** (`…07 01 00`) and
|
||||
the signature length.
|
||||
2. From just after the signature, loop:
|
||||
- Ranged-read a header chunk (start 8 KB; if parsed `HeaderSize` exceeds it — long filenames
|
||||
— re-read exactly).
|
||||
- Minimal block-extent parse:
|
||||
- RAR5: `CRC32(4)` + vint `HeaderSize` + vint `HeaderType` + vint `HeaderFlags`; if the
|
||||
"extra area" flag (`0x0001`) → vint `ExtraAreaSize`; if the "data present" flag
|
||||
(`0x0002`) → vint `DataSize`. Next block = `pos + 4 + len(HeaderSize vint) + HeaderSize
|
||||
+ DataSize`.
|
||||
- RAR4: `HEAD_CRC(2)` + `HEAD_TYPE(1)` + `HEAD_FLAGS(2)` + `HEAD_SIZE(2)`; if flag `0x8000`
|
||||
→ `ADD_SIZE(4)`. Next block = `pos + HEAD_SIZE + ADD_SIZE`.
|
||||
- Harvest `[blockOffset, blockOffset + HeaderSize)` into the regions list.
|
||||
- Stop at the end-of-archive block or EOF.
|
||||
3. `listFromSparse` (headers present, data sparse) → `unrar lt` → `parseUnrarTechnical`. RAR
|
||||
headers carry CRC32, so RAR contributes CRCs (fingerprint disambiguation keeps working).
|
||||
|
||||
**Multipart RAR** (1,016): each volume starts with its own signature + headers. Walk **each
|
||||
part from its own signature**, reconstruct one sparse temp file per part with correct names
|
||||
(`name.part1.rar`, `.part2.rar`, …) co-located in a temp dir, and run `unrar lt` on part 1 —
|
||||
`unrar` auto-discovers co-located siblings (per the existing reader's note). The global-offset →
|
||||
`(part, offsetInPart)` mapping reuses the multipart size math the ZIP path already uses.
|
||||
|
||||
## Fallback & integration
|
||||
|
||||
- A ranged reader returning `null` = cheap read failed (bad magic, read error, walk gave up, or
|
||||
CLI error on the sparse file) → **full-download fallback**: download the whole archive, run the
|
||||
existing `readRarContents` / `read7zContents`, backfill.
|
||||
- The fallback is gated by `config.maxZipSizeMB` (the same guard used at ingest). Over the cap →
|
||||
no download; write a `SystemNotification` (`INTEGRITY_AUDIT`, WARNING) and leave the listing
|
||||
empty for manual review. This ensures nothing pathological (e.g. the 116 GB RAR) is pulled.
|
||||
- Downstream is unchanged: `compareFingerprints` already treats null/incomplete CRCs as
|
||||
"incomplete" (name-size path), and `backfillProvenance` writes entries when the candidate's
|
||||
`fileCount === 0`.
|
||||
- Observability: reuse the `zipsBackfilled` counter; add structured logs with
|
||||
`confidence: "ranged" | "full-download-fallback"` and a WARN on fallback so miss-rate is
|
||||
visible.
|
||||
|
||||
## Risks & de-risking spike (do before the full build)
|
||||
|
||||
On 3–4 real placeholder archives per format:
|
||||
|
||||
1. Confirm `downloadFileRange` returns correct bytes at **arbitrary (non-tail) offsets** —
|
||||
currently only tail-verified in production. Underpins everything; if it fails, stop and
|
||||
rethink. (Note: `range-download.ts` flags absolute-offset behaviour as pending live
|
||||
verification; tail reads are proven by the 43 ZIP backfills done 2026-07-26.)
|
||||
2. Confirm `7z l` and `unrar lt` list correctly from a **sparse reconstructed file** — single
|
||||
part first, then multipart RAR (the highest-risk case).
|
||||
|
||||
If multipart-RAR sparse reconstruction proves unreliable in the spike, multipart RAR uses the
|
||||
full-download fallback (respecting the size cap → oversized ones flagged, not downloaded).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit (vitest, alongside `central-directory.test.ts`):** 7z signature-header parse; RAR4 &
|
||||
RAR5 block-extent walk against committed small fixtures; `sparse-list` writes the correct
|
||||
regions. Pure logic, no TDLib.
|
||||
- **Live post-deploy:** watch `zipsBackfilled` climb for RAR/7z via the ranged path; spot-check
|
||||
a handful of backfilled packages' `package_files` against a real `unrar lt` / `7z l` on a full
|
||||
download of the same file; confirm the fallback/flag path fires on a deliberately-broken case.
|
||||
|
||||
## Rollout
|
||||
|
||||
Local build + deploy (no GitHub push required), per the established recipe: build
|
||||
`worker/Dockerfile` locally, recreate the `dragonsstash-worker` container from the local image
|
||||
(no `pull`). No new DB migration. The scheduler re-runs hourly and will backfill RAR/7z
|
||||
placeholders on subsequent cycles.
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Claude Code Windows CMD Bootstrap Script
|
||||
REM Installs Claude Code for environments where PowerShell is not available
|
||||
|
||||
REM Parse command line argument
|
||||
set "TARGET=%~1"
|
||||
if "!TARGET!"=="" set "TARGET=latest"
|
||||
|
||||
REM Validate target parameter
|
||||
if /i "!TARGET!"=="stable" goto :target_valid
|
||||
if /i "!TARGET!"=="latest" goto :target_valid
|
||||
echo !TARGET! | findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul
|
||||
if !ERRORLEVEL! equ 0 goto :target_valid
|
||||
|
||||
echo Usage: %0 [stable^|latest^|VERSION] >&2
|
||||
echo Example: %0 1.0.58 >&2
|
||||
exit /b 1
|
||||
|
||||
:target_valid
|
||||
|
||||
REM Check for 64-bit Windows
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid
|
||||
if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid
|
||||
if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid
|
||||
|
||||
echo Claude Code does not support 32-bit Windows. Please use a 64-bit version of Windows. >&2
|
||||
exit /b 1
|
||||
|
||||
:arch_valid
|
||||
|
||||
REM Set constants
|
||||
set "GCS_BUCKET=https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases"
|
||||
set "DOWNLOAD_DIR=%USERPROFILE%\.claude\downloads"
|
||||
REM Use native ARM64 binary on ARM64 Windows, x64 otherwise
|
||||
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
|
||||
set "PLATFORM=win32-arm64"
|
||||
) else (
|
||||
set "PLATFORM=win32-x64"
|
||||
)
|
||||
|
||||
REM Create download directory
|
||||
if not exist "!DOWNLOAD_DIR!" mkdir "!DOWNLOAD_DIR!"
|
||||
|
||||
REM Check for curl availability
|
||||
curl --version >nul 2>&1
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo curl is required but not available. Please install curl or use PowerShell installer. >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Always download latest version (which has the most up-to-date installer)
|
||||
call :download_file "!GCS_BUCKET!/latest" "!DOWNLOAD_DIR!\latest"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Failed to get latest version >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Read version from file
|
||||
set /p VERSION=<"!DOWNLOAD_DIR!\latest"
|
||||
del "!DOWNLOAD_DIR!\latest"
|
||||
|
||||
REM Download manifest
|
||||
call :download_file "!GCS_BUCKET!/!VERSION!/manifest.json" "!DOWNLOAD_DIR!\manifest.json"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Failed to get manifest >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Extract checksum from manifest
|
||||
call :parse_manifest "!DOWNLOAD_DIR!\manifest.json" "!PLATFORM!"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Platform !PLATFORM! not found in manifest >&2
|
||||
del "!DOWNLOAD_DIR!\manifest.json" 2>nul
|
||||
exit /b 1
|
||||
)
|
||||
del "!DOWNLOAD_DIR!\manifest.json"
|
||||
|
||||
REM Download binary
|
||||
set "BINARY_PATH=!DOWNLOAD_DIR!\claude-!VERSION!-!PLATFORM!.exe"
|
||||
call :download_file "!GCS_BUCKET!/!VERSION!/!PLATFORM!/claude.exe" "!BINARY_PATH!"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Failed to download binary >&2
|
||||
if exist "!BINARY_PATH!" del "!BINARY_PATH!"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Verify checksum
|
||||
call :verify_checksum "!BINARY_PATH!" "!EXPECTED_CHECKSUM!"
|
||||
if !ERRORLEVEL! neq 0 (
|
||||
echo Checksum verification failed >&2
|
||||
del "!BINARY_PATH!"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Run claude install to set up launcher and shell integration
|
||||
echo Setting up Claude Code...
|
||||
"!BINARY_PATH!" install "!TARGET!"
|
||||
set "INSTALL_RESULT=!ERRORLEVEL!"
|
||||
|
||||
REM Clean up downloaded file
|
||||
REM Wait a moment for any file handles to be released
|
||||
timeout /t 1 /nobreak >nul 2>&1
|
||||
del /f "!BINARY_PATH!" >nul 2>&1
|
||||
if exist "!BINARY_PATH!" (
|
||||
echo Warning: Could not remove temporary file: !BINARY_PATH!
|
||||
)
|
||||
|
||||
if !INSTALL_RESULT! neq 0 (
|
||||
echo Installation failed >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Installation complete^^!
|
||||
echo.
|
||||
exit /b 0
|
||||
|
||||
REM ============================================================================
|
||||
REM SUBROUTINES
|
||||
REM ============================================================================
|
||||
|
||||
:download_file
|
||||
REM Downloads a file using curl
|
||||
REM Args: %1=URL, %2=OutputPath
|
||||
set "URL=%~1"
|
||||
set "OUTPUT=%~2"
|
||||
|
||||
curl -fsSL "!URL!" -o "!OUTPUT!"
|
||||
exit /b !ERRORLEVEL!
|
||||
|
||||
:parse_manifest
|
||||
REM Parse JSON manifest to extract checksum for platform
|
||||
REM Args: %1=ManifestPath, %2=Platform
|
||||
set "MANIFEST_PATH=%~1"
|
||||
set "PLATFORM_NAME=%~2"
|
||||
set "EXPECTED_CHECKSUM="
|
||||
|
||||
REM Use findstr to find platform section, then look for checksum
|
||||
set "FOUND_PLATFORM="
|
||||
set "IN_PLATFORM_SECTION="
|
||||
|
||||
REM Read the manifest line by line
|
||||
for /f "usebackq tokens=*" %%i in ("!MANIFEST_PATH!") do (
|
||||
set "LINE=%%i"
|
||||
|
||||
REM Check if this line contains our platform
|
||||
echo !LINE! | findstr /c:"\"%PLATFORM_NAME%\":" >nul
|
||||
if !ERRORLEVEL! equ 0 (
|
||||
set "IN_PLATFORM_SECTION=1"
|
||||
)
|
||||
|
||||
REM If we're in the platform section, look for checksum
|
||||
if defined IN_PLATFORM_SECTION (
|
||||
echo !LINE! | findstr /c:"\"checksum\":" >nul
|
||||
if !ERRORLEVEL! equ 0 (
|
||||
REM Extract checksum value
|
||||
for /f "tokens=2 delims=:" %%j in ("!LINE!") do (
|
||||
set "CHECKSUM_PART=%%j"
|
||||
REM Remove quotes, whitespace, and comma
|
||||
set "CHECKSUM_PART=!CHECKSUM_PART: =!"
|
||||
set "CHECKSUM_PART=!CHECKSUM_PART:"=!"
|
||||
set "CHECKSUM_PART=!CHECKSUM_PART:,=!"
|
||||
|
||||
REM Check if it looks like a SHA256 (64 hex chars)
|
||||
if not "!CHECKSUM_PART!"=="" (
|
||||
call :check_length "!CHECKSUM_PART!" 64
|
||||
if !ERRORLEVEL! equ 0 (
|
||||
set "EXPECTED_CHECKSUM=!CHECKSUM_PART!"
|
||||
exit /b 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
REM Check if we've left the platform section (closing brace)
|
||||
echo !LINE! | findstr /c:"}" >nul
|
||||
if !ERRORLEVEL! equ 0 set "IN_PLATFORM_SECTION="
|
||||
)
|
||||
)
|
||||
|
||||
if "!EXPECTED_CHECKSUM!"=="" exit /b 1
|
||||
exit /b 0
|
||||
|
||||
:check_length
|
||||
REM Check if string length equals expected length
|
||||
REM Args: %1=String, %2=ExpectedLength
|
||||
set "STR=%~1"
|
||||
set "EXPECTED_LEN=%~2"
|
||||
set "LEN=0"
|
||||
:count_loop
|
||||
if "!STR:~%LEN%,1!"=="" goto :count_done
|
||||
set /a LEN+=1
|
||||
goto :count_loop
|
||||
:count_done
|
||||
if %LEN%==%EXPECTED_LEN% exit /b 0
|
||||
exit /b 1
|
||||
|
||||
:verify_checksum
|
||||
REM Verify file checksum using certutil
|
||||
REM Args: %1=FilePath, %2=ExpectedChecksum
|
||||
set "FILE_PATH=%~1"
|
||||
set "EXPECTED=%~2"
|
||||
|
||||
for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!FILE_PATH!" SHA256') do (
|
||||
set "ACTUAL=%%i"
|
||||
set "ACTUAL=!ACTUAL: =!"
|
||||
if "!ACTUAL!"=="CertUtil:Thecommandcompletedsuccessfully." goto :verify_done
|
||||
if "!ACTUAL!" neq "" (
|
||||
if /i "!ACTUAL!"=="!EXPECTED!" (
|
||||
exit /b 0
|
||||
) else (
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
:verify_done
|
||||
exit /b 1
|
||||
@@ -0,0 +1,21 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "invite_codes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" VARCHAR(32) NOT NULL,
|
||||
"maxUses" INTEGER NOT NULL DEFAULT 1,
|
||||
"uses" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "invite_codes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "invite_codes_code_key" ON "invite_codes"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "invite_codes_code_idx" ON "invite_codes"("code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "invite_codes" ADD CONSTRAINT "invite_codes_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ArchiveType" ADD VALUE 'SEVEN_Z';
|
||||
ALTER TYPE "ArchiveType" ADD VALUE 'DOCUMENT';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "telegram_channels" ADD COLUMN "category" VARCHAR(64);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "telegram_channels_category_idx" ON "telegram_channels"("category");
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ExtractStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "usedInviteId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "archive_extract_requests" (
|
||||
"id" TEXT NOT NULL,
|
||||
"packageId" TEXT NOT NULL,
|
||||
"filePath" VARCHAR(1024) NOT NULL,
|
||||
"status" "ExtractStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"imageData" BYTEA,
|
||||
"contentType" VARCHAR(64),
|
||||
"error" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "archive_extract_requests_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "archive_extract_requests_packageId_filePath_idx" ON "archive_extract_requests"("packageId", "filePath");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "archive_extract_requests_status_idx" ON "archive_extract_requests"("status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_usedInviteId_fkey" FOREIGN KEY ("usedInviteId") REFERENCES "invite_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "archive_extract_requests" ADD CONSTRAINT "archive_extract_requests_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "packages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add tags array column to packages
|
||||
ALTER TABLE "packages" ADD COLUMN "tags" TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Backfill: inherit source channel category as initial tag
|
||||
UPDATE "packages" p
|
||||
SET "tags" = ARRAY[c."category"]
|
||||
FROM "telegram_channels" c
|
||||
WHERE p."sourceChannelId" = c."id"
|
||||
AND c."category" IS NOT NULL
|
||||
AND c."category" != '';
|
||||
@@ -0,0 +1,50 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DeliveryStatus" AS ENUM ('NOT_DELIVERED', 'PARTIAL', 'DELIVERED');
|
||||
CREATE TYPE "PaymentStatus" AS ENUM ('PAID', 'UNPAID');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kickstarter_hosts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "kickstarter_hosts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kickstarters" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"link" TEXT,
|
||||
"filesUrl" TEXT,
|
||||
"deliveryStatus" "DeliveryStatus" NOT NULL DEFAULT 'NOT_DELIVERED',
|
||||
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'UNPAID',
|
||||
"notes" TEXT,
|
||||
"hostId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "kickstarters_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "kickstarter_packages" (
|
||||
"kickstarterId" TEXT NOT NULL,
|
||||
"packageId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "kickstarter_packages_pkey" PRIMARY KEY ("kickstarterId","packageId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "kickstarter_hosts_name_key" ON "kickstarter_hosts"("name");
|
||||
CREATE INDEX "kickstarters_hostId_idx" ON "kickstarters"("hostId");
|
||||
CREATE INDEX "kickstarters_userId_idx" ON "kickstarters"("userId");
|
||||
CREATE INDEX "kickstarters_deliveryStatus_idx" ON "kickstarters"("deliveryStatus");
|
||||
CREATE INDEX "kickstarters_paymentStatus_idx" ON "kickstarters"("paymentStatus");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "kickstarters" ADD CONSTRAINT "kickstarters_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "kickstarter_hosts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "kickstarters" ADD CONSTRAINT "kickstarters_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "kickstarter_packages" ADD CONSTRAINT "kickstarter_packages_kickstarterId_fkey" FOREIGN KEY ("kickstarterId") REFERENCES "kickstarters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "kickstarter_packages" ADD CONSTRAINT "kickstarter_packages_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "packages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SkipReason" AS ENUM ('SIZE_LIMIT', 'DOWNLOAD_FAILED', 'EXTRACT_FAILED', 'UPLOAD_FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "skipped_packages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"fileSize" BIGINT NOT NULL,
|
||||
"reason" "SkipReason" NOT NULL,
|
||||
"errorMessage" TEXT,
|
||||
"sourceChannelId" TEXT NOT NULL,
|
||||
"sourceMessageId" BIGINT NOT NULL,
|
||||
"sourceTopicId" BIGINT,
|
||||
"isMultipart" BOOLEAN NOT NULL DEFAULT false,
|
||||
"partCount" INTEGER NOT NULL DEFAULT 1,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "skipped_packages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "skipped_packages_sourceChannelId_sourceMessageId_key" ON "skipped_packages"("sourceChannelId", "sourceMessageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "skipped_packages_reason_idx" ON "skipped_packages"("reason");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "skipped_packages_accountId_idx" ON "skipped_packages"("accountId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "skipped_packages" ADD CONSTRAINT "skipped_packages_sourceChannelId_fkey" FOREIGN KEY ("sourceChannelId") REFERENCES "telegram_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "skipped_packages" ADD CONSTRAINT "skipped_packages_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "telegram_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "packages" ADD COLUMN "packageGroupId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "package_groups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"mediaAlbumId" TEXT,
|
||||
"sourceChannelId" TEXT NOT NULL,
|
||||
"previewData" BYTEA,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "package_groups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "package_groups_sourceChannelId_idx" ON "package_groups"("sourceChannelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "package_groups_mediaAlbumId_sourceChannelId_key" ON "package_groups"("mediaAlbumId", "sourceChannelId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "packages_packageGroupId_idx" ON "packages"("packageGroupId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "packages" ADD CONSTRAINT "packages_packageGroupId_fkey" FOREIGN KEY ("packageGroupId") REFERENCES "package_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "package_groups" ADD CONSTRAINT "package_groups_sourceChannelId_fkey" FOREIGN KEY ("sourceChannelId") REFERENCES "telegram_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "packages" ADD COLUMN "destMessageIds" BIGINT[] DEFAULT ARRAY[]::BIGINT[];
|
||||
|
||||
-- Backfill: copy existing destMessageId into the array
|
||||
UPDATE "packages"
|
||||
SET "destMessageIds" = ARRAY["destMessageId"]
|
||||
WHERE "destMessageId" IS NOT NULL;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateEnum GroupingSource
|
||||
CREATE TYPE "GroupingSource" AS ENUM ('ALBUM', 'MANUAL', 'AUTO_TIME', 'AUTO_PATTERN', 'AUTO_REPLY', 'AUTO_ZIP', 'AUTO_CAPTION');
|
||||
|
||||
-- CreateEnum NotificationType
|
||||
CREATE TYPE "NotificationType" AS ENUM ('HASH_MISMATCH', 'MISSING_PART', 'UPLOAD_FAILED', 'DOWNLOAD_FAILED', 'GROUPING_CONFLICT', 'INTEGRITY_AUDIT');
|
||||
|
||||
-- CreateEnum NotificationSeverity
|
||||
CREATE TYPE "NotificationSeverity" AS ENUM ('INFO', 'WARNING', 'ERROR');
|
||||
|
||||
-- AlterTable: add groupingSource to package_groups
|
||||
ALTER TABLE "package_groups" ADD COLUMN "groupingSource" "GroupingSource" NOT NULL DEFAULT 'MANUAL';
|
||||
|
||||
-- Backfill: mark album-based groups
|
||||
UPDATE "package_groups" SET "groupingSource" = 'ALBUM' WHERE "mediaAlbumId" IS NOT NULL;
|
||||
|
||||
-- CreateTable: system_notifications
|
||||
CREATE TABLE "system_notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "NotificationType" NOT NULL,
|
||||
"severity" "NotificationSeverity" NOT NULL DEFAULT 'INFO',
|
||||
"title" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"context" JSONB,
|
||||
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "system_notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "system_notifications_isRead_createdAt_idx" ON "system_notifications"("isRead", "createdAt");
|
||||
CREATE INDEX "system_notifications_type_idx" ON "system_notifications"("type");
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: add sourceCaption and replyToMessageId to packages
|
||||
ALTER TABLE "packages" ADD COLUMN "sourceCaption" TEXT;
|
||||
ALTER TABLE "packages" ADD COLUMN "replyToMessageId" BIGINT;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- AlterTable: add autoGroupEnabled to telegram_channels
|
||||
ALTER TABLE "telegram_channels" ADD COLUMN "autoGroupEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable: grouping_rules
|
||||
CREATE TABLE "grouping_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sourceChannelId" TEXT NOT NULL,
|
||||
"pattern" TEXT NOT NULL,
|
||||
"signalType" "GroupingSource" NOT NULL,
|
||||
"confidence" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdByGroupId" TEXT,
|
||||
|
||||
CONSTRAINT "grouping_rules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "grouping_rules_sourceChannelId_idx" ON "grouping_rules"("sourceChannelId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "grouping_rules" ADD CONSTRAINT "grouping_rules_sourceChannelId_fkey" FOREIGN KEY ("sourceChannelId") REFERENCES "telegram_channels"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Full-text search: add tsvector column and GIN index
|
||||
ALTER TABLE "packages" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
|
||||
|
||||
UPDATE "packages" SET "searchVector" = to_tsvector('english',
|
||||
coalesce("fileName", '') || ' ' || coalesce("creator", '') || ' ' || coalesce("sourceCaption", '')
|
||||
) WHERE "searchVector" IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "packages_search_vector_idx" ON "packages" USING GIN ("searchVector");
|
||||
|
||||
-- Trigger to auto-update searchVector on insert/update
|
||||
CREATE OR REPLACE FUNCTION packages_search_vector_update() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW."searchVector" := to_tsvector('english',
|
||||
coalesce(NEW."fileName", '') || ' ' || coalesce(NEW."creator", '') || ' ' || coalesce(NEW."sourceCaption", '')
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS packages_search_vector_trigger ON "packages";
|
||||
CREATE TRIGGER packages_search_vector_trigger
|
||||
BEFORE INSERT OR UPDATE OF "fileName", "creator", "sourceCaption"
|
||||
ON "packages"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION packages_search_vector_update();
|
||||
@@ -0,0 +1,30 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ManualUploadStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "manual_uploads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "ManualUploadStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"groupName" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"errorMessage" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
CONSTRAINT "manual_uploads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "manual_upload_files" (
|
||||
"id" TEXT NOT NULL,
|
||||
"uploadId" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"fileSize" BIGINT NOT NULL,
|
||||
"packageId" TEXT,
|
||||
CONSTRAINT "manual_upload_files_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "manual_uploads_status_idx" ON "manual_uploads"("status");
|
||||
CREATE INDEX "manual_upload_files_uploadId_idx" ON "manual_upload_files"("uploadId");
|
||||
|
||||
ALTER TABLE "manual_uploads" ADD CONSTRAINT "manual_uploads_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
ALTER TABLE "manual_upload_files" ADD CONSTRAINT "manual_upload_files_uploadId_fkey" FOREIGN KEY ("uploadId") REFERENCES "manual_uploads"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "telegram_accounts" ADD COLUMN "isPremium" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: count of packages whose provenance was backfilled during a run
|
||||
-- (opportunistic cross-channel provenance backfill). Additive, non-null with a
|
||||
-- default of 0 — no data change for existing rows.
|
||||
ALTER TABLE "ingestion_runs"
|
||||
ADD COLUMN "zipsBackfilled" INTEGER NOT NULL DEFAULT 0;
|
||||
+328
-10
@@ -38,6 +38,11 @@ model User {
|
||||
tags Tag[]
|
||||
settings UserSettings?
|
||||
telegramLink TelegramLink?
|
||||
kickstarters Kickstarter[]
|
||||
inviteCodes InviteCode[] @relation("InviteCreator")
|
||||
usedInvite InviteCode? @relation("InviteUser", fields: [usedInviteId], references: [id], onDelete: SetNull)
|
||||
usedInviteId String?
|
||||
manualUploads ManualUpload[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -376,6 +381,8 @@ enum ChannelRole {
|
||||
enum ArchiveType {
|
||||
ZIP
|
||||
RAR
|
||||
SEVEN_Z
|
||||
DOCUMENT
|
||||
}
|
||||
|
||||
enum IngestionStatus {
|
||||
@@ -399,13 +406,15 @@ model TelegramAccount {
|
||||
isActive Boolean @default(true)
|
||||
authState AuthState @default(PENDING)
|
||||
authCode String?
|
||||
isPremium Boolean @default(false)
|
||||
lastSeenAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
channelMaps AccountChannelMap[]
|
||||
ingestionRuns IngestionRun[]
|
||||
fetchRequests ChannelFetchRequest[]
|
||||
channelMaps AccountChannelMap[]
|
||||
ingestionRuns IngestionRun[]
|
||||
fetchRequests ChannelFetchRequest[]
|
||||
skippedPackages SkippedPackage[]
|
||||
|
||||
@@index([isActive])
|
||||
@@map("telegram_accounts")
|
||||
@@ -418,13 +427,20 @@ model TelegramChannel {
|
||||
type ChannelType
|
||||
isForum Boolean @default(false)
|
||||
isActive Boolean @default(false)
|
||||
category String? @db.VarChar(64)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accountMaps AccountChannelMap[]
|
||||
packages Package[]
|
||||
autoGroupEnabled Boolean @default(true)
|
||||
|
||||
accountMaps AccountChannelMap[]
|
||||
packages Package[]
|
||||
skippedPackages SkippedPackage[]
|
||||
packageGroups PackageGroup[]
|
||||
groupingRules GroupingRule[]
|
||||
|
||||
@@index([type, isActive])
|
||||
@@index([category])
|
||||
@@map("telegram_channels")
|
||||
}
|
||||
|
||||
@@ -434,6 +450,17 @@ model AccountChannelMap {
|
||||
channelId String
|
||||
role ChannelRole @default(READER)
|
||||
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())
|
||||
|
||||
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
@@ -456,21 +483,34 @@ model Package {
|
||||
sourceChannelId String
|
||||
sourceMessageId 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?
|
||||
destMessageId BigInt?
|
||||
destMessageIds BigInt[] @default([])
|
||||
isMultipart Boolean @default(false)
|
||||
partCount Int @default(1)
|
||||
fileCount Int @default(0)
|
||||
tags String[] @default([])
|
||||
sourceCaption String? // Caption text from source Telegram message
|
||||
replyToMessageId BigInt? // reply_to_message_id from source message (for reply chain grouping)
|
||||
previewData Bytes? // JPEG thumbnail from nearby Telegram photo (stored as raw bytes)
|
||||
previewMsgId BigInt? // Telegram message ID of the matched photo
|
||||
packageGroupId String?
|
||||
indexedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id])
|
||||
files PackageFile[]
|
||||
ingestionRun IngestionRun? @relation(fields: [ingestionRunId], references: [id])
|
||||
ingestionRunId String?
|
||||
sendRequests BotSendRequest[]
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id])
|
||||
packageGroup PackageGroup? @relation(fields: [packageGroupId], references: [id], onDelete: SetNull)
|
||||
files PackageFile[]
|
||||
ingestionRun IngestionRun? @relation(fields: [ingestionRunId], references: [id])
|
||||
ingestionRunId String?
|
||||
sendRequests BotSendRequest[]
|
||||
extractRequests ArchiveExtractRequest[]
|
||||
kickstarterLinks KickstarterPackage[]
|
||||
|
||||
@@index([sourceChannelId])
|
||||
@@index([destChannelId])
|
||||
@@ -478,6 +518,8 @@ model Package {
|
||||
@@index([indexedAt])
|
||||
@@index([archiveType])
|
||||
@@index([creator])
|
||||
@@index([packageGroupId])
|
||||
@@index([sourceChannelId, remoteUniqueId])
|
||||
@@map("packages")
|
||||
}
|
||||
|
||||
@@ -499,6 +541,24 @@ model PackageFile {
|
||||
@@map("package_files")
|
||||
}
|
||||
|
||||
model PackageGroup {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
mediaAlbumId String?
|
||||
sourceChannelId String
|
||||
groupingSource GroupingSource @default(MANUAL)
|
||||
previewData Bytes?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
packages Package[]
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([mediaAlbumId, sourceChannelId])
|
||||
@@index([sourceChannelId])
|
||||
@@map("package_groups")
|
||||
}
|
||||
|
||||
model IngestionRun {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
@@ -509,6 +569,7 @@ model IngestionRun {
|
||||
zipsFound Int @default(0)
|
||||
zipsDuplicate Int @default(0)
|
||||
zipsIngested Int @default(0)
|
||||
zipsBackfilled Int @default(0)
|
||||
errorMessage String?
|
||||
|
||||
// Live activity tracking — written by worker in real-time
|
||||
@@ -522,6 +583,8 @@ model IngestionRun {
|
||||
totalBytes BigInt? // Total size of current download
|
||||
downloadPercent Int? // 0-100
|
||||
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])
|
||||
packages Package[]
|
||||
@@ -537,7 +600,20 @@ model TopicProgress {
|
||||
accountChannelMapId String
|
||||
topicId BigInt
|
||||
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?
|
||||
/// 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)
|
||||
|
||||
@@ -554,6 +630,22 @@ model GlobalSetting {
|
||||
@@map("global_settings")
|
||||
}
|
||||
|
||||
model InviteCode {
|
||||
id String @id @default(cuid())
|
||||
code String @unique @db.VarChar(32)
|
||||
maxUses Int @default(1)
|
||||
uses Int @default(0)
|
||||
expiresAt DateTime?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
usedBy User[] @relation("InviteUser")
|
||||
|
||||
@@index([code])
|
||||
@@map("invite_codes")
|
||||
}
|
||||
|
||||
model ChannelFetchRequest {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
@@ -626,3 +718,229 @@ model BotSubscription {
|
||||
@@index([telegramUserId])
|
||||
@@map("bot_subscriptions")
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────
|
||||
// Archive image extraction (worker-mediated)
|
||||
// ───────────────────────────────────────
|
||||
|
||||
enum ExtractStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
/// A request for the worker to extract an image from an archive.
|
||||
/// The web app creates this, sends a pg_notify, and the worker
|
||||
/// downloads the archive, extracts the file, and writes the result.
|
||||
model ArchiveExtractRequest {
|
||||
id String @id @default(cuid())
|
||||
packageId String
|
||||
filePath String @db.VarChar(1024) // path within archive to extract
|
||||
status ExtractStatus @default(PENDING)
|
||||
imageData Bytes? // extracted image bytes (JPEG/PNG/WebP)
|
||||
contentType String? @db.VarChar(64) // MIME type of extracted image
|
||||
error String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([packageId, filePath])
|
||||
@@index([status])
|
||||
@@map("archive_extract_requests")
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────
|
||||
// Skipped/Failed Archives
|
||||
// ───────────────────────────────────────
|
||||
|
||||
enum SkipReason {
|
||||
SIZE_LIMIT
|
||||
DOWNLOAD_FAILED
|
||||
EXTRACT_FAILED
|
||||
UPLOAD_FAILED
|
||||
}
|
||||
|
||||
model SkippedPackage {
|
||||
id String @id @default(cuid())
|
||||
fileName String
|
||||
fileSize BigInt
|
||||
reason SkipReason
|
||||
errorMessage String?
|
||||
sourceChannelId String
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade)
|
||||
sourceMessageId BigInt
|
||||
sourceTopicId BigInt?
|
||||
isMultipart Boolean @default(false)
|
||||
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
|
||||
account TelegramAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([sourceChannelId, sourceMessageId])
|
||||
@@index([reason])
|
||||
@@index([accountId])
|
||||
@@map("skipped_packages")
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────
|
||||
// Purchased Kickstarters
|
||||
// ───────────────────────────────────────
|
||||
|
||||
enum DeliveryStatus {
|
||||
NOT_DELIVERED
|
||||
PARTIAL
|
||||
DELIVERED
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
PAID
|
||||
UNPAID
|
||||
}
|
||||
|
||||
model KickstarterHost {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kickstarters Kickstarter[]
|
||||
|
||||
@@map("kickstarter_hosts")
|
||||
}
|
||||
|
||||
model Kickstarter {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
link String?
|
||||
filesUrl String?
|
||||
deliveryStatus DeliveryStatus @default(NOT_DELIVERED)
|
||||
paymentStatus PaymentStatus @default(UNPAID)
|
||||
notes String?
|
||||
hostId String?
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
host KickstarterHost? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
packages KickstarterPackage[]
|
||||
|
||||
@@index([hostId])
|
||||
@@index([userId])
|
||||
@@index([deliveryStatus])
|
||||
@@index([paymentStatus])
|
||||
@@map("kickstarters")
|
||||
}
|
||||
|
||||
model KickstarterPackage {
|
||||
kickstarterId String
|
||||
packageId String
|
||||
|
||||
kickstarter Kickstarter @relation(fields: [kickstarterId], references: [id], onDelete: Cascade)
|
||||
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([kickstarterId, packageId])
|
||||
@@map("kickstarter_packages")
|
||||
}
|
||||
|
||||
// ── Grouping & Notifications ──
|
||||
|
||||
enum GroupingSource {
|
||||
ALBUM
|
||||
MANUAL
|
||||
AUTO_TIME
|
||||
AUTO_PATTERN
|
||||
AUTO_REPLY
|
||||
AUTO_ZIP
|
||||
AUTO_CAPTION
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
HASH_MISMATCH
|
||||
MISSING_PART
|
||||
UPLOAD_FAILED
|
||||
DOWNLOAD_FAILED
|
||||
GROUPING_CONFLICT
|
||||
INTEGRITY_AUDIT
|
||||
CHANNEL_ACCESS_LOST
|
||||
}
|
||||
|
||||
enum NotificationSeverity {
|
||||
INFO
|
||||
WARNING
|
||||
ERROR
|
||||
}
|
||||
|
||||
model SystemNotification {
|
||||
id String @id @default(cuid())
|
||||
type NotificationType
|
||||
severity NotificationSeverity @default(INFO)
|
||||
title String
|
||||
message String
|
||||
context Json?
|
||||
isRead Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([isRead, createdAt])
|
||||
@@index([type])
|
||||
@@map("system_notifications")
|
||||
}
|
||||
|
||||
model GroupingRule {
|
||||
id String @id @default(cuid())
|
||||
sourceChannelId String
|
||||
pattern String // Regex or keyword pattern learned from manual grouping
|
||||
signalType GroupingSource // Which grouping signal this rule applies to
|
||||
confidence Float @default(1.0)
|
||||
createdAt DateTime @default(now())
|
||||
createdByGroupId String? // The manual group that spawned this rule
|
||||
|
||||
sourceChannel TelegramChannel @relation(fields: [sourceChannelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sourceChannelId])
|
||||
@@map("grouping_rules")
|
||||
}
|
||||
|
||||
enum ManualUploadStatus {
|
||||
PENDING
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model ManualUpload {
|
||||
id String @id @default(cuid())
|
||||
status ManualUploadStatus @default(PENDING)
|
||||
groupName String? // Group name if multiple files
|
||||
userId String
|
||||
errorMessage String?
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
files ManualUploadFile[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([status])
|
||||
@@map("manual_uploads")
|
||||
}
|
||||
|
||||
model ManualUploadFile {
|
||||
id String @id @default(cuid())
|
||||
uploadId String
|
||||
fileName String
|
||||
filePath String // Path on shared volume
|
||||
fileSize BigInt
|
||||
packageId String? // Set after processing
|
||||
|
||||
upload ManualUpload @relation(fields: [uploadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([uploadId])
|
||||
@@map("manual_upload_files")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Copy, Link2, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { createInviteCode, createBulkInviteCodes, deleteInviteCode } from "../actions";
|
||||
|
||||
type InviteUser = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type InviteCode = {
|
||||
id: string;
|
||||
code: string;
|
||||
maxUses: number;
|
||||
uses: number;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
creator: { name: string | null };
|
||||
usedBy: InviteUser[];
|
||||
};
|
||||
|
||||
export function InviteManager({
|
||||
inviteCodes,
|
||||
appUrl,
|
||||
}: {
|
||||
inviteCodes: InviteCode[];
|
||||
appUrl: string;
|
||||
}) {
|
||||
const [maxUses, setMaxUses] = useState(1);
|
||||
const [expiresInDays, setExpiresInDays] = useState(7);
|
||||
const [noExpiry, setNoExpiry] = useState(false);
|
||||
const [bulkCount, setBulkCount] = useState(5);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [copiedType, setCopiedType] = useState<"code" | "link" | null>(null);
|
||||
|
||||
function handleCreate() {
|
||||
startTransition(async () => {
|
||||
await createInviteCode({
|
||||
maxUses,
|
||||
expiresInDays: noExpiry ? null : expiresInDays,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleBulkCreate() {
|
||||
startTransition(async () => {
|
||||
await createBulkInviteCodes({
|
||||
count: bulkCount,
|
||||
maxUses,
|
||||
expiresInDays: noExpiry ? null : expiresInDays,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
startTransition(async () => {
|
||||
await deleteInviteCode(id);
|
||||
});
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string, id: string, type: "code" | "link") {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setCopiedType(type);
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
setCopiedType(null);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function getStatus(invite: InviteCode): "active" | "used" | "expired" {
|
||||
if (invite.uses >= invite.maxUses) return "used";
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) return "expired";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return "Expired";
|
||||
if (diffDays === 0) return "Today";
|
||||
if (diffDays === 1) return "Tomorrow";
|
||||
return `${diffDays} days`;
|
||||
}
|
||||
|
||||
const activeCount = inviteCodes.filter((i) => getStatus(i) === "active").length;
|
||||
const usedCount = inviteCodes.filter((i) => getStatus(i) === "used").length;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
{/* Create Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generate Invite Codes</CardTitle>
|
||||
<CardDescription>
|
||||
Create single or bulk invite codes to share with new users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxUses">Max Uses</Label>
|
||||
<Input
|
||||
id="maxUses"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiresInDays">Expires in (days)</Label>
|
||||
<Input
|
||||
id="expiresInDays"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
disabled={noExpiry}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<Switch
|
||||
id="noExpiry"
|
||||
checked={noExpiry}
|
||||
onCheckedChange={setNoExpiry}
|
||||
/>
|
||||
<Label htmlFor="noExpiry" className="text-sm">
|
||||
No expiry
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 border-t pt-4">
|
||||
<Button onClick={handleCreate} disabled={isPending}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isPending ? "Creating..." : "Create One"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bulkCount">Count</Label>
|
||||
<Input
|
||||
id="bulkCount"
|
||||
type="number"
|
||||
min={2}
|
||||
max={25}
|
||||
value={bulkCount}
|
||||
onChange={(e) => setBulkCount(Number(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBulkCreate}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isPending ? "Creating..." : `Create ${bulkCount}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Codes Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invite Codes</CardTitle>
|
||||
<CardDescription>
|
||||
{inviteCodes.length} total · {activeCount} active · {usedCount} fully used
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{inviteCodes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No invite codes yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>Redeemed By</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inviteCodes.map((invite) => {
|
||||
const status = getStatus(invite);
|
||||
const isCopiedCode =
|
||||
copiedId === invite.id && copiedType === "code";
|
||||
const isCopiedLink =
|
||||
copiedId === invite.id && copiedType === "link";
|
||||
|
||||
return (
|
||||
<TableRow key={invite.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{invite.code}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
status === "active"
|
||||
? "default"
|
||||
: status === "used"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invite.uses} / {invite.maxUses}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invite.usedBy.length === 0 ? (
|
||||
<span className="text-muted-foreground">--</span>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{invite.usedBy.map((user) => (
|
||||
<Tooltip key={user.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-sm cursor-default">
|
||||
{user.name ?? user.email ?? "Unknown"}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-xs">
|
||||
{user.email && <div>{user.email}</div>}
|
||||
<div>
|
||||
Joined{" "}
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invite.expiresAt ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">
|
||||
{formatRelativeDate(invite.expiresAt)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{new Date(invite.expiresAt).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">
|
||||
{new Date(invite.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
by {invite.creator.name ?? "Unknown"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
invite.code,
|
||||
invite.id,
|
||||
"code"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
{isCopiedCode && (
|
||||
<span className="ml-1">Copied!</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy code</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`${appUrl}/register?code=${invite.code}`,
|
||||
invite.id,
|
||||
"link"
|
||||
)
|
||||
}
|
||||
disabled={status !== "active"}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{isCopiedLink && (
|
||||
<span className="ml-1">Copied!</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy registration link</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<AlertDialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete code</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete invite code?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the invite code{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
{invite.code}
|
||||
</span>
|
||||
.{" "}
|
||||
{status === "active" &&
|
||||
"Anyone with this code will no longer be able to register."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(invite.id)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ActionResult } from "@/types/api.types";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function createInviteCode(input: {
|
||||
maxUses: number;
|
||||
expiresInDays: number | null;
|
||||
}): Promise<ActionResult<{ code: string }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const code = crypto.randomBytes(6).toString("hex");
|
||||
const expiresAt = input.expiresInDays
|
||||
? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
await prisma.inviteCode.create({
|
||||
data: {
|
||||
code,
|
||||
maxUses: input.maxUses,
|
||||
expiresAt,
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/invites");
|
||||
return { success: true, data: { code } };
|
||||
}
|
||||
|
||||
export async function createBulkInviteCodes(input: {
|
||||
count: number;
|
||||
maxUses: number;
|
||||
expiresInDays: number | null;
|
||||
}): Promise<ActionResult<{ codes: string[] }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
if (input.count < 1 || input.count > 25) {
|
||||
return { success: false, error: "Can generate between 1 and 25 codes at a time" };
|
||||
}
|
||||
|
||||
const expiresAt = input.expiresInDays
|
||||
? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const codes: string[] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < input.count; i++) {
|
||||
const code = crypto.randomBytes(6).toString("hex");
|
||||
codes.push(code);
|
||||
await tx.inviteCode.create({
|
||||
data: {
|
||||
code,
|
||||
maxUses: input.maxUses,
|
||||
expiresAt,
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/invites");
|
||||
return { success: true, data: { codes } };
|
||||
}
|
||||
|
||||
export async function deleteInviteCode(id: string): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
await prisma.inviteCode.delete({ where: { id } });
|
||||
|
||||
revalidatePath("/invites");
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
||||
export async function getInviteCodes() {
|
||||
const codes = await prisma.inviteCode.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
creator: { select: { name: true } },
|
||||
usedBy: { select: { id: true, name: true, email: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
return codes;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { getInviteCodes } from "./actions";
|
||||
import { InviteManager } from "./_components/invite-manager";
|
||||
|
||||
export default async function InvitesPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
if (session.user.role !== "ADMIN") redirect("/dashboard");
|
||||
|
||||
const inviteCodes = await getInviteCodes();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Invite Codes"
|
||||
description="Manage invite codes for new user registration"
|
||||
/>
|
||||
<InviteManager
|
||||
inviteCodes={JSON.parse(JSON.stringify(inviteCodes))}
|
||||
appUrl={process.env.NEXT_PUBLIC_APP_URL ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2, ExternalLink, Link2, Send } from "lucide-react";
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export interface KickstarterRow {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string | null;
|
||||
filesUrl: string | null;
|
||||
deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED";
|
||||
paymentStatus: "PAID" | "UNPAID";
|
||||
notes: string | null;
|
||||
hostId: string | null;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
host: { id: string; name: string } | null;
|
||||
_count: { packages: number };
|
||||
}
|
||||
|
||||
interface KickstarterColumnsProps {
|
||||
onEdit: (kickstarter: KickstarterRow) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onLinkPackages: (kickstarter: KickstarterRow) => void;
|
||||
onSendAll: (kickstarter: KickstarterRow) => void;
|
||||
}
|
||||
|
||||
const deliveryConfig: Record<string, { label: string; className: string }> = {
|
||||
NOT_DELIVERED: {
|
||||
label: "Not Delivered",
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
PARTIAL: {
|
||||
label: "Partial",
|
||||
className: "bg-orange-500/15 text-orange-400 border-orange-500/30",
|
||||
},
|
||||
DELIVERED: {
|
||||
label: "Delivered",
|
||||
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
const paymentConfig: Record<string, { label: string; className: string }> = {
|
||||
PAID: {
|
||||
label: "Paid",
|
||||
className: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
},
|
||||
UNPAID: {
|
||||
label: "Unpaid",
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
export function getKickstarterColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onLinkPackages,
|
||||
onSendAll,
|
||||
}: KickstarterColumnsProps): ColumnDef<KickstarterRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
{row.original.link && (
|
||||
<a
|
||||
href={row.original.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Host" />,
|
||||
cell: ({ row }) =>
|
||||
row.original.host ? (
|
||||
<span className="text-sm">{row.original.host.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">--</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
header: "Files",
|
||||
cell: ({ row }) =>
|
||||
row.original.filesUrl ? (
|
||||
<a
|
||||
href={row.original.filesUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">--</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "deliveryStatus",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Delivery" />,
|
||||
cell: ({ row }) => {
|
||||
const config = deliveryConfig[row.original.deliveryStatus];
|
||||
return (
|
||||
<Badge variant="outline" className={`text-[10px] font-medium ${config.className}`}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "paymentStatus",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Payment" />,
|
||||
cell: ({ row }) => {
|
||||
const config = paymentConfig[row.original.paymentStatus];
|
||||
return (
|
||||
<Badge variant="outline" className={`text-[10px] font-medium ${config.className}`}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "packages",
|
||||
header: "Packages",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original._count.packages}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onLinkPackages(row.original)}>
|
||||
<Link2 className="mr-2 h-3.5 w-3.5" />
|
||||
Link Packages
|
||||
</DropdownMenuItem>
|
||||
{row.original._count.packages > 0 && (
|
||||
<DropdownMenuItem onClick={() => onSendAll(row.original)}>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Send All ({row.original._count.packages})
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import { Plus } from "lucide-react";
|
||||
import { kickstarterSchema, type KickstarterInput } from "@/schemas/kickstarter.schema";
|
||||
import { createKickstarter, updateKickstarter, createHost } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface HostOption {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { kickstarters: number };
|
||||
}
|
||||
|
||||
interface KickstarterFormProps {
|
||||
kickstarter?: {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string | null;
|
||||
filesUrl: string | null;
|
||||
deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED";
|
||||
paymentStatus: "PAID" | "UNPAID";
|
||||
hostId: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
hosts: HostOption[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function KickstarterForm({ kickstarter, hosts, onSuccess }: KickstarterFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [hostList, setHostList] = useState(hosts);
|
||||
const [showNewHost, setShowNewHost] = useState(false);
|
||||
const [newHostName, setNewHostName] = useState("");
|
||||
const isEditing = !!kickstarter;
|
||||
|
||||
const form = useForm<KickstarterInput>({
|
||||
resolver: zodResolver(kickstarterSchema),
|
||||
defaultValues: {
|
||||
name: kickstarter?.name ?? "",
|
||||
link: kickstarter?.link ?? "",
|
||||
filesUrl: kickstarter?.filesUrl ?? "",
|
||||
deliveryStatus: kickstarter?.deliveryStatus ?? "NOT_DELIVERED",
|
||||
paymentStatus: kickstarter?.paymentStatus ?? "UNPAID",
|
||||
hostId: kickstarter?.hostId ?? "",
|
||||
notes: kickstarter?.notes ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: KickstarterInput) {
|
||||
startTransition(async () => {
|
||||
const result = isEditing
|
||||
? await updateKickstarter(kickstarter!.id, values)
|
||||
: await createKickstarter(values);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(isEditing ? "Kickstarter updated" : "Kickstarter created");
|
||||
form.reset();
|
||||
onSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddHost() {
|
||||
if (!newHostName.trim()) return;
|
||||
startTransition(async () => {
|
||||
const result = await createHost({ name: newHostName.trim() });
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success(`Host "${result.data!.name}" created`);
|
||||
setHostList((prev) => [
|
||||
...prev,
|
||||
{ id: result.data!.id, name: result.data!.name, _count: { kickstarters: 0 } },
|
||||
]);
|
||||
form.setValue("hostId", result.data!.id);
|
||||
setNewHostName("");
|
||||
setShowNewHost(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Kickstarter name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="link"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Link</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://kickstarter.com/..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="filesUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Files URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://drive.google.com/..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="deliveryStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delivery Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="NOT_DELIVERED">Not Delivered</SelectItem>
|
||||
<SelectItem value="PARTIAL">Partial</SelectItem>
|
||||
<SelectItem value="DELIVERED">Delivered</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paymentStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Payment Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
<SelectItem value="UNPAID">Unpaid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
{!showNewHost ? (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={(v) => field.onChange(v === "none" ? "" : v)}
|
||||
defaultValue={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select host (optional)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No Host</SelectItem>
|
||||
{hostList.map((host) => (
|
||||
<SelectItem key={host.id} value={host.id}>
|
||||
{host.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowNewHost(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New host name"
|
||||
value={newHostName}
|
||||
onChange={(e) => setNewHostName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddHost();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setShowNewHost(false);
|
||||
setNewHostName("");
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddHost}
|
||||
disabled={isPending || !newHostName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowNewHost(false);
|
||||
setNewHostName("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Optional notes" rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { KickstarterForm } from "./kickstarter-form";
|
||||
|
||||
interface HostOption {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { kickstarters: number };
|
||||
}
|
||||
|
||||
interface KickstarterModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
hosts: HostOption[];
|
||||
kickstarter?: {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string | null;
|
||||
filesUrl: string | null;
|
||||
deliveryStatus: "NOT_DELIVERED" | "PARTIAL" | "DELIVERED";
|
||||
paymentStatus: "PAID" | "UNPAID";
|
||||
hostId: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function KickstarterModal({ open, onOpenChange, hosts, kickstarter }: KickstarterModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{kickstarter ? "Edit Kickstarter" : "Add Kickstarter"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kickstarter
|
||||
? "Update the kickstarter details below."
|
||||
: "Track a new Kickstarter or crowdfunding campaign."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<KickstarterForm
|
||||
kickstarter={kickstarter}
|
||||
hosts={hosts}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { getKickstarterColumns, type KickstarterRow } from "./kickstarter-columns";
|
||||
import { KickstarterModal } from "./kickstarter-modal";
|
||||
import { PackageLinkerDialog } from "./package-linker-dialog";
|
||||
import { deleteKickstarter, sendAllKickstarterPackages } from "../actions";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface HostOption {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { kickstarters: number };
|
||||
}
|
||||
|
||||
interface KickstarterTableProps {
|
||||
data: KickstarterRow[];
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
hosts: HostOption[];
|
||||
}
|
||||
|
||||
export function KickstarterTable({
|
||||
data,
|
||||
pageCount,
|
||||
totalCount,
|
||||
hosts,
|
||||
}: KickstarterTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editKickstarter, setEditKickstarter] = useState<KickstarterRow | undefined>();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [linkTarget, setLinkTarget] = useState<KickstarterRow | null>(null);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
|
||||
|
||||
const updateSearch = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set("search", value);
|
||||
params.set("page", "1");
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value && value !== "all") {
|
||||
params.set(key, value);
|
||||
params.set("page", "1");
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const columns = getKickstarterColumns({
|
||||
onEdit: (kickstarter) => {
|
||||
setEditKickstarter(kickstarter);
|
||||
setModalOpen(true);
|
||||
},
|
||||
onDelete: (id) => setDeleteId(id),
|
||||
onLinkPackages: (kickstarter) => setLinkTarget(kickstarter),
|
||||
onSendAll: (kickstarter) => {
|
||||
startTransition(async () => {
|
||||
const result = await sendAllKickstarterPackages(kickstarter.id);
|
||||
if (result.success) {
|
||||
toast.success(`Queued ${result.data!.queued} package(s) for delivery`);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { table } = useDataTable({ data, columns, pageCount });
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteId) return;
|
||||
startTransition(async () => {
|
||||
const result = await deleteKickstarter(deleteId);
|
||||
if (result.success) {
|
||||
toast.success("Kickstarter deleted");
|
||||
setDeleteId(null);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const activeDelivery = searchParams.get("delivery") ?? "";
|
||||
const activePayment = searchParams.get("payment") ?? "";
|
||||
const activeHost = searchParams.get("host") ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Kickstarters" description="Track your crowdfunding campaigns and deliveries">
|
||||
<Button onClick={() => { setEditKickstarter(undefined); setModalOpen(true); }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Kickstarter
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search kickstarters..."
|
||||
value={searchValue}
|
||||
onChange={(e) => updateSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={activeDelivery || "all"} onValueChange={(v) => updateFilter("delivery", v)}>
|
||||
<SelectTrigger className="w-[160px] h-9">
|
||||
<SelectValue placeholder="All Delivery" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Delivery</SelectItem>
|
||||
<SelectItem value="NOT_DELIVERED">Not Delivered</SelectItem>
|
||||
<SelectItem value="PARTIAL">Partial</SelectItem>
|
||||
<SelectItem value="DELIVERED">Delivered</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activePayment || "all"} onValueChange={(v) => updateFilter("payment", v)}>
|
||||
<SelectTrigger className="w-[140px] h-9">
|
||||
<SelectValue placeholder="All Payment" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Payment</SelectItem>
|
||||
<SelectItem value="PAID">Paid</SelectItem>
|
||||
<SelectItem value="UNPAID">Unpaid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hosts.length > 0 && (
|
||||
<Select value={activeHost || "all"} onValueChange={(v) => updateFilter("host", v)}>
|
||||
<SelectTrigger className="w-[160px] h-9">
|
||||
<SelectValue placeholder="All Hosts" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Hosts</SelectItem>
|
||||
{hosts.map((host) => (
|
||||
<SelectItem key={host.id} value={host.id}>
|
||||
{host.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
|
||||
<DataTable table={table} emptyMessage="No kickstarters found. Add your first campaign!" />
|
||||
<DataTablePagination table={table} totalCount={totalCount} />
|
||||
|
||||
<KickstarterModal
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) setEditKickstarter(undefined);
|
||||
}}
|
||||
hosts={hosts}
|
||||
kickstarter={editKickstarter}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(open) => !open && setDeleteId(null)}
|
||||
title="Delete Kickstarter"
|
||||
description="This will permanently delete this kickstarter and unlink any associated packages."
|
||||
onConfirm={handleDelete}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
|
||||
{linkTarget && (
|
||||
<PackageLinkerDialog
|
||||
open={!!linkTarget}
|
||||
onOpenChange={(open) => !open && setLinkTarget(null)}
|
||||
kickstarterId={linkTarget.id}
|
||||
kickstarterName={linkTarget.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useCallback, useEffect } from "react";
|
||||
import { Search, Package, X, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { linkPackages } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface PackageResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
archiveType: string;
|
||||
creator: string | null;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
interface PackageLinkerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kickstarterId: string;
|
||||
kickstarterName: string;
|
||||
}
|
||||
|
||||
function formatSize(bytes: string | number): string {
|
||||
const b = Number(bytes);
|
||||
if (b >= 1024 * 1024 * 1024) return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(0)} MB`;
|
||||
return `${(b / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
export function PackageLinkerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
kickstarterId,
|
||||
kickstarterName,
|
||||
}: PackageLinkerDialogProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<PackageResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Fetch currently linked packages when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
fetch(`/api/packages/linked?kickstarterId=${kickstarterId}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.packageIds) {
|
||||
setSelectedIds(new Set(data.packageIds));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [open, kickstarterId]);
|
||||
|
||||
const doSearch = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const res = await fetch(`/api/packages/search?q=${encodeURIComponent(query)}&limit=20`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchResults(data.packages ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Ignore search errors
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => doSearch(searchQuery), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, doSearch]);
|
||||
|
||||
function togglePackage(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
startTransition(async () => {
|
||||
const result = await linkPackages(kickstarterId, Array.from(selectedIds));
|
||||
if (result.success) {
|
||||
toast.success(`Linked ${selectedIds.size} package(s) to "${kickstarterName}"`);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link Packages</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search and select STL packages to link to “{kickstarterName}”.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Package className="h-4 w-4" />
|
||||
{selectedIds.size} package(s) selected
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search packages by name or creator..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-2.5 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px] rounded-md border">
|
||||
<div className="p-2 space-y-1">
|
||||
{searchResults.length === 0 && searchQuery.length >= 2 && !isSearching && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No packages found
|
||||
</p>
|
||||
)}
|
||||
{searchQuery.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
)}
|
||||
{searchResults.map((pkg) => (
|
||||
<label
|
||||
key={pkg.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(pkg.id)}
|
||||
onCheckedChange={() => togglePackage(pkg.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{pkg.fileName}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{pkg.creator && <span>{pkg.creator}</span>}
|
||||
<span>{formatSize(pkg.fileSize)}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1">
|
||||
{pkg.archiveType}
|
||||
</Badge>
|
||||
{pkg.fileCount > 0 && <span>{pkg.fileCount} files</span>}
|
||||
</div>
|
||||
</div>
|
||||
{selectedIds.has(pkg.id) && (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isPending}>
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : null}
|
||||
Save ({selectedIds.size})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { kickstarterSchema, kickstarterHostSchema } from "@/schemas/kickstarter.schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ActionResult } from "@/types/api.types";
|
||||
|
||||
const REVALIDATE_PATH = "/kickstarters";
|
||||
|
||||
export async function createKickstarter(
|
||||
input: unknown
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const parsed = kickstarterSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
try {
|
||||
const ks = await prisma.kickstarter.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
link: parsed.data.link || null,
|
||||
filesUrl: parsed.data.filesUrl || null,
|
||||
deliveryStatus: parsed.data.deliveryStatus,
|
||||
paymentStatus: parsed.data.paymentStatus,
|
||||
hostId: parsed.data.hostId || null,
|
||||
notes: parsed.data.notes || null,
|
||||
userId: session.user.id,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { id: ks.id } };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to create kickstarter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateKickstarter(
|
||||
id: string,
|
||||
input: unknown
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const parsed = kickstarterSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
const existing = await prisma.kickstarter.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Not found" };
|
||||
|
||||
try {
|
||||
await prisma.kickstarter.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
link: parsed.data.link || null,
|
||||
filesUrl: parsed.data.filesUrl || null,
|
||||
deliveryStatus: parsed.data.deliveryStatus,
|
||||
paymentStatus: parsed.data.paymentStatus,
|
||||
hostId: parsed.data.hostId || null,
|
||||
notes: parsed.data.notes || null,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update kickstarter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteKickstarter(id: string): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const existing = await prisma.kickstarter.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Not found" };
|
||||
|
||||
try {
|
||||
await prisma.kickstarter.delete({ where: { id } });
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to delete kickstarter" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHost(
|
||||
input: unknown
|
||||
): Promise<ActionResult<{ id: string; name: string }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const parsed = kickstarterHostSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
try {
|
||||
const host = await prisma.kickstarterHost.create({
|
||||
data: { name: parsed.data.name },
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { id: host.id, name: host.name } };
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Unique constraint")
|
||||
) {
|
||||
return { success: false, error: "A host with that name already exists" };
|
||||
}
|
||||
return { success: false, error: "Failed to create host" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkPackages(
|
||||
kickstarterId: string,
|
||||
packageIds: string[]
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const existing = await prisma.kickstarter.findFirst({
|
||||
where: { id: kickstarterId, userId: session.user.id },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Not found" };
|
||||
|
||||
try {
|
||||
// Replace all linked packages
|
||||
await prisma.$transaction([
|
||||
prisma.kickstarterPackage.deleteMany({
|
||||
where: { kickstarterId },
|
||||
}),
|
||||
...packageIds.map((packageId) =>
|
||||
prisma.kickstarterPackage.create({
|
||||
data: { kickstarterId, packageId },
|
||||
})
|
||||
),
|
||||
]);
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to link packages" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAllKickstarterPackages(
|
||||
kickstarterId: string
|
||||
): Promise<ActionResult<{ queued: number }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
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 kickstarter = await prisma.kickstarter.findFirst({
|
||||
where: { id: kickstarterId, userId: session.user.id },
|
||||
select: {
|
||||
packages: {
|
||||
select: {
|
||||
package: {
|
||||
select: { id: true, destChannelId: true, destMessageId: true, fileName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!kickstarter) {
|
||||
return { success: false, error: "Kickstarter not found" };
|
||||
}
|
||||
|
||||
const sendablePackages = kickstarter.packages
|
||||
.map((lnk) => lnk.package)
|
||||
.filter((p) => p.destChannelId && p.destMessageId);
|
||||
|
||||
if (sendablePackages.length === 0) {
|
||||
return { success: false, error: "No linked packages are available for sending" };
|
||||
}
|
||||
|
||||
let queued = 0;
|
||||
for (const pkg of sendablePackages) {
|
||||
const existing = await prisma.botSendRequest.findFirst({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
telegramLinkId: telegramLink.id,
|
||||
status: { in: ["PENDING", "SENDING"] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const sendRequest = await prisma.botSendRequest.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
telegramLinkId: telegramLink.id,
|
||||
requestedByUserId: session.user.id,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('bot_send', $1)`,
|
||||
sendRequest.id
|
||||
);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { queued } };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to send packages" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getKickstarters, getKickstarterHosts } from "@/data/kickstarter.queries";
|
||||
import type { DataTableSearchParams } from "@/types/table.types";
|
||||
import { KickstarterTable } from "./_components/kickstarter-table";
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<DataTableSearchParams & { delivery?: string; payment?: string; host?: string }>;
|
||||
}
|
||||
|
||||
export default async function KickstartersPage({ searchParams }: Props) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const params = await searchParams;
|
||||
const [{ data, pageCount, totalCount }, hosts] = await Promise.all([
|
||||
getKickstarters(session.user.id, params),
|
||||
getKickstarterHosts(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<KickstarterTable
|
||||
data={data}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
hosts={hosts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
// Guard against a stale JWT session whose user no longer exists in the
|
||||
// database (e.g. after a DB reset). The signed cookie still passes edge
|
||||
// middleware, but every downstream query keyed on session.user.id would fail.
|
||||
// Send such sessions to /logout, which clears the cookie and returns to login.
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) redirect("/logout");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<div className="hidden lg:block">
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useTransition } from "react";
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
ImageOff,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { setPreviewFromExtract } from "../actions";
|
||||
import { ImageLightbox } from "./image-lightbox";
|
||||
|
||||
interface ArchiveImage {
|
||||
id: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
extension: string | null;
|
||||
size: string;
|
||||
}
|
||||
|
||||
interface ThumbnailState {
|
||||
status: "idle" | "loading" | "loaded" | "failed";
|
||||
requestId?: string;
|
||||
imageUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ArchivePreviewPickerProps {
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPreviewSet?: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
const bytes = Number(bytesStr);
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function ArchivePreviewPicker({
|
||||
packageId,
|
||||
packageName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onPreviewSet,
|
||||
}: ArchivePreviewPickerProps) {
|
||||
const [images, setImages] = useState<ArchiveImage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
||||
// Track which paths have already been requested to avoid re-requesting
|
||||
const requestedPaths = useRef<Set<string>>(new Set());
|
||||
|
||||
// Cleanup poll timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of pollTimers.current.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch image list when opened
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setImages([]);
|
||||
setThumbnails(new Map());
|
||||
setSelectedPath(null);
|
||||
requestedPaths.current.clear();
|
||||
|
||||
// Clear any leftover poll timers
|
||||
for (const timer of pollTimers.current.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
pollTimers.current.clear();
|
||||
|
||||
const fetchImages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/zips/${packageId}/images`);
|
||||
if (!res.ok) throw new Error("Failed to fetch images");
|
||||
const data = await res.json();
|
||||
setImages(data.images);
|
||||
} catch {
|
||||
toast.error("Failed to load archive images");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImages();
|
||||
}, [open, packageId]);
|
||||
|
||||
// Poll callback for a specific request
|
||||
const startPolling = useCallback(
|
||||
(filePath: string, requestId: string) => {
|
||||
// Clear any existing poll for this path
|
||||
const existing = pollTimers.current.get(filePath);
|
||||
if (existing) clearInterval(existing);
|
||||
|
||||
const pollId = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`/api/zips/${packageId}/extract/${requestId}`
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
const pollData = await pollRes.json();
|
||||
|
||||
if (pollData.status === "COMPLETED") {
|
||||
clearInterval(pollId);
|
||||
pollTimers.current.delete(filePath);
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "loaded",
|
||||
requestId,
|
||||
imageUrl: `/api/zips/${packageId}/extract/${requestId}?image=true`,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} else if (pollData.status === "FAILED") {
|
||||
clearInterval(pollId);
|
||||
pollTimers.current.delete(filePath);
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "failed",
|
||||
error: pollData.error || "Extraction failed",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on network error
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
pollTimers.current.set(filePath, pollId);
|
||||
},
|
||||
[packageId]
|
||||
);
|
||||
|
||||
// Request extraction for a specific image
|
||||
const requestThumbnail = useCallback(
|
||||
async (filePath: string) => {
|
||||
// Don't re-request if already in progress
|
||||
if (requestedPaths.current.has(filePath)) return;
|
||||
requestedPaths.current.add(filePath);
|
||||
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, { status: "loading" });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/zips/${packageId}/extract`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filePath }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Extract failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "COMPLETED") {
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "loaded",
|
||||
requestId: data.requestId,
|
||||
imageUrl: `/api/zips/${packageId}/extract/${data.requestId}?image=true`,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending or in-progress: start polling
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, { status: "loading", requestId: data.requestId });
|
||||
return next;
|
||||
});
|
||||
|
||||
startPolling(filePath, data.requestId);
|
||||
} catch (err) {
|
||||
requestedPaths.current.delete(filePath);
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Failed to extract",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[packageId, startPolling]
|
||||
);
|
||||
|
||||
// Auto-request thumbnails for the first batch of images
|
||||
useEffect(() => {
|
||||
if (!open || images.length === 0) return;
|
||||
|
||||
// Request the first 12 images automatically
|
||||
const toRequest = images.slice(0, 12);
|
||||
for (const img of toRequest) {
|
||||
requestThumbnail(img.path);
|
||||
}
|
||||
// Only trigger when images list changes, not on every requestThumbnail change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [images, open]);
|
||||
|
||||
// Handle selection confirmation
|
||||
const handleConfirm = () => {
|
||||
if (!selectedPath) return;
|
||||
const thumbState = thumbnails.get(selectedPath);
|
||||
if (!thumbState?.requestId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await setPreviewFromExtract(packageId, thumbState.requestId!);
|
||||
if (result.success) {
|
||||
toast.success("Preview updated from archive image");
|
||||
onOpenChange(false);
|
||||
onPreviewSet?.();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-1">
|
||||
<DialogTitle>Select Preview Image</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Choose an image from the archive to use as the preview for{" "}
|
||||
<span className="font-medium text-foreground">{packageName}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-4">
|
||||
{loading ? (
|
||||
<div className="flex flex-col 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 image list...
|
||||
</span>
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||
<ImageOff className="h-6 w-6 text-muted-foreground/50" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No images found in this archive
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{images.map((img) => {
|
||||
const thumbState = thumbnails.get(img.path);
|
||||
const isSelected = selectedPath === img.path;
|
||||
const isLoaded = thumbState?.status === "loaded";
|
||||
const isLoading = thumbState?.status === "loading";
|
||||
const isFailed = thumbState?.status === "failed";
|
||||
|
||||
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
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative aspect-square w-full rounded-lg overflow-hidden border-2 transition-all",
|
||||
"hover:border-primary/50 cursor-pointer",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-border",
|
||||
isFailed && "opacity-60"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isLoaded) {
|
||||
setSelectedPath(img.path);
|
||||
} else if (isFailed) {
|
||||
// Allow retry on failed
|
||||
requestedPaths.current.delete(img.path);
|
||||
requestThumbnail(img.path);
|
||||
} else if (!thumbState || thumbState.status === "idle") {
|
||||
requestThumbnail(img.path);
|
||||
}
|
||||
}}
|
||||
title={img.path}
|
||||
>
|
||||
{isLoaded && thumbState.imageUrl ? (
|
||||
<img
|
||||
src={thumbState.imageUrl}
|
||||
alt={img.fileName}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : isFailed ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center bg-muted gap-1">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-[10px] text-destructive px-1 text-center">
|
||||
Click to retry
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted">
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection checkmark */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File info overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">
|
||||
{img.fileName}
|
||||
</p>
|
||||
<p className="text-[9px] text-white/70">
|
||||
{formatBytes(img.size)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
{images.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{images.length} image{images.length !== 1 ? "s" : ""} found
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!selectedPath || isPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
Setting...
|
||||
</>
|
||||
) : (
|
||||
"Use as Preview"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
<ImageLightbox
|
||||
src={lightboxSrc}
|
||||
open={!!lightboxSrc}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLightboxSrc(null);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user