54 Commits

Author SHA1 Message Date
6178ff3b08 docs: add Task 3 (creator filter combobox) to plan
All checks were successful
continuous-integration/drone/push Build is passing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:42:37 +02:00
57842c6d95 Merge feat/send-all-from-creator: send-all-from-creator button + searchable creator filter 2026-07-04 09:42:08 +02:00
b3c49c3794 feat(stls): add searchable creator filter combobox to toolbar 2026-07-04 09:36:51 +02:00
20e60bc6af feat(stls): add 'send all from creator' toolbar button 2026-07-04 09:26:36 +02:00
50e89719bb feat(stls): add sendAllFromCreatorAction server action 2026-07-04 09:20:36 +02:00
1cae855c26 docs: implementation plan for send-all-from-creator button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:15:58 +02:00
b0baf72f0a docs: spec for send-all-from-creator button on STL page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:14:33 +02:00
0cf5fcd3a7 fix(stls): stop loading preview blobs in list queries (OOM fix)
All checks were successful
continuous-integration/drone/push Build is passing
The STLs list queries selected the full previewData JPEG bytes (avg
~700KB, up to 2MB, and every group member's preview) only to compute a
hasPreview boolean. Under concurrent page loads this piled blobs into
the Node heap, exceeded the 512MB container limit, crashed the process,
and surfaced as repeated connection errors while browsing.

Replace the byte-loading select with a fetchPreviewFlags() helper that
checks `previewData IS NOT NULL` in SQL (IDs only, no bytes). Applied to
listPackages, listDisplayItems, searchPackages, and listUngroupedPackages.

Verified: same concurrent load that drove memory to 508/512MB and forced
a restart now peaks at 156MB (30%) with zero failed requests and no restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:05:34 +02:00
3b7202a662 harden(telegram): reject non-positive topic id in disableActiveTopic
All checks were successful
continuous-integration/drone/push Build is passing
BigInt("") / BigInt("0") don't throw; guard against them since the
action is exported and admin-callable independent of the UI button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:15:03 +02:00
23f8e91c50 feat(telegram): add "skip & disable topic" on the live worker panel
Track the forum topic currently being processed on IngestionRun
(currentTopicId + currentAccountChannelMapId; additive migration) and
expose it on the live status. processArchiveSets gains an optional
shouldStop callback polled before each archive set; the forum branch
passes a live isTopicFetchEnabled check, so disabling a topic mid-run
lets the in-flight file finish, then skips the rest of that topic.

A new disableActiveTopic server action sets the topic's fetchEnabled
false (upsert), and the worker status panel shows a "Skip & disable
this topic" button while a topic is being processed. Future runs skip
the topic via the existing live per-topic read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:10:56 +02:00
c749d03376 Merge fix/topics-drawer-scroll-and-filter: topics drawer scroll + title filter
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-22 09:05:46 +02:00
d7771887a1 fix(worker): silence false-positive integrity-test notifications
The pre-upload integrity test is advisory (never blocks upload), but it
raised a WARNING SystemNotification whenever `7z t` failed — most often
because large 7z archives OOM-kill the test process (SIGKILL / exit 137)
in the memory-limited worker container, which is a tool limitation, not
corruption. Classify failures as encrypted | corrupt | inconclusive;
suppress notifications for inconclusive (debug log only) while still
proceeding with the upload as before. Genuine corruption now uses the
INTEGRITY_AUDIT notification type instead of the misleading HASH_MISMATCH;
encrypted archives still notify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:57:25 +02:00
26bc43299a fix(telegram): make topics drawer scrollable + add topic title filter
The Radix ScrollArea viewport did not get a bounded height inside the
flex-column, max-h, vertically-centred dialog, so a long topic list
overflowed the dialog instead of scrolling (last topics unreachable).
Switch to a native flex-1 min-h-0 overflow-y-auto container, which is
the canonical touch-friendly flex-scroll pattern.

Also add a "Filter topics..." input (matches by topic title) so any
topic can be reached regardless of list length; reset the filter on
close; and add a toast fallback message on toggle failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:50:10 +02:00
8b500a1610 feat(stls): enlarge candidate images in preview picker
All checks were successful
continuous-integration/drone/push Build is passing
Each loaded candidate gets an enlarge button that opens the ImageLightbox
without changing selection, so the right preview is easier to choose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:57:40 +02:00
f0a9d3b4da feat(stls): click-to-enlarge preview in package files drawer
The drawer preview image opens the ImageLightbox; replacing the image
stays available via the existing Upload/Pick Preview buttons. No-preview
upload affordance unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:48:13 +02:00
b90317c007 feat(stls): click-to-enlarge package preview thumbnail in table
Package preview thumbnails open the ImageLightbox on click (hover shows a
maximize affordance). No-preview cells unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:44:52 +02:00
6f8ddcca81 feat(stls): add reusable ImageLightbox for full-size preview viewing
Radix Dialog-based lightbox (Esc / overlay / close button to dismiss),
image shown object-contain capped to the viewport. No new dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:17:42 +02:00
25ff067ea0 feat(telegram): add Topics action to forum channels in Channels tab
Forum source channels get a Topics menu item that opens the TopicsDrawer.
Non-forum channels are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:15:46 +02:00
7e58cc29fa feat(telegram): add TopicsDrawer for per-topic fetch toggles
Lists a channel's topics with an enable/disable Switch each (optimistic,
revert on failure). Empty state explains topics appear after the next scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:24:57 +02:00
5a4e358eee feat(telegram): add setTopicFetchEnabled server action
Admin-guarded toggle of TopicProgress.fetchEnabled by row id. Persists
immediately; the worker honours it on its next live per-topic read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:22:45 +02:00
c31afc5b92 feat(telegram): add GET /api/telegram/channels/[channelId]/topics
Returns per-topic fetch state for a channel, mirroring the existing
account-links route auth pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:19:46 +02:00
6324d64870 feat(telegram): expose isForum on ChannelRow and add listChannelTopics
ChannelRow now carries isForum so the UI can show the Topics action for
forum channels only. listChannelTopics returns per-topic fetch state for
a channel from TopicProgress.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:16:42 +02:00
974769350b feat(worker): honour per-topic fetch toggle live in scan loop
Eager-persist a TopicProgress row for every discovered topic at run
start, and read the live fetchEnabled flag per topic so a mid-run
disable skips topics not yet started. Disabled topics are skipped
before any TDLib scan or fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:13:10 +02:00
f9b82f1654 feat(worker): add ensureTopicProgressRows + isTopicFetchEnabled helpers
ensureTopicProgressRows inserts missing topic rows only (skipDuplicates),
leaving watermarks/scan-state/fetchEnabled untouched. isTopicFetchEnabled
reads the live per-topic flag for mid-run skip decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:27:24 +02:00
7146a5cf0d feat(db): add fetchEnabled flag to TopicProgress
Additive Postgres migration (default true) that backs the per-topic
fetch enable/disable toggle. Existing rows backfill to enabled, so
current behaviour is unchanged. Migration applied to the live DB via
CI prisma migrate deploy (deferred, per environment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:25:21 +02:00
4f6a6f0f75 feat(worker): forum-topic scan-skip + getForumTopic short-circuit
All checks were successful
continuous-integration/drone/push Build is passing
Mirror of the non-forum guards from 1a4bc6f, scoped to forum topics
inside the topic loop:

  - Top-of-topic-loop recency/backoff skip
  - getForumTopic short-circuit after the SkippedPackage retry pass
  - upsertTopicScanState for end-of-scan persistence (both the
    archives-found path and the no-archives path)

Same trulyIdle definition throughout: no archives this scan, no
failures this scan, no retryable SkippedPackage rows pending. Topics
with chronic failures stay out of backoff because their counter
never increments.

For MPE specifically (1,086 forum topics), per-cycle searchChatMessages
calls drop from ~1,086 to roughly the count of topics with new
activity in the last 5 minutes — typically <50.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:58:44 +02:00
1a4bc6f9f3 feat(worker): non-forum channel-scan-skip + getChat short-circuit
For non-forum channels in runWorkerForAccount, three guards:

  1. Top-of-loop recency/backoff skip — if recently scanned with no
     pending work, or in backoff and not its turn, skip entirely.
     Bypassed when retryable SkippedPackages exist.

  2. After the SkippedPackage retry pass, a getChat short-circuit —
     if TDLib's local cache says the channel's last_message.id <= our
     effective watermark, skip the paginated searchChatMessages.

  3. End-of-scan persists lastScannedAt + lastScanFoundArchives +
     consecutiveEmptyScans via the new upsertChannelScanState helper.
     trulyIdle requires: no archives, no failures, no retryable pending.

scheduler.ts exposes getCurrentCycle() so the backoff "every Nth cycle"
modulo can be applied.

Forum-topic branch lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:56:54 +02:00
c6b23715e8 feat(tdlib): add getChannelLastMessageId / getForumTopicLastMessageId
Both read the server-side last message ID from TDLib's local cache.
Used by the channel-scan-skip guard to short-circuit a paginated
searchChatMessages when last_message.id <= our watermark.

getForumTopic uses forum_topic_id (renamed from message_thread_id in
TDLib 1.8.64, same pattern as searchChatMessages / getForumTopics).

Returns null on any failure so the caller can fall back to scanning —
we'd rather waste a scan than miss new content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:54:53 +02:00
3111d658f8 feat(db): add upsertChannelScanState / upsertTopicScanState helpers
Wraps the existing watermark write with the three new scan-state
columns from the previous commit. Single transaction, sets
lastScannedAt=NOW() server-side. Caller is responsible for computing
the trulyIdle bool and the new consecutiveEmptyScans value
(pre-increment vs reset).

Existing updateLastProcessedMessage / upsertTopicProgress are kept for
callers that don't need the new fields (the SkippedPackage retry pass,
which only adjusts the watermark).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:53:50 +02:00
6652fb8bc4 feat(config): add three scan-skip tuning env vars
WORKER_SKIP_RECENT_SCAN_WINDOW_MS    (default 300000 = 5 min)
  WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD  (default 5 cycles)
  WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH  (default 5)

All optional with safe defaults. Not yet read by any code — the worker
integration lands in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:53:12 +02:00
ff846b8e8e feat(db): add scan-state columns to AccountChannelMap + TopicProgress
Three new fields on each table:
  - lastScannedAt          — when the worker last touched this scope
  - lastScanFoundArchives  — true if last scan had archives OR pending
                             retryables; tracks "work might need revisit"
  - consecutiveEmptyScans  — counter for cold-channel backoff

Schema change only. Worker logic in follow-up commits. Migration is a
metadata-only ALTER (NOT NULL with default) so it runs in ms even on
21k+ Package rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:52:28 +02:00
3be3509151 docs: add implementation plan for channel-scan skip optimization
7-task plan covering schema migration, config knobs, DB + TDLib helpers,
and wiring the skip guards + getChat/getForumTopic short-circuits into
both the forum and non-forum branches of runWorkerForAccount.

Each task ends with a type-check step before its commit so the tree
compiles after every step. Task 7 is manual verification covering
restart safety, failure-retry preservation, and backoff behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:45:46 +02:00
6223c47549 docs: add channel-scan skip optimization design spec
Design for adding lastScannedAt + lastScanFoundArchives +
consecutiveEmptyScans columns to AccountChannelMap and TopicProgress,
plus a getChat / getForumTopicInfo short-circuit before
searchChatMessages.

Goal: on restart and during cold-channel cycles, skip scanning channels
and forum topics that have nothing new. For MPE specifically, drops
the per-cycle API call count from ~1,086 to ~50.

Key safety rule: "truly idle" requires both no new archives AND no
retryable SkippedPackage rows pending. The 901f32f retry pass continues
to run unchanged. Failure retries are never skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:26:36 +02:00
13b261c0c8 fix(worker): make pre-upload integrity test advisory, not a hard gate
Diagnosed from production: main was rejecting almost every 7z file with
exit code 137 — kernel OOM-killing 7z t mid-test. p7zip needs to
decompress into memory to verify CRCs; ~1.5GB+ 7z archives with solid
compression exhaust the container's RAM and get SIGKILL'd.

Plus the multipart ZIP false-positive from yesterday (unzip -t can't
span .zip.001 chunks).

Both failure modes are tool limitations, not actual corruption. But
the integrity test in 04effed was a hard gate that THREW on any
non-success, blocking the upload. Result: dozens of valid archives
downloaded then thrown away over the past 6 hours.

This commit demotes the test from gate → advisory:

  - Failures get logged at warn level with the actual reason
  - A SystemNotification is emitted so the admin sees them in the UI
  - Encrypted archives get a clearer notification title but STILL
    proceed (the existing UI gives the user a way to see what's
    encrypted and decide what to do)
  - Upload proceeds normally — we have hash verification + archive
    metadata parse for the structural integrity signals we actually
    need

Multipart ZIPs are still skipped entirely (they can't be tested at
all without concatenation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:57:12 +02:00
25a6196262 fix(worker): skip integrity test for multipart ZIPs — unzip -t can't span them
All checks were successful
continuous-integration/drone/push Build is passing
Diagnosed from production: main downloaded several 28 GB ZIP sets
(CA 3D STUDIOS 2023-07.zip.001..007, 2023-08.zip.001..006, ...) and
rejected every one of them with:

  "Archive integrity check failed: Command failed:
   unzip -tqq /tmp/zips/.../CA 3D STUDIOS 2023-07.zip.001"

Root cause: the integrity test I added in 04effed passed `uploadPaths[0]`
to the archive tester. For byte-split multipart ZIPs (`.zip.001`,
`.zip.002`, ...), the first chunk isn't a valid ZIP on its own — the
central directory only exists at the END of the assembled archive.
unzip's spanned-ZIP support uses `.z01/.z02/.../.zip` naming, not
`.zip.001/.002`, so even pointing at the assembled-form parts wouldn't
help.

Three correctness changes:

  1. Test runs on `tempPaths[0]` (the original downloaded file) instead
     of `uploadPaths[0]` (which may be byte-split chunks we created).
     For single-file ZIPs we re-split, this still tests the unsplit
     original.

  2. Skip the test entirely when archiveType=ZIP AND tempPaths.length>1
     — these are source multipart ZIPs we can't validate without
     concatenating, and the hash check + central-directory parse we
     already do are sufficient structural signals.

  3. RAR and 7Z multipart still ARE tested — `unrar t` and `7z t` both
     auto-discover sibling parts when pointed at the first one.

This unblocks all multipart-ZIP ingestion for the main account. Hours
of downloaded archives that were being rejected will now pass through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:15:07 +02:00
166dc556c9 fix(worker): match old General-topic progress rows under new TDLib forum_topic_id
All checks were successful
continuous-integration/drone/push Build is passing
After the TDLib 1.8.50 → 1.8.64 upgrade, the worker now correctly
enumerates all forum topics in MPE (1,086 of them) — a huge win. But a
data-shape mismatch was about to bite us: TDLib changed how the
General topic is identified.

  TDLib 1.8.50: info.message_thread_id = 1048576  (magic constant)
  TDLib 1.8.64: info.forum_topic_id    = 1

Existing topic_progress rows for General carry topicId=1048576. The
worker looks up progress via `topicProgressList.find(tp => tp.topicId === topic.topicId)`,
which fails for General under the new TDLib → progress becomes null →
the scan starts from message 0.

For MPE specifically, that means re-scanning all ~378k General-topic
messages. Dedup catches the previously-ingested ones (no double upload),
but it burns hours of bandwidth before the watermark catches up.

Fix: when topicId lookup misses for a topic named "General", fall back
to a name match. The first watermark write after that saves under the
new ID (1), so future runs hit the topicId match directly without the
fallback. The orphaned 1048576 row stays as harmless dead data — we
don't delete it in case a TDLib downgrade or revert ever happens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:43:39 +02:00
e8daabd28d fix(tdlib): handle 1.8.64 renames in searchChatMessages + message reply_to
All checks were successful
continuous-integration/drone/push Build is passing
Audit of every TDLib call site against the live 1.8.64 schema in
node_modules/@prebuilt-tdlib/types/tdlib-types.d.ts surfaced three
additional silent breakages beyond the getForumTopics fix in 106700b.

1. searchChatMessages parameter restructure
   The top-level `message_thread_id` and `saved_messages_topic_id`
   request fields were collapsed into a single tagged-union
   `topic_id: MessageTopic$Input`. Three call sites affected:

   - topics.ts getTopicMessages — was passing message_thread_id, now
     sends topic_id with the messageTopicForum variant carrying
     forum_topic_id. Without this the topic scan returns the whole
     channel (or nothing) instead of just the topic.
   - download.ts getChannelMessages — used to pass message_thread_id: 0;
     just omit the topic_id field entirely for a flat scan.
   - rebuild.ts — same treatment.

2. message.reply_to_message_id replaced with reply_to tagged union
   On incoming messages, the flat `reply_to_message_id` field was
   replaced with `reply_to: MessageReplyTo` (messageReplyToMessage or
   messageReplyToStory). Our reply-chain grouping needs the message-ID
   case.

   Added extractReplyToMessageId() that reads both old and new shapes
   so a transition build or future downgrade still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:45:06 +02:00
106700b13f fix(topics): handle TDLib 1.8.64 renamed forum-topic fields
After the TDLib upgrade in 18a0efb, getForumTopicList returned 0 topics
for every forum channel. Confirmed in production logs:

  "title":"Model Printing Emporium","topicCount":0
  "title":"GB_Butler_Bot2","topicCount":0
  "title":"Darnascus 2 : Flamigos Miniatures","topicCount":0

Cycle results: messagesScanned=0, zipsFound=0 — main account's entire
ingestion pipeline was a no-op because all source channels are forums.

Root cause: TDLib 1.8.64 renamed three fields without bumping the
breaking-change indicator we'd notice:

  Request  offset_message_thread_id           → offset_forum_topic_id
  Response next_offset_message_thread_id      → next_offset_forum_topic_id
  Response topics[].info.message_thread_id    → topics[].info.forum_topic_id

The old field names became no-ops in the new TDLib, so every request
came back with an empty topic list and the "stuck pagination" detection
correctly bailed out.

Fix: send the new field name on the request side, read both old and
new names on the response side (so a future TDLib version change in
either direction stays handled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:18:08 +02:00
04effed825 feat(verify): pre-upload integrity test, post-upload read-back, batched recovery
All checks were successful
continuous-integration/drone/push Build is passing
Three independent verification improvements landing together.

1. Pre-upload archive integrity test (testArchiveIntegrity)
   Before sending an archive to the destination channel, runs the
   appropriate CLI test:
     - unzip -t   for ZIP
     - unrar t    for RAR
     - 7z t       for SEVEN_Z
   Catches truncated downloads, internal CRC errors, bad central
   directories, and password-protected archives BEFORE we burn upload
   bandwidth on a file that can't be extracted. Encrypted archives are
   specifically flagged so the SkippedPackage error message is clear.

2. Post-upload destination read-back
   updateMessageSendSucceeded tells us Telegram accepted the upload,
   but says nothing about whether the destination message actually
   contains the file we sent. After each successful upload, getMessage
   each destMessageId and confirm document.size matches uploadPaths[i]'s
   on-disk size.

   Mismatches don't abort ingestion — they surface as
   HASH_MISMATCH / UPLOAD_FAILED SystemNotifications so the admin can
   see them in the UI and decide whether to recover.

3. Batched recovery (verifyMessagesBatch)
   recoverIncompleteUploads previously called getMessage (singular)
   per Package — at 20k packages that's 20k round-trips. Switched to
   TDLib's getMessages (plural) with batch size 100 → 200 round-trips.
   On 20k packages this is ~100x faster.

   Per-message fallback if a whole batch errors out, so one bad batch
   never loses all verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:56:50 +02:00
c4d9be83bd feat(worker): auto-tag packages with the slicer(s) their files target
Indexes 86k+ Lychee Slicer (.lys/.lyt), 23k+ ChituBox (.chitubox/.ctb/
.cbddlp), 1k+ Anycubic (.photon/.pwmo/.pwmx), and Bambu (.3mf)
slicer-specific files. Until now they were just generic extensions in
PackageFile.

After this commit:
  - Newly-ingested packages get tags derived from their file list
    ("lychee", "chitubox", "anycubic", "bambu", "fdm", "mango")
  - The `backfill_filelists` listener also applies tags to re-indexed
    packages
  - A new pure-DB listener `backfill_slicer_tags` walks existing
    Packages with file lists and applies tags retroactively — no
    downloads, no TDLib, takes seconds for thousands of rows.

Trigger the one-shot retroactive backfill with:
  SELECT pg_notify('backfill_slicer_tags', '{"limit":5000}');

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:53:18 +02:00
7d39a13310 feat(worker): use TDLib remote.unique_id as zero-false-positive dedup signal
The fileName + size repost detection from ff4e150 works but has a
theoretical false-positive: two unrelated files in the same channel
with identical names and identical total sizes get treated as duplicates.

TDLib's document.remote.unique_id is a stable identifier per file
content — every repost of the exact same file across messages keeps
the same unique_id. Using it as the first dedup check eliminates the
false-positive risk entirely.

Schema:
  - Package.remoteUniqueId (nullable, since existing rows lack it)
  - Index on (sourceChannelId, remoteUniqueId)

Pipeline:
  1. Capture remoteUniqueId in getChannelMessages + getTopicMessages
  2. Pass through TelegramMessage type
  3. processOneArchiveSet checks findPackageByRemoteUniqueId FIRST
     (before packageExistsBySourceMessage / findRepostedPackage)
  4. createPackageStub stores it on the new Package row

Existing 19,952 Packages have remoteUniqueId = NULL — they fall through
to the existing checks (source-msg-id, name+size, content-hash). New
ingestions populate it and benefit from the strong signal immediately.
Old Packages get backfilled organically when their content is
re-encountered and a new Package would otherwise be created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:24 +02:00
18a0efb3d4 chore(tdlib): upgrade tdl 8.0.0 → 8.1.0 and prebuilt-tdlib 1.8.50 → 1.8.64
12 versions of TDLib bug fixes, performance improvements, and stricter
type definitions in @prebuilt-tdlib/types.

Two API breakages handled:

1. `getChatFolders` (plural) was removed — folder IDs now arrive via
   the `updateChatFolders` update event. Replaced the synchronous call
   with a 200ms event listener; if no folders arrive, we proceed with
   just main + archive lists. Chats inside folders are still reachable
   from chatListMain so this isn't a functional regression.

2. The new tdl `Client.invoke` signature requires a literal `_` field
   and rejects `Record<string, any>` shapes. Our `invokeWithTimeout`
   wrapper is intentionally generic — cast through `any` at the call
   site with a comment explaining why.

Both worker and bot type-check + build cleanly with the new versions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:45:44 +02:00
2ccc9820cd fix(recovery): distinguish 'message gone' from 'TDLib couldn't tell us'
The old verifyMessageExists returned a bare boolean. Any error other
than HTTP 404 was treated as "exists" — meaning a TDLib connection
problem or transient TG hiccup at recovery time caused the worker to
declare "all destination messages verified" when it had actually
verified nothing.

Replaced with a discriminated VerifyResult:
  - exists         — message present and is a document, keep Package
  - deleted        — TG confirms it's gone (404 / MESSAGE_ID_INVALID /
                     "Message not found"), reset Package for re-upload
  - wrong-content  — message exists but isn't messageDocument, reset
  - unknown        — TDLib threw a non-404 error; do NOT reset, retry
                     next startup

Recovery summary now reports all four counts and switches to a
non-success message when unknownCount > 0, so a degraded TDLib run
doesn't hide behind a green log line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:43:09 +02:00
c72b5a4b48 feat(worker): add backfill_filelists pg_notify listener
Companion to 0bdd4ba (RAR parser fix). 4,380 RAR packages and ~450
ZIP/7Z packages in the DB have fileCount=0 because of the old broken
parser (and a handful of edge cases). This adds an on-demand backfill
that re-indexes their file lists.

Triggered by:
  SELECT pg_notify('backfill_filelists', '{"limit":50,"archiveType":"RAR"}');

Both payload fields are optional. archiveType filters to ZIP/RAR/SEVEN_Z;
default limit is 100. Multiple notifications queue sequentially so
TDLib downloads don't compete for the per-account mutex.

For each candidate:
  1. Resolve destChannel.telegramId from the Package
  2. getMessage for each destMessageId in destMessageIds[] (handles
     multipart) to recover the file_id from Telegram
  3. downloadFile (uses TDLib cache when available — most are fast)
  4. Run readZipCentralDirectory / readRarContents / read7zContents
  5. Transactionally replace PackageFile rows + update fileCount

Re-check of fileCount inside the transaction ensures a concurrent
backfill from another worker (or a fresh ingestion of the same archive)
doesn't get clobbered.

Prefers the Premium account when both are linked, for faster downloads
and to avoid the speed-limit throttling on the secondary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:42:01 +02:00
0bdd4ba0cc fix(rar-reader): use unrar lt (technical) so file listings actually work
Diagnosed from production: all 4,380 RAR packages in the database have
fileCount = 0. The old parser used \`unrar l -v\` and a regex that
expected an 8-column \`Attributes Size Packed Ratio% Date Time CRC32 Name\`
output. unrar 6.21's actual \`l -v\` output is 5 columns: \`Attributes
Size Date Time Name\` — no Packed, no Ratio, no CRC32. So every RAR
silently parsed to zero entries.

Switch to \`unrar lt\` (list technical), which emits one block per file
with key:value lines:

         Name: Lost Kingdom 2023 01 January/Nagas/NagaCaptainBody.stl
         Type: File
         Size: 22503584
  Packed size: 21430123
         CRC32: A1B2C3D4
         ...

The new parser tokenizes blocks on blank lines and matches "key: value"
lines per block. Handles multi-word keys ("Packed size", "Host OS") and
gracefully skips Directory entries and the archive header block. Also
tolerates BLAKE2sp checksums for newer RAR archives.

Verified against a live 644MB RAR with 201 entries (194 files, 7 dirs);
parser returns 194 entries with correct paths, sizes, and CRC32s.

Future RAR ingestions will populate fileCount and PackageFile rows
correctly. Backfilling existing 4,380 packages requires a separate
pass — added in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:38:46 +02:00
901f32ff41 feat(worker): retry old SkippedPackages + prefer specific topics over General
Three connected safeguards driven by user feedback after deploying the
incremental watermark and repost-detection fixes.

1. SkippedPackage retry pass (watermark pull-back)
   The auto-retry chain (d99a506 + watermark cap) only works for failures
   that occur AFTER the fix is deployed. Pre-existing SkippedPackages may
   sit below the current watermark — example from prod: secondary's
   "Turnbase Delivery Folder.7z" at msgId 37,109,104,640 vs watermark
   37,111,201,792. The auto-retry never sees it.

   Before scanning each channel/topic, we now query SkippedPackages with
   attemptCount < cap for that scope and pull the watermark back to
   (lowestSkippedMsgId - 1n) when needed. Both forum and non-forum
   branches handle this.

2. Topic scan order: specific topics first, General last
   In forum channels, files often appear in both a specific topic (e.g.,
   "Artisan Guild January 2022") AND in General. The first encounter
   created the Package and locked in the topic context. If we happened
   to scan General first, the Package recorded the less-informative
   topic.

   We now sort topics so General is processed last. New Packages get
   the more specific topic name as their context by default.

3. Backfill specific topic on existing Packages
   For Packages that were already created with General topic context,
   when findRepostedPackage matches and the current scan is in a more
   specific topic, update the existing Package's sourceTopicId (and
   creator, if it was derived from "General") to the more specific one.
   Audit log shows both old and new topic IDs.

The findRepostedPackage query also got an ORDER BY so it returns the
most-specific existing match (non-null sourceTopicId first) when
multiple Packages share the same filename + size in a channel — giving
the audit log richer context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:02:54 +02:00
ff4e150544 fix: skip download when the same file was already uploaded from this channel
Diagnosed from production: in 8 hours of main's current run, zero
uploads happened despite the worker being busy 100% of the time. Logs
showed continuous "Downloading archive part" entries with no
corresponding upload activity.

Root cause: the source channel ("Model Printing Emporium") frequently
reposts the same file at new Telegram message IDs. Concrete example
from the DB:
  - "(EN) PaintGuides All.zip"  → present 6 times, msgIds 44B → 92B
  - "00 Welcome Pack.7z"        → present 2 times, msgIds 91B and 177B
  - "FanteZi April 2022-...zip" → uploaded May 8 at msgId 24,697,110,528;
                                  current run re-downloading at 87,488,987,136

packageExistsBySourceMessage(channelId, msgId) correctly misses because
the msgId is different. We download the (potentially gigabyte-sized)
file, hash it, then packageExistsByHash hits and we discard the
download. ~30 seconds wasted per repost x thousands of reposts = whole
runs spent uploading nothing.

Fix: add findRepostedPackage(sourceChannelId, fileName, fileSize) — a
pre-download check that catches reposts by the strong (channel + name
+ total size) signal. On hit, skip the set entirely. Watermark
advances normally (no minFailedId tracking) so the next cycle sees
the channel as caught up.

False-positive risk: two unrelated files in the same channel with
identical name AND identical total fileSize. Extremely rare in
practice; if it ever happens, the new file is silently treated as a
duplicate. Logged at info level with the existing Package ID and dest
message ID so the user can audit if a file is mysteriously missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:54:20 +02:00
77aeb4cc00 fix: advance channel/topic watermark incrementally per successful set
All checks were successful
continuous-integration/drone/push Build is passing
Diagnosed from production logs for the main (Premium) account:

  RUNNING   2026-05-21 → in progress, 22h     ingested: 0
  FAILED    2026-05-14 → 2026-05-21 (7.4d)    ingested: 5,426 (killed by restart)
  FAILED    2026-05-06 → 2026-05-14 (7.7d)    ingested: 8,300 (killed by restart)

Main's two source channels have 378k+ messages each. A full scan takes
days, but the worker gets restarted (container update, cycle timeout,
etc.) every few days. updateLastProcessedMessage was only called at the
END of a channel's scan — so the watermark on AccountChannelMap stayed
NULL through restart after restart, and every new run re-scanned from
message 0.

That explains the user's symptom: "main wasn't uploading although it
said it did". The dashboard showed currentStep alternating through
downloading / hashing / deduplicating, but zipsIngested stayed at 0
because every archive the run encountered was already a hash-duplicate
of something uploaded by a previous run.

Fix: processArchiveSets now accepts an onWatermarkAdvance callback.
After each successful set (ingested OR confirmed duplicate), the callback
fires with a watermark capped below the current minFailedId. Both call
sites (forum/topic and non-forum) wire it to upsertTopicProgress /
updateLastProcessedMessage. The end-of-scan write is retained for the
no-archives and all-failures-with-fallback cases.

Worst-case progress loss on restart now is one in-flight archive set,
not the entire scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:20:20 +02:00
3b327eb3f3 feat(app): show attempt count column on the Skipped Packages tab
attemptCount goes through SkippedPackageItem and SkippedRow into a new
column on the data table. Badge color cues:
  - outline (1)        first failure, will auto-retry next cycle
  - secondary (2-4)    has retried but still below cap
  - destructive (>=5)  hit the cap; will not auto-retry until reset

The "Skipped" column is renamed to "Last Skipped" since the timestamp
now reflects the most recent attempt, not the first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:08:08 +02:00
379bf246cd feat(worker): per-account safeguards for second-account upload failures
Driven by a real production case: secondary account was attached to 17
source channels but ingesting only ~2-3 archives per cycle. Log analysis
showed three distinct issues that this commit addresses.

1. Auto-retry cap (WORKER_MAX_SKIP_ATTEMPTS, default 5)
   processArchiveSets now filters out SkippedPackage rows whose
   attemptCount has reached the cap. Removing them from the working
   list means they are not tracked in minFailedId, so the watermark
   cap from d99a506 does not pin progress below them anymore. A bad
   file no longer blocks the rest of the channel forever; the user
   can manually retry via the UI to reset the count.

2. Account phone in error messages
   Every SkippedPackage row and SystemNotification produced from a
   failure is now prefixed with [<phone>] in errorMessage / message,
   and the JSON context includes accountPhone. When two accounts
   share a source channel and only one is failing, the UI tells you
   which one.

3. Explicit getChat for destination at run start
   loadChats only loads main/archive/folder chat lists. If an account
   archived or moved the destination chat, sendMessage failed silently
   per-archive. Now we getChat the destination once per cycle; on
   failure we record a SystemNotification and skip the account's
   entire ingestion cycle (no point downloading what we can't upload).

4. Retry on transient Telegram server errors
   The "Turnbase Delivery Folder.7z" failure on the secondary and
   "10. Kingdom of the Depth.part1.rar" on the main were both
   "Internal Server Error during file upload" — a TG-side hiccup, not
   a stall or FLOOD_WAIT. These now retry up to MAX_UPLOAD_RETRIES
   with linear backoff (15s, 30s, 45s + jitter) before giving up.

5. Channel-access-lost notification
   "Iridium 2 w/ Add-ons [Completed]" has been throwing
   "Can't access the chat" every cycle for the secondary. The worker
   now surfaces a CHANNEL_ACCESS_LOST notification (deduped to once per
   24h per channel/account) so the admin sees it and can re-join or
   unlink the channel instead of just losing visibility into the loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:07:57 +02:00
7a79b52baf feat(db): add attemptCount on SkippedPackage + CHANNEL_ACCESS_LOST enum
attemptCount tracks how many times the worker has tried each failed
source message. Combined with WORKER_MAX_SKIP_ATTEMPTS (default 5), the
worker will auto-retry across cycles but eventually let the watermark
advance past a chronically failing file so cycles aren't pinned forever.
The SkippedPackage row stays so the user can manually retry via the UI.

CHANNEL_ACCESS_LOST is a new notification type the worker emits when a
source channel becomes inaccessible (account got removed, channel
deleted, etc.) — surfaces the issue instead of silently failing every
cycle as we've been doing with "Iridium 2 w/ Add-ons [Completed]".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:07:40 +02:00
26e2cba69d fix: buffer upload confirmation events to close tempMsgId race
sendMessage resolves with the temporary message ID inside a .then()
microtask. If TDLib emits updateMessageSendSucceeded synchronously
(cached file, already-known media), the event handler fires while
tempMsgId is still null — the success is dropped and the promise hangs
until the 15-min upload timeout fires.

Buffer success/failure events that arrive before tempMsgId is known,
then replay them in the .then() callback once tempMsgId is set.
Extract completeWithSuccess / completeWithFailure helpers so the
resolution path is shared between live events and replayed events.

This race matters more now that stalls fail fast — without the buffer,
a fast-completing upload could still hang for 15 min before recovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:48:38 +02:00
84cc8d995b fix: fail fast on upload stall instead of retrying on broken client
Previously a single TDLib event-stream degradation cost ~45 minutes
per archive: 3 retries x 15-min minimum timeout, all on the same
broken client. The retries had no chance of succeeding because the
underlying issue (missing updateMessageSendSucceeded events) is a
client-level problem, not a transient send failure.

Now the first stall throws UploadStallError immediately. The caller
in processArchiveSets already recreates the TDLib client on
UploadStallError, so we drop from ~45 min recovery to ~15 min
(one timeout cycle) per stalled archive.

The stalled set is recorded in SkippedPackage; with the watermark
cap from d99a506 it gets retried on the next ingestion cycle with
a fresh client.

FLOOD_WAIT retries inside sendWithRetry are unchanged — those handle
legitimate rate limiting, not stalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:47:08 +02:00
d99a506b10 fix: cap watermark below failed sets so failures retry next cycle
Previously the channel/topic watermark could advance past failed
archive sets in two ways:

1. A later successful set raised maxProcessedId past a failed earlier
   set within the same scan.
2. scanResult.maxScannedMessageId was used as fallback even when
   archives in the scan had failed (added in 77c26ad to prevent
   re-scanning empty channels).

Both paths buried failed archives below the watermark on the next
cycle — they sat permanently in SkippedPackage with no auto-recovery.

Now processArchiveSets returns the lowest failed source message ID
alongside the highest processed one. The caller caps the watermark at
(minFailedId - 1n) so the next scan re-includes the failed messages
and processOneArchiveSet retries them. Successful sets above the
failure boundary are not re-uploaded — packageExistsBySourceMessage
early-skips them on the second pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:46:26 +02:00
48 changed files with 5393 additions and 381 deletions

94
bot/package-lock.json generated
View File

@@ -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"
},

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,454 @@
# Send All From Creator Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a toolbar button on the STL packages page that queues every sendable package from the currently-filtered creator for delivery to the user's Telegram.
**Architecture:** A new server action `sendAllFromCreatorAction(creatorName)` mirrors the existing `sendAllInGroupAction`: it fetches sendable packages for the creator, dedups against live `BotSendRequest`s, creates a `BotSendRequest` per package, and fires `pg_notify('bot_send', id)`. The bot's existing `send-listener` consumes these unchanged. The client table renders a "Send all from [Creator]" button in the packages-tab toolbar only when `?creator=<name>` is active, wired to the action with a confirm dialog and a count toast.
**Tech Stack:** Next.js 16 App Router, TypeScript, Prisma v7, server actions, `pg_notify`, sonner toasts, lucide-react icons.
**Testing note:** This repo has no test framework (`CLAUDE.md`: "Testing is manual"). Verification for each task is `npm run lint` + `npm run build`, plus manual UI checks at the end. Do not scaffold a test harness.
---
### Task 1: Add `sendAllFromCreatorAction` server action
**Files:**
- Modify: `src/app/(app)/stls/actions.ts` (append new action after `sendAllInGroupAction`, which ends at line 591)
Reference implementation to mirror: `sendAllInGroupAction` at `src/app/(app)/stls/actions.ts:515-591`.
Existing imports already present in the file (do not re-add): `auth` from `@/lib/auth`, `prisma` from `@/lib/prisma`, `ActionResult` from `@/types/api.types`, `revalidatePath` from `next/cache`.
`ActionResult` is generic: `ActionResult<T = void>` (`src/types/api.types.ts`).
- [ ] **Step 1: Append the new action**
Add this to the end of `src/app/(app)/stls/actions.ts`:
```ts
export async function sendAllFromCreatorAction(
creatorName: string
): Promise<ActionResult<{ queued: number; skipped: number }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const creator = creatorName.trim();
if (!creator) {
return { success: false, error: "No creator specified" };
}
try {
const telegramLink = await prisma.telegramLink.findUnique({
where: { userId: session.user.id },
});
if (!telegramLink) {
return { success: false, error: "No linked Telegram account. Link one in Settings." };
}
const sendablePackages = await prisma.package.findMany({
where: {
creator,
destChannelId: { not: null },
destMessageId: { not: null },
},
select: { id: true },
});
if (sendablePackages.length === 0) {
return { success: false, error: "No uploaded packages found for this creator" };
}
let queued = 0;
let skipped = 0;
for (const pkg of sendablePackages) {
// Only create if no existing PENDING/SENDING request for this package+link combo
const existing = await prisma.botSendRequest.findFirst({
where: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
status: { in: ["PENDING", "SENDING"] },
},
});
if (existing) {
skipped++;
continue;
}
const sendRequest = await prisma.botSendRequest.create({
data: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
requestedByUserId: session.user.id,
status: "PENDING",
},
});
// Notify the bot via pg_notify
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('bot_send', $1)`,
sendRequest.id
);
} catch {
// Best-effort — the bot also polls periodically
}
queued++;
}
revalidatePath("/stls");
return { success: true, data: { queued, skipped } };
} catch {
return { success: false, error: "Failed to send creator packages" };
}
}
```
- [ ] **Step 2: Verify lint + typecheck pass**
Run: `npm run lint`
Expected: no new errors in `src/app/(app)/stls/actions.ts`.
- [ ] **Step 3: Commit**
```bash
git add src/app/\(app\)/stls/actions.ts
git commit -m "feat(stls): add sendAllFromCreatorAction server action"
```
---
### Task 2: Wire up the toolbar button in the STL table
**Files:**
- Modify: `src/app/(app)/stls/_components/stl-table.tsx`
- lucide import (line 6)
- actions import block (lines 44-54)
- handler (after `handleSendAllInGroup`, which ends at line 278)
- `activeCreator` derivation (near `activeTag` at line 445)
- toolbar JSX (packages-tab toolbar row starting at line 478)
- [ ] **Step 1: Add the `Send` icon to the lucide import**
Change line 6 from:
```ts
import { Search, Layers, Upload } from "lucide-react";
```
to:
```ts
import { Search, Layers, Upload, Send } from "lucide-react";
```
- [ ] **Step 2: Import the new action**
In the actions import block (lines 44-54), add `sendAllFromCreatorAction` next to `sendAllInGroupAction`:
```ts
import {
updatePackageCreator,
updatePackageTags,
renameGroupAction,
dissolveGroupAction,
createGroupAction,
removeFromGroupAction,
sendAllInGroupAction,
sendAllFromCreatorAction,
updateGroupPreviewAction,
mergeGroupsAction,
} from "../actions";
```
- [ ] **Step 3: Derive the active creator from the URL**
Immediately after the `activeTag` line (`src/app/(app)/stls/_components/stl-table.tsx:445`):
```ts
const activeTag = searchParams.get("tag") ?? "";
```
add:
```ts
const activeCreator = searchParams.get("creator") ?? "";
```
- [ ] **Step 4: Add the click handler**
After `handleSendAllInGroup` (which ends at line 278), add:
```ts
const handleSendAllFromCreator = useCallback(() => {
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
startTransition(async () => {
const result = await sendAllFromCreatorAction(activeCreator);
if (result.success) {
const { queued, skipped } = result.data;
toast.success(
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
(skipped ? ` (${skipped} already queued)` : "")
);
router.refresh();
} else {
toast.error(result.error);
}
});
}, [activeCreator, router]);
```
- [ ] **Step 5: Render the toolbar button**
In the packages-tab toolbar (`flex flex-wrap items-center gap-2` row at line 478), add the button right after the "Upload Files" button (which closes at line 507, before the `selectedPackages.size >= 2` block at line 508):
```tsx
{activeCreator && (
<Button
variant="outline"
size="sm"
className="h-9 gap-1.5"
onClick={handleSendAllFromCreator}
>
<Send className="h-3.5 w-3.5" />
Send all from {activeCreator}
</Button>
)}
```
- [ ] **Step 6: Verify lint + build pass**
Run: `npm run lint && npm run build`
Expected: no new errors; build completes.
- [ ] **Step 7: Manual verification**
1. `npm run dev`, open `/stls`.
2. Click a creator name in the Creator column to apply `?creator=<name>` (or navigate to `/stls?creator=<known creator>`).
3. Confirm the "Send all from [Creator]" button appears in the toolbar.
4. Remove the creator filter → confirm the button disappears.
5. Click the button → confirm the dialog appears; on confirm, a toast reports the queued count.
6. (If a linked Telegram account + uploaded packages exist) confirm packages arrive via the bot.
- [ ] **Step 8: Commit**
```bash
git add src/app/\(app\)/stls/_components/stl-table.tsx
git commit -m "feat(stls): add 'send all from creator' toolbar button"
```
---
### Task 3: Searchable creator filter combobox (makes the filter reachable)
**Why:** Discovered during execution — the `?creator=` filter that reveals the Task 2
button had no UI trigger. The creator table cell click only opens the edit prompt
(`package-columns.tsx:339``onSetCreator`). This task adds a searchable
"All Creators" combobox to the packages-tab toolbar (like the existing Tags select,
but type-to-filter since there can be many creators). The creator cell stays
edit-only.
**Files:**
- Modify: `src/lib/telegram/queries.ts` — add `getAllPackageCreators()`.
- Modify: `src/app/(app)/stls/page.tsx` — fetch + pass `availableCreators`.
- Create: `src/app/(app)/stls/_components/creator-filter.tsx` — the combobox.
- Modify: `src/app/(app)/stls/_components/stl-table.tsx` — new prop, handler, render.
- [ ] **Step 1: Add the distinct-creators query**
Append to `src/lib/telegram/queries.ts` (mirrors `getAllPackageTags` at line 530):
```ts
export async function getAllPackageCreators(): Promise<string[]> {
const result = await prisma.$queryRaw<{ creator: string }[]>`
SELECT DISTINCT creator FROM packages
WHERE creator IS NOT NULL AND creator <> ''
ORDER BY creator
`;
return result.map((r) => r.creator);
}
```
- [ ] **Step 2: Create the combobox component**
Create `src/app/(app)/stls/_components/creator-filter.tsx` (mirrors the
Popover+Command pattern in `src/components/shared/data-table-faceted-filter.tsx`):
```tsx
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface CreatorFilterProps {
creators: string[];
value: string; // active creator, "" when none
onChange: (creator: string) => void; // "" clears the filter
}
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className="h-9 w-[200px] justify-between"
>
<span className="truncate">{value || "All Creators"}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search creators..." className="h-9" />
<CommandList>
<CommandEmpty>No creators found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__all__"
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
/>
All Creators
</CommandItem>
{creators.map((creator) => (
<CommandItem
key={creator}
value={creator}
onSelect={() => {
onChange(creator);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === creator ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{creator}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
- [ ] **Step 3: Wire the query through the page**
In `src/app/(app)/stls/page.tsx`:
- Add `getAllPackageCreators` to the import from `@/lib/telegram/queries` (line 3).
- Add `getAllPackageCreators()` to the `Promise.all` (line 27) and destructure
`availableCreators`:
```ts
const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] =
await Promise.all([
// ...existing entries unchanged...
getIngestionStatus(),
getAllPackageTags(),
getAllPackageCreators(),
countSkippedPackages(),
countUngroupedPackages(),
]);
```
(Insert `getAllPackageCreators()` immediately after `getAllPackageTags()`, and add
`availableCreators` in the matching position of the destructure.)
- Pass the prop to `<StlTable>`: `availableCreators={availableCreators}` (next to
`availableTags={availableTags}`).
- [ ] **Step 4: Add prop + handler + render in the table**
In `src/app/(app)/stls/_components/stl-table.tsx`:
- Import the component: `import { CreatorFilter } from "./creator-filter";`
- Add to `StlTableProps` (next to `availableTags: string[];`): `availableCreators: string[];`
- Add to the destructured params (next to `availableTags,`): `availableCreators,`
- Add the handler after `updateTagFilter` (ends ~line 210):
```ts
const updateCreatorFilter = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("creator", value);
params.set("page", "1");
} else {
params.delete("creator");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
```
- Render it in the toolbar right after the Tags `Select` block (the
`{availableTags.length > 0 && (...)}` block, ~lines 507-522):
```tsx
{availableCreators.length > 0 && (
<CreatorFilter
creators={availableCreators}
value={activeCreator}
onChange={updateCreatorFilter}
/>
)}
```
- [ ] **Step 5: Verify + manual test**
Run: `npx tsc --noEmit` (filter for the touched files — expect none new). Note: full
`npm run build` fails on a pre-existing stale Prisma client issue in
`src/lib/telegram/*` unrelated to this change; do not attempt to fix it.
Manual: open `/stls`, use the "All Creators" combobox, type to filter, pick a
creator → list filters and the "Send all from [Creator]" button appears; pick
"All Creators" → filter clears and button disappears.
- [ ] **Step 6: Commit**
```bash
git add "src/lib/telegram/queries.ts" "src/app/(app)/stls/page.tsx" \
"src/app/(app)/stls/_components/creator-filter.tsx" \
"src/app/(app)/stls/_components/stl-table.tsx"
git commit -m "feat(stls): add searchable creator filter combobox to toolbar"
```
---
## Self-Review
**Spec coverage:**
- Req 1 (button when filtered by creator) → Task 2 Step 5 (`{activeCreator && ...}`).
- Req 2 (hidden when no filter) → Task 2 Step 5 conditional.
- Req 3 (confirm dialog) → Task 2 Step 4 `confirm(...)`.
- Req 4 (all pages, not current page) → Task 1 queries `prisma.package.findMany` by `creator`, unpaginated.
- Req 5 (skip not-uploaded + dedup live requests) → Task 1 `where` filter on `destChannelId`/`destMessageId` + `existing` check.
- Req 6 (report counts) → Task 1 returns `{ queued, skipped }`; Task 2 Step 4 toast.
- Req 7 (no polling) → Task 2 handler queues + `router.refresh()`, no poll loop.
- Blank-creator guard → Task 1 `creator.trim()` check.
**Placeholder scan:** No TBD/TODO/placeholder steps; all code shown in full.
**Type consistency:** Action returns `ActionResult<{ queued: number; skipped: number }>`; handler destructures `result.data.{queued,skipped}` inside the `result.success` branch (where `data` is typed). `sendAllFromCreatorAction` name matches between Task 1 definition, Task 2 import, and Task 2 call site. `activeCreator` defined once (Step 3) and used in Steps 4-5.

View File

@@ -0,0 +1,353 @@
# Channel-Scan Skip Optimization — Design
**Goal:** stop the worker from re-scanning channels and forum topics that haven't changed since the last scan, especially on restart. Reduce the per-cycle API call count for the Model Printing Emporium channel (1,086 forum topics) from ~1,000+ to ~50.
**Non-goals:**
- Replacing polling with event-driven ingestion (`updateNewMessage`). That's a separate, larger design (Phase 2 in the original brainstorm).
- Surfacing per-channel scan history in the UI (also a separate, observability-only design).
**Architecture sketch:**
Add three persisted columns to `AccountChannelMap` and `TopicProgress`, plus one runtime `getChat`/`getForumTopicInfo` lookup before each scan. The new state survives restarts because it's in PostgreSQL; the lookup is a cheap TDLib local-cache call. Failure-retry semantics (`d99a506` + `901f32f`) must be preserved — a channel sitting on retryable `SkippedPackage` rows is never considered idle.
---
## Problem statement
### Today's behavior
Every ingestion cycle the worker walks every linked source channel for every authenticated account. For each channel/topic it calls TDLib's `searchChatMessages` paginated from `lastProcessedMessageId`. Even when nothing has changed since the previous scan:
- One `searchChatMessages` call (sometimes paginated) is still made
- For Model Printing Emporium, that's ~1,086 calls per cycle (one per forum topic)
- The 1-second `apiDelayMs` between pages multiplies the cost
- Most calls return zero new messages — the work is wasted
The cost is most acute right after a restart: the worker boots, runs recovery, then issues 1,000+ effectively-empty calls before any productive work happens.
### What we already track
- `AccountChannelMap.lastProcessedMessageId` — highest processed message ID (per non-forum channel, per account)
- `TopicProgress.lastProcessedMessageId` — same per forum topic
- Both are advanced incrementally per archive set (`77aeb4c`)
- Both are pulled back below failed messages by the `SkippedPackage` retry pass (`901f32f`)
### What we don't track and want to add
- When was the last scan?
- Did the last scan find any archives, OR is there outstanding retry work?
- How many cycles in a row have been totally idle?
These let us skip the scan entirely when nothing has changed.
---
## High-level approach
Three guards at the top of the per-channel and per-topic processing loops:
1. **DB-persistent "skip if recently scanned and truly idle"** — checks `lastScannedAt`, `lastScanFoundArchives`, and a `retryableSkippedCount` query. If all three say "nothing new, nothing failing", skip without any TDLib call.
2. **Adaptive backoff for cold channels**`consecutiveEmptyScans` counter. After it crosses a threshold, scan only every Nth cycle. Reset to 0 whenever the channel is "not idle".
3. **`chat.last_message.id` short-circuit** — if (1) and (2) don't skip but the channel's last server-side message ID matches our watermark, skip the `searchChatMessages` paginated call. This runs after the existing `SkippedPackage` retry pass, which pulls the watermark back below failures, so it correctly forces a scan when retries are pending.
The retry pass from `901f32f` is preserved untouched — it runs in front of these guards and adjusts the watermark, so retries always happen.
---
## Schema changes
### `AccountChannelMap` (worker/src/db/schema.prisma)
```prisma
model AccountChannelMap {
// ... existing fields ...
lastScannedAt DateTime?
lastScanFoundArchives Boolean @default(false)
consecutiveEmptyScans Int @default(0)
}
```
### `TopicProgress`
```prisma
model TopicProgress {
// ... existing fields ...
lastScannedAt DateTime?
lastScanFoundArchives Boolean @default(false)
consecutiveEmptyScans Int @default(0)
}
```
### Migration
```sql
-- Both tables get the same three columns. Existing rows get defaults:
-- lastScannedAt = NULL (next scan will populate)
-- lastScanFoundArchives = false (safe default — will be overwritten by next scan)
-- consecutiveEmptyScans = 0 (resets backoff for existing channels)
ALTER TABLE "account_channel_map"
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "topic_progress"
ADD COLUMN "lastScannedAt" TIMESTAMP(3),
ADD COLUMN "lastScanFoundArchives" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consecutiveEmptyScans" INTEGER NOT NULL DEFAULT 0;
```
NULL `lastScannedAt` means "never scanned" — every channel will be scanned the first cycle after deploy. Subsequent cycles benefit from the new fields.
---
## Configuration
Two new env vars in `worker/src/util/config.ts`:
```typescript
/** Window in which a recent successful empty scan lets us skip. Default 5 min. */
skipRecentScanWindowMs:
parseInt(process.env.WORKER_SKIP_RECENT_SCAN_WINDOW_MS ?? "300000", 10),
/** After this many consecutive empty scans, channel enters backoff mode. */
emptyScanBackoffThreshold:
parseInt(process.env.WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD ?? "5", 10),
/** Backoff factor — N means "scan every Nth cycle once in backoff". */
emptyScanBackoffEveryNth:
parseInt(process.env.WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH ?? "5", 10),
```
All three are tunable per deployment without code changes.
---
## Decision logic per channel / topic
The skip decision sits at the top of each channel/topic iteration in `runWorkerForAccount`. It runs BEFORE the existing `SkippedPackage` retry pass.
```text
For each channel (or topic):
1. Query retryableSkippedCount for this scope (already a query we do elsewhere)
2. If retryableSkippedCount > 0:
Force scan (don't skip — failures need retry)
Proceed to existing flow (retry pass → scan)
3. Else if lastScannedAt is NULL:
Force scan (we've never touched this)
Proceed to existing flow
4. Else if Date.now() - lastScannedAt.getTime() < skipRecentScanWindowMs
AND lastScanFoundArchives === false:
Skip — recently scanned and truly idle
5. Else if consecutiveEmptyScans >= emptyScanBackoffThreshold
AND (cycleCount % emptyScanBackoffEveryNth !== 0):
Skip — channel is cold, not its turn to scan
6. Else:
Run the existing flow:
a. SkippedPackage retry pass (901f32f) — may pull watermark back
b. NEW: getChat (or getForumTopicInfo) — if last_message.id <= watermark, skip
c. searchChatMessages scan
```
`cycleCount` is the global ingestion-cycle counter from `scheduler.ts`. It already increments per cycle.
---
## End-of-scan bookkeeping
After every scan (whether it found archives or not), update the three new fields atomically with the existing watermark write:
```typescript
// "Truly idle" means: nothing new this scan AND nothing failed AND no leftover
// retryable failures. The retry-pending check is critical — without it, a
// scan that found no new archives but left SkippedPackage retries pending
// would be marked idle and incorrectly skipped next cycle.
const retryablePending = await getRetryableSkippedMessageIds({
accountId, sourceChannelId, topicId, cap: maxSkipAttempts,
});
const trulyIdle =
scanResult.archives.length === 0
&& minFailedId === null
&& retryablePending.length === 0;
const newConsecutiveEmpty = trulyIdle
? (prev.consecutiveEmptyScans ?? 0) + 1
: 0;
await upsertChannelOrTopicScanState({
// ... existing watermark fields ...
lastScannedAt: new Date(),
lastScanFoundArchives: !trulyIdle,
consecutiveEmptyScans: newConsecutiveEmpty,
});
```
The `consecutiveEmptyScans` counter resets to 0 the moment *anything* happens — archives found, archives failed, or unresolved retries pending. A channel with a chronically-failing archive (whose attemptCount is still below the cap) keeps the counter at 0 and never enters backoff.
If a SkippedPackage hits `attemptCount === maxSkipAttempts`, it's no longer "retryable pending" (it's been given up on), so the counter increments correctly. Same for SkippedPackages that get deleted via the UI's "retry" button — the counter behaves correctly without special-casing.
---
## `getChat` / `getForumTopicInfo` short-circuit
After the retry pass has finalized the effective watermark, but before `searchChatMessages`:
```typescript
// For non-forum channels:
const chat = await client.invoke({ _: "getChat", chat_id: Number(channel.telegramId) });
const channelLastMessageId = chat.last_message?.id;
if (channelLastMessageId && BigInt(channelLastMessageId) <= effectiveWatermark) {
// Nothing new server-side — skip the paginated search entirely.
// Still update lastScannedAt / consecutiveEmptyScans so the recent-scan
// skip kicks in next cycle.
await persistScanState({ trulyIdle: true });
continue;
}
// For forum topics:
const topicInfo = await client.invoke({
_: "getForumTopicInfo",
chat_id: Number(channel.telegramId),
message_thread_id: Number(topic.topicId),
});
const topicLastMessageId = topicInfo.info?.last_message_id;
if (topicLastMessageId && BigInt(topicLastMessageId) <= effectiveWatermark) {
await persistScanState({ trulyIdle: true });
continue;
}
```
`getChat` is served from TDLib's local cache (no network) for chats we've already loaded, which we do up front via `loadChats`. `getForumTopicInfo` is a single round-trip but much cheaper than a paginated `searchChatMessages` call.
The comparison is `<=` because the watermark is the highest message we've fully processed — if the server's last is the same, we're caught up.
This step is correct in the failure-retry case because the retry pass runs FIRST: if there were retryable failures, the retry pass pulled the watermark back below them, and `channelLastMessageId > effectiveWatermark` (since the failed message exists in TG), so we don't skip — we scan and re-pick-up the failure.
---
## Restart behavior
The improvements compose for restart safety:
| Scenario | Today | After this change |
|---|---|---|
| Restart 5 min after a clean cycle | ~2,000 API calls for MPE | ~10 calls (only retryable + truly-active topics) |
| Restart 1 hour later (one missed cycle) | ~2,000 API calls | `getChat` per channel + scan only those where `last_message.id > watermark` (≈ 50 for MPE) |
| Restart after long downtime (12h) | ~2,000 calls + lots of new content | `getChat` per channel, scan everything with new activity |
The three new columns are in PostgreSQL — they survive container restarts directly. `consecutiveEmptyScans = 47` for a cold topic stays at 47 across restart, so backoff continues to apply.
---
## Edge cases and their handling
### 1. Manual SkippedPackage retry via UI between cycles
The UI's `retrySkippedPackageAction` lowers the watermark and deletes the SkippedPackage. Next cycle: `retryableSkippedCount === 0` (the row is gone), but the watermark is lower than `chat.last_message.id` (the retried message exists in TG). So step 6 in the decision tree triggers a scan via the `getChat` check. ✓
### 2. SkippedPackage hits the attempt cap mid-cycle
Once `attemptCount === maxSkipAttempts`, the row is no longer in `getRetryableSkippedMessageIds` results. The channel correctly becomes idle-eligible. The capped SkippedPackage stays in the table as "permanently failed (manual retry only)" — that's the existing behavior. ✓
### 3. New SkippedPackage is created mid-cycle (e.g., an upload fails)
At the end of that scan, `retryablePending` includes the new row → `trulyIdle = false``lastScanFoundArchives = true` → next cycle does NOT skip. ✓
### 4. Channel/topic added after deploy
New rows in `AccountChannelMap` / `TopicProgress` have `lastScannedAt = NULL`, so step 3 in the decision tree always triggers a scan. After the first scan, the fields are populated normally. ✓
### 5. Clock skew / drift
The `lastScannedAt < 5 min ago` check uses `Date.now() - lastScannedAt.getTime()`. Both are application-side clocks (Node.js + PostgreSQL `NOW()` at write). A few seconds of drift doesn't matter; an hour of clock jump (rare but possible) just means one cycle either skips or re-scans — recoverable.
### 6. TDLib `getChat` returns stale data
TDLib's local cache could theoretically be stale (e.g., the account hasn't received the latest update yet). If `channelLastMessageId` is stale (lower than server reality), we'd skip a scan that should have happened. Mitigation: the next cycle's `getChat` likely has fresh data; the watermark guards correctness (we don't lose data, we just process it one cycle later). Acceptable.
### 7. `getForumTopicInfo` rate limit
Calling it per-topic could add up for channels with 1000+ topics. Mitigation: skip-on-recent-scan (step 4) eliminates the call for most topics; only "stale-but-was-active" topics get the call. Worst case is ~50 calls per cycle for MPE, comfortably under the 30 req/sec global limit.
### 8. Channel becomes a forum (or vice versa) between cycles
Existing code handles this — `isChatForum` is rechecked each cycle and `setChannelForum` updates the DB. The new fields live on the same rows, so no extra handling needed.
---
## File-level changes
### New / modified
- `prisma/schema.prisma` — add the six new columns
- `prisma/migrations/<timestamp>_channel_scan_state/migration.sql` — the ALTER TABLE
- `worker/src/util/config.ts` — three new env vars
- `worker/src/db/queries.ts` — new helpers:
- `getChannelScanState(mappingId)` and `getTopicScanState(topicProgressId)`
- `upsertChannelScanState(...)` and `upsertTopicScanState(...)`
- Both wrap the existing `updateLastProcessedMessage` / `upsertTopicProgress` so callers don't need to remember to update the new fields too.
- `worker/src/worker.ts` — top-of-loop skip checks in both the forum and non-forum branches, plus end-of-scan state writes
- `worker/src/tdlib/chats.ts` — small helper `getChatLastMessageId(client, chatId)` and `getForumTopicLastMessageId(client, chatId, topicId)` wrapping the TDLib calls with the existing `invokeWithTimeout` pattern
### Untouched
- `recovery.ts` — recovery is per-startup and one-shot; not affected
- `scheduler.ts``cycleCount` is already there; just expose it where needed
- The existing `SkippedPackage` retry pass logic in `runWorkerForAccount` is unchanged
---
## Testing plan
The project has no automated tests, so verification is manual via Docker logs after deploy:
1. **Build cleanly:** `docker compose up -d --build worker` — no migration errors
2. **First cycle after deploy:** all channels scan (NULL `lastScannedAt`), all fields populated at end of cycle. Log lines confirm normal scan flow.
3. **Second cycle 5 min later:**
- Check logs for `"Skipping recently-scanned idle channel"` — should appear for any channel/topic that was empty last cycle
- Total `searchChatMessages` calls per cycle should drop dramatically (compare to first cycle)
4. **Failure-retry preservation:**
- Find a SkippedPackage with `attemptCount < cap`
- Run a cycle — confirm the channel/topic is NOT skipped (log says it's scanned)
- Confirm the SkippedPackage gets re-tried
5. **Backoff:**
- Pick a cold channel, wait for it to scan 5+ cycles cleanly
- Confirm `consecutiveEmptyScans` climbs to 5+
- Confirm subsequent cycles skip it (only scan every 5th)
6. **`getChat` short-circuit:**
- Pick an active channel
- Trigger an immediate cycle (UI button)
- If `last_message.id <= watermark`, expect log `"Channel caught up via getChat — skipping searchChatMessages"`
7. **Restart safety:**
- Push the change, restart worker
- First cycle after restart should log multiple "Skipping recently-scanned idle channel" lines (because the DB state survived)
- Total cycle time should be a fraction of a baseline restart
---
## Risks and mitigations
| Risk | Mitigation |
|---|---|
| Skip incorrectly applied → real failures never retried | Rule 1 (truly-idle includes `retryablePending === 0`) + dedicated test step 4 |
| `getChat` returns stale data | Next cycle's `getChat` corrects it; watermark guards correctness (no data loss) |
| `getForumTopicInfo` not available in TDLib 1.8.64 | Verify the method exists in the schema; fall back to scan if it throws |
| Backoff applies during legitimate activity bursts | Counter resets to 0 the moment any archive is found OR any retry is pending |
| Migration takes too long on the live DB | Both columns have NOT NULL defaults — Postgres can add them as fast metadata changes (no table rewrite) |
---
## What's explicitly NOT in this design
To keep scope tight:
- **Event-driven ingestion via `updateNewMessage`.** Bigger design, addressed separately. This design is compatible with it — when (D) lands, polling becomes a 4-hour safety net using these same skip rules.
- **Per-channel scan history UI.** Observability layer; separate design.
- **Surfacing the new counters in the admin dashboard.** Can come after the worker-side change is verified.
- **Backfilling `consecutiveEmptyScans` from historical `IngestionRun` data.** Not worth it — it'll converge to the correct value within ~6 cycles.
---
## Open questions
None — the failure-retry interaction was the main risk and is handled by Rule 1 + the existing retry pass.

View File

@@ -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.

View File

@@ -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';

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -450,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)
@@ -472,6 +483,11 @@ 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([])
@@ -503,6 +519,7 @@ model Package {
@@index([archiveType])
@@index([creator])
@@index([packageGroupId])
@@index([sourceChannelId, remoteUniqueId])
@@map("packages")
}
@@ -565,6 +582,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[]
@@ -580,7 +599,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)
@@ -741,6 +773,12 @@ model SkippedPackage {
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())
@@ -830,6 +868,7 @@ enum NotificationType {
DOWNLOAD_FAILED
GROUPING_CONFLICT
INTEGRITY_AUDIT
CHANNEL_ACCESS_LOST
}
enum NotificationSeverity {

View File

@@ -7,6 +7,7 @@ import {
Check,
AlertCircle,
ImageOff,
Maximize2,
} from "lucide-react";
import {
Dialog,
@@ -20,6 +21,7 @@ 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;
@@ -65,6 +67,7 @@ export function ArchivePreviewPicker({
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());
@@ -290,71 +293,85 @@ export function ArchivePreviewPicker({
const isFailed = thumbState?.status === "failed";
return (
<button
key={img.id}
type="button"
className={cn(
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all",
"hover:border-primary/50 cursor-pointer group",
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>
<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>
)}
{/* 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>
{/* 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>
@@ -394,6 +411,13 @@ export function ArchivePreviewPicker({
</div>
)}
</DialogContent>
<ImageLightbox
src={lightboxSrc}
open={!!lightboxSrc}
onOpenChange={(open) => {
if (!open) setLightboxSrc(null);
}}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface CreatorFilterProps {
creators: string[];
value: string; // active creator, "" when none
onChange: (creator: string) => void; // "" clears the filter
}
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className="h-9 w-[200px] justify-between"
>
<span className="truncate">{value || "All Creators"}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search creators..." className="h-9" />
<CommandList>
<CommandEmpty>No creators found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__all__"
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
/>
All Creators
</CommandItem>
{creators.map((creator) => (
<CommandItem
key={creator}
value={creator}
onSelect={() => {
onChange(creator);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === creator ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{creator}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
interface ImageLightboxProps {
src: string | null;
alt?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Full-size, in-page preview viewer. Renders the image at native size capped
* to the viewport (object-contain). Dismiss via the close button, Esc, or by
* clicking the overlay.
*/
export function ImageLightbox({
src,
alt = "",
open,
onOpenChange,
}: ImageLightboxProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-auto max-w-[95vw] border-0 bg-transparent p-0 shadow-none sm:max-w-[90vw]">
<DialogTitle className="sr-only">Enlarged preview image</DialogTitle>
{src && (
<img
src={src}
alt={alt}
className="mx-auto max-h-[88vh] w-auto max-w-full rounded-lg object-contain"
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,12 +1,14 @@
"use client";
import { useState } from "react";
import { type ColumnDef } from "@tanstack/react-table";
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge } from "lucide-react";
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge, Maximize2 } from "lucide-react";
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { SendToTelegramButton } from "./send-to-telegram-button";
import { ImageLightbox } from "./image-lightbox";
export interface PackageRow {
id: string;
@@ -84,14 +86,30 @@ export function formatBytes(bytesStr: string): string {
}
function PreviewCell({ pkg }: { pkg: PackageRow }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
if (pkg.hasPreview) {
const src = `/api/zips/${pkg.id}/preview`;
return (
<img
src={`/api/zips/${pkg.id}/preview`}
alt=""
className="h-9 w-9 rounded-md object-cover bg-muted"
loading="lazy"
/>
<>
<button
type="button"
className="group/preview relative h-9 w-9 overflow-hidden rounded-md bg-muted"
onClick={() => setLightboxOpen(true)}
title="Click to enlarge"
>
<img
src={src}
alt=""
className="h-9 w-9 object-cover"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-black/50 opacity-0 transition-opacity group-hover/preview:opacity-100">
<Maximize2 className="h-3.5 w-3.5 text-white" />
</div>
</button>
<ImageLightbox src={src} open={lightboxOpen} onOpenChange={setLightboxOpen} />
</>
);
}
return (

View File

@@ -13,6 +13,7 @@ import {
Upload,
ImagePlus,
Images,
Maximize2,
} from "lucide-react";
import {
Dialog,
@@ -30,6 +31,7 @@ import type { PackageRow } from "./package-columns";
import { SendToTelegramButton } from "./send-to-telegram-button";
import { uploadPackagePreview } from "../actions";
import { ArchivePreviewPicker } from "./archive-preview-picker";
import { ImageLightbox } from "./image-lightbox";
interface FileItem {
id: string;
@@ -264,6 +266,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
const [uploading, setUploading] = useState(false);
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
const [previewLightboxOpen, setPreviewLightboxOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handlePreviewUpload = useCallback(
@@ -384,9 +387,8 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
<button
type="button"
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
title="Click to replace preview image"
onClick={() => setPreviewLightboxOpen(true)}
title="Click to enlarge"
>
<img
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
@@ -394,11 +396,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploading ? (
<Loader2 className="h-5 w-5 text-white animate-spin" />
) : (
<Upload className="h-5 w-5 text-white" />
)}
<Maximize2 className="h-5 w-5 text-white" />
</div>
</button>
) : (
@@ -582,6 +580,11 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
}}
/>
)}
<ImageLightbox
src={pkg ? (localPreviewUrl ?? `/api/zips/${pkg.id}/preview`) : null}
open={previewLightboxOpen}
onOpenChange={setPreviewLightboxOpen}
/>
</>
);
}

View File

@@ -20,6 +20,7 @@ export interface SkippedRow {
sourceChannel: { id: string; title: string };
isMultipart: boolean;
partCount: number;
attemptCount: number;
createdAt: string;
}
@@ -107,9 +108,22 @@ export function getSkippedColumns({
),
accessorFn: (row) => row.sourceChannel.title,
},
{
accessorKey: "attemptCount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Attempts" />,
cell: ({ row }) => {
const count = row.original.attemptCount;
const variant = count >= 5 ? "destructive" : count > 1 ? "secondary" : "outline";
return (
<Badge variant={variant} className="text-[10px]">
{count}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Skipped" />,
header: ({ column }) => <DataTableColumnHeader column={column} title="Last Skipped" />,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{new Date(row.original.createdAt).toLocaleDateString()}

View File

@@ -3,7 +3,7 @@
import { useState, useCallback, useTransition, useMemo, useRef } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { Search, Layers, Upload } from "lucide-react";
import { Search, Layers, Upload, Send } from "lucide-react";
import { UploadDialog } from "./upload-dialog";
import { useDataTable } from "@/hooks/use-data-table";
import {
@@ -16,6 +16,7 @@ import {
import { PackageFilesDrawer } from "./package-files-drawer";
import { IngestionStatus } from "./ingestion-status";
import { SkippedPackagesTab } from "./skipped-packages-tab";
import { CreatorFilter } from "./creator-filter";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
@@ -49,6 +50,7 @@ import {
createGroupAction,
removeFromGroupAction,
sendAllInGroupAction,
sendAllFromCreatorAction,
updateGroupPreviewAction,
mergeGroupsAction,
} from "../actions";
@@ -59,6 +61,7 @@ interface StlTableProps {
totalCount: number;
ingestionStatus: IngestionAccountStatus[];
availableTags: string[];
availableCreators: string[];
searchTerm: string;
skippedData: SkippedRow[];
skippedPageCount: number;
@@ -74,6 +77,7 @@ export function StlTable({
totalCount,
ingestionStatus,
availableTags,
availableCreators,
searchTerm,
skippedData,
skippedPageCount,
@@ -85,6 +89,7 @@ export function StlTable({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeCreator = searchParams.get("creator") ?? "";
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
@@ -207,6 +212,20 @@ export function StlTable({
[router, pathname, searchParams]
);
const updateCreatorFilter = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("creator", value);
params.set("page", "1");
} else {
params.delete("creator");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
const activeTab = searchParams.get("tab") ?? "packages";
const updateTab = useCallback(
@@ -277,6 +296,23 @@ export function StlTable({
[router]
);
const handleSendAllFromCreator = useCallback(() => {
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
startTransition(async () => {
const result = await sendAllFromCreatorAction(activeCreator);
if (result.success) {
const { queued, skipped } = result.data;
toast.success(
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
(skipped ? ` (${skipped} already queued)` : "")
);
router.refresh();
} else {
toast.error(result.error);
}
});
}, [activeCreator, router]);
const handleRemoveFromGroup = useCallback(
(packageId: string) => {
startTransition(async () => {
@@ -500,11 +536,29 @@ export function StlTable({
</SelectContent>
</Select>
)}
{availableCreators.length > 0 && (
<CreatorFilter
creators={availableCreators}
value={activeCreator}
onChange={updateCreatorFilter}
/>
)}
<DataTableViewOptions table={table} />
<Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" />
Upload Files
</Button>
{activeCreator && (
<Button
variant="outline"
size="sm"
className="h-9 gap-1.5"
onClick={handleSendAllFromCreator}
>
<Send className="h-3.5 w-3.5" />
Send all from {activeCreator}
</Button>
)}
{selectedPackages.size >= 2 && (
<Button
variant="outline"

View File

@@ -589,3 +589,82 @@ export async function sendAllInGroupAction(
return { success: false, error: "Failed to send group packages" };
}
}
export async function sendAllFromCreatorAction(
creatorName: string
): Promise<ActionResult<{ queued: number; skipped: number }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const creator = creatorName.trim();
if (!creator) {
return { success: false, error: "No creator specified" };
}
try {
const telegramLink = await prisma.telegramLink.findUnique({
where: { userId: session.user.id },
});
if (!telegramLink) {
return { success: false, error: "No linked Telegram account. Link one in Settings." };
}
const sendablePackages = await prisma.package.findMany({
where: {
creator,
destChannelId: { not: null },
destMessageId: { not: null },
},
select: { id: true },
});
if (sendablePackages.length === 0) {
return { success: false, error: "No uploaded packages found for this creator" };
}
let queued = 0;
let skipped = 0;
for (const pkg of sendablePackages) {
// Only create if no existing PENDING/SENDING request for this package+link combo
const existing = await prisma.botSendRequest.findFirst({
where: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
status: { in: ["PENDING", "SENDING"] },
},
});
if (existing) {
skipped++;
continue;
}
const sendRequest = await prisma.botSendRequest.create({
data: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
requestedByUserId: session.user.id,
status: "PENDING",
},
});
// Notify the bot via pg_notify
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('bot_send', $1)`,
sendRequest.id
);
} catch {
// Best-effort — the bot also polls periodically
}
queued++;
}
revalidatePath("/stls");
return { success: true, data: { queued, skipped } };
} catch {
return { success: false, error: "Failed to send creator packages" };
}
}

View File

@@ -1,6 +1,6 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, getAllPackageCreators, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
import { StlTable } from "./_components/stl-table";
import type { DisplayItem, PackageListItem } from "@/lib/telegram/types";
@@ -24,7 +24,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
const tab = (params.tab as string) ?? "packages";
// Fetch packages, ingestion status, tags, and skipped count in parallel
const [result, ingestionStatus, availableTags, skippedCount, ungroupedCount] = await Promise.all([
const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] = await Promise.all([
search
? searchPackages({
query: search,
@@ -42,6 +42,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
}),
getIngestionStatus(),
getAllPackageTags(),
getAllPackageCreators(),
countSkippedPackages(),
countUngroupedPackages(),
]);
@@ -68,6 +69,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
totalCount={result.pagination.total}
ingestionStatus={ingestionStatus}
availableTags={availableTags}
availableCreators={availableCreators}
searchTerm={search}
skippedData={skippedResult?.items ?? []}
skippedPageCount={skippedResult?.pagination.totalPages ?? 0}

View File

@@ -9,6 +9,7 @@ import {
ArrowUpFromLine,
RefreshCcw,
Tag,
MessagesSquare,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -27,6 +28,7 @@ interface ChannelColumnsProps {
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
onRescan: (id: string) => void;
onSetCategory: (id: string, category: string | null) => void;
onManageTopics: (id: string) => void;
}
export function getChannelColumns({
@@ -35,6 +37,7 @@ export function getChannelColumns({
onSetType,
onRescan,
onSetCategory,
onManageTopics,
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
return [
{
@@ -147,6 +150,14 @@ export function getChannelColumns({
Rescan Channel
</DropdownMenuItem>
)}
{row.original.type === "SOURCE" && row.original.isForum && (
<DropdownMenuItem
onClick={() => onManageTopics(row.original.id)}
>
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
Topics
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");

View File

@@ -7,6 +7,7 @@ import { getChannelColumns } from "./channel-columns";
import { DestinationCard } from "./destination-card";
import { ChannelPickerDialog } from "./channel-picker-dialog";
import { JoinChannelDialog } from "./join-channel-dialog";
import { TopicsDrawer } from "./topics-drawer";
import {
deleteChannel,
toggleChannelActive,
@@ -32,6 +33,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
const [rescanId, setRescanId] = useState<string | null>(null);
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
const [topicsChannelId, setTopicsChannelId] = useState<string | null>(null);
// Find the first authenticated account for "Fetch Channels"
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
@@ -60,6 +62,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
else toast.error(result.error);
});
},
onManageTopics: (id) => setTopicsChannelId(id),
});
const { table } = useDataTable({
@@ -167,6 +170,17 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
open={joinDialogOpen}
onOpenChange={setJoinDialogOpen}
/>
<TopicsDrawer
channelId={topicsChannelId}
channelTitle={
channels.find((c) => c.id === topicsChannelId)?.title
}
open={!!topicsChannelId}
onOpenChange={(open) => {
if (!open) setTopicsChannelId(null);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useTransition } from "react";
import { toast } from "sonner";
import { Loader2, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { setTopicFetchEnabled } from "../actions";
interface TopicRow {
id: string;
topicId: string;
topicName: string | null;
fetchEnabled: boolean;
lastScannedAt: string | null;
}
interface TopicsDrawerProps {
channelId: string | null;
channelTitle?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function topicLabel(t: TopicRow): string {
return t.topicName ?? `Topic ${t.topicId}`;
}
export function TopicsDrawer({
channelId,
channelTitle,
open,
onOpenChange,
}: TopicsDrawerProps) {
const [topics, setTopics] = useState<TopicRow[]>([]);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState("");
const [, startTransition] = useTransition();
const [pendingId, setPendingId] = useState<string | null>(null);
const fetchTopics = useCallback(async () => {
if (!channelId) return;
setLoading(true);
try {
const res = await fetch(`/api/telegram/channels/${channelId}/topics`);
if (res.ok) setTopics(await res.json());
else toast.error("Failed to load topics");
} catch {
toast.error("Failed to load topics");
}
setLoading(false);
}, [channelId]);
useEffect(() => {
if (open && channelId) fetchTopics();
}, [open, channelId, fetchTopics]);
const handleOpenChange = (next: boolean) => {
// Reset the filter on close so the next open starts clean (the drawer is
// opened per-channel, so close-then-open is the normal channel switch).
if (!next) setFilter("");
onOpenChange(next);
};
const handleToggle = (topic: TopicRow, enabled: boolean) => {
// Optimistic update
setTopics((prev) =>
prev.map((t) => (t.id === topic.id ? { ...t, fetchEnabled: enabled } : t))
);
setPendingId(topic.id);
startTransition(async () => {
const result = await setTopicFetchEnabled(topic.id, enabled);
if (!result.success) {
toast.error(result.error ?? "Failed to update topic");
// Revert on failure
setTopics((prev) =>
prev.map((t) =>
t.id === topic.id ? { ...t, fetchEnabled: !enabled } : t
)
);
}
setPendingId(null);
});
};
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return topics;
return topics.filter((t) => topicLabel(t).toLowerCase().includes(q));
}, [topics, filter]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
<div className="space-y-1">
<DialogTitle className="truncate pr-8">
Topics{channelTitle ? `: ${channelTitle}` : ""}
</DialogTitle>
<DialogDescription>
Enabled topics are scanned and their files fetched. Disable a topic
to stop fetching new files from it already-fetched files are kept.
</DialogDescription>
</div>
{topics.length > 0 && (
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter topics..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-9 h-9"
/>
</div>
)}
</DialogHeader>
{/* Native overflow scroll: the Radix ScrollArea viewport does not get a
bounded height inside this flex-column, max-h, vertically-centred
dialog, so the list overflowed instead of scrolling. flex-1 +
min-h-0 + overflow-y-auto is the canonical, touch-friendly fix. */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-6 py-4 space-y-2">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Loading topics...
</span>
</div>
) : topics.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No topics discovered yet they&apos;ll appear here after the next
scan.
</p>
) : filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No topics match &quot;{filter}&quot;.
</p>
) : (
filtered.map((topic) => (
<div
key={topic.id}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{topicLabel(topic)}
</p>
<p className="text-xs text-muted-foreground">
{topic.fetchEnabled
? "Fetching enabled"
: "Fetching disabled"}
{topic.lastScannedAt
? ` · last scanned ${new Date(
topic.lastScannedAt
).toLocaleDateString()}`
: " · not scanned yet"}
</p>
</div>
<Switch
checked={topic.fetchEnabled}
disabled={pendingId === topic.id}
onCheckedChange={(checked) => handleToggle(topic, checked)}
aria-label={`Toggle fetching for ${topicLabel(topic)}`}
/>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -9,13 +9,14 @@ import {
Radio,
AlertTriangle,
RefreshCw,
SkipForward,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { triggerIngestion } from "../actions";
import { triggerIngestion, disableActiveTopic } from "../actions";
import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface WorkerStatusPanelProps {
@@ -218,6 +219,24 @@ function RunningStatus({
}: {
run: NonNullable<IngestionAccountStatus["currentRun"]>;
}) {
const [isDisabling, startDisable] = useTransition();
const handleSkipTopic = () => {
const acmId = run.currentAccountChannelMapId;
const topicId = run.currentTopicId;
if (!acmId || !topicId) return;
startDisable(async () => {
const result = await disableActiveTopic(acmId, topicId);
if (result.success) {
toast.success(
"Topic disabled — the current file finishes, then the rest is skipped"
);
} else {
toast.error(result.error ?? "Failed to disable topic");
}
});
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -273,6 +292,27 @@ function RunningStatus({
</span>
)}
</div>
{/* Skip & disable the topic currently being processed */}
{run.currentTopicId && run.currentAccountChannelMapId && (
<div className="pl-6 pt-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={handleSkipTopic}
disabled={isDisabling}
title="Finish the current file, skip the rest of this topic, and don't fetch it in future runs"
>
{isDisabling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SkipForward className="h-3 w-3" />
)}
Skip &amp; disable this topic
</Button>
</div>
)}
</div>
);
}

View File

@@ -436,6 +436,80 @@ export async function rescanChannel(channelId: string): Promise<ActionResult> {
}
}
// ── Topic fetch toggle ──
export async function setTopicFetchEnabled(
topicProgressId: string,
enabled: boolean
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
const existing = await prisma.topicProgress.findUnique({
where: { id: topicProgressId },
});
if (!existing) return { success: false, error: "Topic not found" };
try {
await prisma.topicProgress.update({
where: { id: topicProgressId },
data: { fetchEnabled: enabled },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to update topic" };
}
}
/**
* Disable the topic currently being processed by the worker, identified by its
* account-channel mapping + Telegram topic id (as exposed on the live run
* status). Upserts so a disabled row exists even if the worker hasn't persisted
* the topic yet. The worker honours this live: it finishes the in-flight file
* then skips the rest of the topic, and future runs skip it entirely.
*/
export async function disableActiveTopic(
accountChannelMapId: string,
topicId: string
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
let topicIdBig: bigint;
try {
topicIdBig = BigInt(topicId);
} catch {
return { success: false, error: "Invalid topic id" };
}
// BigInt("") / BigInt("0") don't throw — reject non-positive ids explicitly
// since this action is exported and admin-callable outside the UI button.
if (topicIdBig <= BigInt(0)) {
return { success: false, error: "Invalid topic id" };
}
try {
await prisma.topicProgress.upsert({
where: {
accountChannelMapId_topicId: {
accountChannelMapId,
topicId: topicIdBig,
},
},
create: {
accountChannelMapId,
topicId: topicIdBig,
fetchEnabled: false,
},
update: { fetchEnabled: false },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to disable topic" };
}
}
// ── Account-Channel link actions ──
export async function linkChannel(

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
import { listChannelTopics } from "@/lib/telegram/admin-queries";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ channelId: string }> }
) {
const authResult = await authenticateApiRequest(request, true);
if ("error" in authResult) return authResult.error;
const { channelId } = await params;
const topics = await listChannelTopics(channelId);
return NextResponse.json(topics);
}

View File

@@ -41,6 +41,7 @@ export async function listChannels() {
telegramId: c.telegramId.toString(),
title: c.title,
type: c.type,
isForum: c.isForum,
isActive: c.isActive,
category: c.category,
createdAt: c.createdAt.toISOString(),
@@ -140,3 +141,24 @@ export async function getUnlinkedChannels(accountId: string) {
telegramId: c.telegramId.toString(),
}));
}
// ── Channel topic queries ──
export async function listChannelTopics(channelId: string) {
const rows = await prisma.topicProgress.findMany({
where: { accountChannelMap: { channelId } },
orderBy: [{ fetchEnabled: "desc" }, { topicName: "asc" }],
});
return rows.map((r) => ({
id: r.id,
topicId: r.topicId.toString(),
topicName: r.topicName,
fetchEnabled: r.fetchEnabled,
lastScannedAt: r.lastScannedAt?.toISOString() ?? null,
}));
}
export type ChannelTopicRow = Awaited<
ReturnType<typeof listChannelTopics>
>[number];

View File

@@ -9,6 +9,28 @@ import type {
PackageGroupRow,
} from "./types";
/**
* Returns the subset of the given IDs whose `previewData` column is non-null,
* WITHOUT transferring the (large) image bytes.
*
* List views only need a `hasPreview` boolean per row. Selecting `previewData`
* directly pulls every JPEG blob (avg ~700 KB, up to 2 MB) into the Node heap
* just to compare it against null — under concurrent requests this exhausts the
* container memory limit and crashes the process. This keeps the check in SQL.
*/
async function fetchPreviewFlags(
table: "packages" | "package_groups",
ids: string[]
): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const placeholders = ids.map((_, i) => `$${i + 1}`).join(", ");
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(
`SELECT id FROM ${table} WHERE "previewData" IS NOT NULL AND id IN (${placeholders})`,
...ids
);
return new Set(rows.map((r) => r.id));
}
export async function listPackages(options: {
page: number;
limit: number;
@@ -40,13 +62,17 @@ export async function listPackages(options: {
indexedAt: true,
creator: true,
tags: true,
previewData: true, // check actual image data, not previewMsgId proxy
sourceChannel: { select: { id: true, title: true } },
},
}),
prisma.package.count({ where }),
]);
const previewIds = await fetchPreviewFlags(
"packages",
items.map((p) => p.id)
);
const mapped: PackageListItem[] = items.map((pkg) => ({
id: pkg.id,
fileName: pkg.fileName,
@@ -55,7 +81,7 @@ export async function listPackages(options: {
archiveType: pkg.archiveType,
fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null,
hasPreview: previewIds.has(pkg.id),
creator: pkg.creator,
tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(),
@@ -149,7 +175,7 @@ export async function listDisplayItems(options: {
select: {
id: true, fileName: true, fileSize: true, contentHash: true,
archiveType: true, fileCount: true, isMultipart: true,
indexedAt: true, creator: true, tags: true, previewData: true,
indexedAt: true, creator: true, tags: true,
sourceChannel: { select: { id: true, title: true } },
},
})
@@ -159,13 +185,13 @@ export async function listDisplayItems(options: {
? await prisma.packageGroup.findMany({
where: { id: { in: groupIds } },
select: {
id: true, name: true, previewData: true,
id: true, name: true,
sourceChannel: { select: { id: true, title: true } },
packages: {
select: {
id: true, fileName: true, fileSize: true, contentHash: true,
archiveType: true, fileCount: true, isMultipart: true,
indexedAt: true, creator: true, tags: true, previewData: true,
indexedAt: true, creator: true, tags: true,
sourceChannel: { select: { id: true, title: true } },
},
orderBy: { indexedAt: "desc" },
@@ -174,6 +200,16 @@ export async function listDisplayItems(options: {
})
: [];
// Compute hasPreview flags without transferring image bytes (see fetchPreviewFlags)
const allPackageIds = [
...standalonePackages.map((p) => p.id),
...groups.flatMap((g) => g.packages.map((p) => p.id)),
];
const [packagePreviewIds, groupPreviewIds] = await Promise.all([
fetchPreviewFlags("packages", allPackageIds),
fetchPreviewFlags("package_groups", groups.map((g) => g.id)),
]);
// Build DisplayItem array in the original sort order
const packageMap = new Map(standalonePackages.map((p) => [p.id, p]));
const groupMap = new Map(groups.map((g) => [g.id, g]));
@@ -191,7 +227,7 @@ export async function listDisplayItems(options: {
archiveType: pkg.archiveType,
fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null,
hasPreview: packagePreviewIds.has(pkg.id),
creator: pkg.creator,
tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(),
@@ -209,7 +245,7 @@ export async function listDisplayItems(options: {
data: {
id: grp.id,
name: grp.name,
hasPreview: grp.previewData !== null,
hasPreview: groupPreviewIds.has(grp.id),
totalFileSize: grp.packages.reduce((sum, p) => sum + p.fileSize, BigInt(0)).toString(),
totalFileCount: grp.packages.reduce((sum, p) => sum + p.fileCount, 0),
packageCount: grp.packages.length,
@@ -227,7 +263,7 @@ export async function listDisplayItems(options: {
archiveType: pkg.archiveType,
fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null,
hasPreview: packagePreviewIds.has(pkg.id),
creator: pkg.creator,
tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(),
@@ -440,13 +476,17 @@ export async function searchPackages(options: {
indexedAt: true,
creator: true,
tags: true,
previewData: true,
sourceChannel: { select: { id: true, title: true } },
},
}),
Promise.resolve(allIds.length),
]);
const previewIds = await fetchPreviewFlags(
"packages",
items.map((p) => p.id)
);
const mapped: PackageListItem[] = items.map((pkg) => ({
id: pkg.id,
fileName: pkg.fileName,
@@ -455,7 +495,7 @@ export async function searchPackages(options: {
archiveType: pkg.archiveType,
fileCount: pkg.fileCount,
isMultipart: pkg.isMultipart,
hasPreview: pkg.previewData !== null,
hasPreview: previewIds.has(pkg.id),
creator: pkg.creator,
tags: pkg.tags,
indexedAt: pkg.indexedAt.toISOString(),
@@ -494,6 +534,15 @@ export async function getAllPackageTags(): Promise<string[]> {
return result.map((r) => r.tag);
}
export async function getAllPackageCreators(): Promise<string[]> {
const result = await prisma.$queryRaw<{ creator: string }[]>`
SELECT DISTINCT creator FROM packages
WHERE creator IS NOT NULL AND creator <> ''
ORDER BY creator
`;
return result.map((r) => r.creator);
}
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
const accounts = await prisma.telegramAccount.findMany({
orderBy: { createdAt: "asc" },
@@ -550,6 +599,8 @@ export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
totalBytes: currentRun.totalBytes?.toString() ?? null,
downloadPercent: currentRun.downloadPercent,
lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null,
currentTopicId: currentRun.currentTopicId?.toString() ?? null,
currentAccountChannelMapId: currentRun.currentAccountChannelMapId,
}
: null,
});
@@ -589,6 +640,7 @@ export async function listSkippedPackages(options: {
sourceMessageId: s.sourceMessageId.toString(),
isMultipart: s.isMultipart,
partCount: s.partCount,
attemptCount: s.attemptCount,
createdAt: s.createdAt.toISOString(),
}));
@@ -633,13 +685,17 @@ export async function listUngroupedPackages(options: {
partCount: true,
tags: true,
indexedAt: true,
previewData: true,
sourceChannel: { select: { id: true, title: true } },
},
}),
prisma.package.count({ where }),
]);
const previewIds = await fetchPreviewFlags(
"packages",
items.map((p) => p.id)
);
return {
items: items.map((p) => ({
id: p.id,
@@ -653,7 +709,7 @@ export async function listUngroupedPackages(options: {
partCount: p.partCount,
tags: p.tags,
indexedAt: p.indexedAt.toISOString(),
hasPreview: !!p.previewData,
hasPreview: previewIds.has(p.id),
sourceChannel: p.sourceChannel,
matchedFileCount: 0,
matchedByContent: false,

View File

@@ -55,6 +55,8 @@ export interface SkippedPackageItem {
sourceMessageId: string;
isMultipart: boolean;
partCount: number;
/** How many times the worker has tried this source message across cycles. */
attemptCount: number;
createdAt: string;
}
@@ -121,5 +123,7 @@ export interface IngestionAccountStatus {
totalBytes: string | null; // BigInt serialized as string
downloadPercent: number | null;
lastActivityAt: string | null;
currentTopicId: string | null; // BigInt serialized as string
currentAccountChannelMapId: string | null;
} | null;
}

View File

@@ -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",
"yauzl": "^3.2.0"
},
"devDependencies": {
@@ -568,9 +568,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"
],
@@ -581,9 +581,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"
],
@@ -594,9 +594,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"
],
@@ -607,9 +620,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"
],
@@ -619,10 +632,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"
],
@@ -1696,16 +1729,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": {
@@ -1998,13 +2034,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"
},

View File

@@ -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",
"yauzl": "^3.2.0"
},
"devDependencies": {

View File

@@ -0,0 +1,128 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { childLogger } from "../util/logger.js";
const execFileAsync = promisify(execFile);
const log = childLogger("integrity");
export type IntegrityFailureKind = "encrypted" | "corrupt" | "inconclusive";
export type IntegrityResult =
| { ok: true }
| { ok: false; reason: string; kind: IntegrityFailureKind };
/**
* Test that the archive can be read end-to-end without errors, BEFORE we
* spend bandwidth uploading it to the destination channel.
*
* Failures are classified so the caller can react appropriately:
* - "encrypted" — password-protected; users can't extract it. Actionable.
* - "corrupt" — genuine CRC / structural error (truncated download, bad
* central directory, CRC mismatch). Actionable.
* - "inconclusive" — the test tool itself was killed (OOM) or timed out,
* typically on very large 7z archives in a memory-limited
* container (exit 137 / SIGKILL). This is a TOOL
* LIMITATION, not corruption — callers should NOT raise a
* user-facing alarm for it.
*
* Returns { ok: true } if the archive is intact, otherwise
* { ok: false, reason, kind }.
*
* For multipart archives, pass the first part. unzip / unrar / 7z all
* auto-discover sibling parts.
*
* archiveType "DOCUMENT" is a pass-through — there's no container to test.
*/
export async function testArchiveIntegrity(
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT",
firstPartPath: string
): Promise<IntegrityResult> {
if (archiveType === "DOCUMENT") {
return { ok: true };
}
try {
if (archiveType === "ZIP") {
// -t = test, -qq = very quiet (errors only)
const { stderr } = await execFileAsync("unzip", ["-tqq", firstPartPath], {
timeout: 300_000, // 5 min for very large archives
maxBuffer: 10 * 1024 * 1024,
});
if (stderr && stderr.trim()) {
return { ok: false, kind: "corrupt", reason: `unzip -t reported: ${stderr.slice(0, 500)}` };
}
return { ok: true };
}
if (archiveType === "RAR") {
const { stdout, stderr } = await execFileAsync("unrar", ["t", firstPartPath], {
timeout: 300_000,
maxBuffer: 10 * 1024 * 1024,
});
// unrar uses non-zero exit code on errors, which becomes a throw.
// If it succeeds, "All OK" is in stdout.
const combined = `${stdout}\n${stderr}`;
if (/All OK/i.test(combined)) {
return { ok: true };
}
return { ok: false, kind: "corrupt", reason: `unrar t did not report "All OK": ${combined.slice(-500)}` };
}
if (archiveType === "SEVEN_Z") {
const { stdout, stderr } = await execFileAsync("7z", ["t", firstPartPath], {
timeout: 300_000,
maxBuffer: 10 * 1024 * 1024,
});
const combined = `${stdout}\n${stderr}`;
if (/Everything is Ok/i.test(combined)) {
return { ok: true };
}
return { ok: false, kind: "corrupt", reason: `7z t did not report "Everything is Ok": ${combined.slice(-500)}` };
}
return { ok: false, kind: "corrupt", reason: `Unknown archive type: ${archiveType}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// execFile throws on non-zero exit, on timeout, and when killed by a signal.
const e = err as {
stderr?: unknown;
stdout?: unknown;
signal?: string | null;
killed?: boolean;
code?: number | string | null;
};
const stderr = typeof e.stderr === "string" ? e.stderr : "";
const stdout = typeof e.stdout === "string" ? e.stdout : "";
const detail = stderr ? `: ${stderr.slice(0, 500)}` : "";
const haystack = `${msg}\n${stdout}\n${stderr}`;
// Encrypted archives — users can't extract them, so flag clearly.
if (/password|encrypted|wrong password|enter password/i.test(haystack)) {
return { ok: false, kind: "encrypted", reason: `Archive is encrypted (password protected): ${msg}${detail}` };
}
// Inconclusive — the test tool was killed or timed out rather than
// reporting corruption. Common on large 7z in memory-limited containers,
// where `7z t` gets OOM-killed (SIGKILL / exit 137) mid-decompression.
// That's a tool limitation, not a corrupt archive.
const killedBySignal = e.signal === "SIGKILL" || e.signal === "SIGTERM";
const killedExitCode = e.code === 137 || e.code === 143; // 128 + SIGKILL/SIGTERM
const timedOut = e.killed === true;
const maxBufferExceeded = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
if (killedBySignal || killedExitCode || timedOut || maxBufferExceeded) {
log.debug(
{ err, archiveType, firstPartPath, signal: e.signal, code: e.code, killed: e.killed },
"Archive integrity test inconclusive (tool killed or timed out)"
);
return {
ok: false,
kind: "inconclusive",
reason: `Integrity test could not complete (tool killed or timed out — likely OOM on a large archive): ${msg}`,
};
}
// Genuine failure: a real non-zero exit with error output.
log.debug({ err, archiveType, firstPartPath }, "Archive integrity test failed");
return { ok: false, kind: "corrupt", reason: `Integrity test failed: ${msg}${detail}` };
}
}

View File

@@ -11,8 +11,11 @@ export interface TelegramMessage {
fileSize: bigint;
date: Date;
mediaAlbumId?: string;
replyToMessageId?: bigint; // NEW
caption?: string; // NEW
replyToMessageId?: bigint;
caption?: string;
/** TDLib's `remote.unique_id` for the file — stable across reposts of
* the exact same content. Empty string if the message didn't expose it. */
remoteUniqueId?: string;
}
export interface ArchiveSet {

View File

@@ -8,83 +8,122 @@ const execFileAsync = promisify(execFile);
const log = childLogger("rar-reader");
/**
* Parse output of `unrar l -v <file>` to extract file metadata.
* unrar automatically discovers sibling parts when they're co-located.
* Parse output of `unrar lt <file>` to extract file metadata.
*
* `lt` (list technical) emits one block per archived file with key:value
* lines — far more reliable than the column-based default `l -v` output,
* which has changed format twice across unrar versions.
*
* unrar automatically discovers sibling multipart files when they're
* co-located (e.g. *.part1.rar + *.part2.rar in the same directory).
*
* Returns [] on any failure (best-effort: ingestion still succeeds with
* an empty file list rather than failing the whole archive).
*/
export async function readRarContents(
firstPartPath: string
): Promise<FileEntry[]> {
try {
const { stdout } = await execFileAsync("unrar", ["l", "-v", firstPartPath], {
timeout: 30000,
maxBuffer: 10 * 1024 * 1024, // 10MB for very large archives
const { stdout } = await execFileAsync("unrar", ["lt", firstPartPath], {
timeout: 60_000,
maxBuffer: 50 * 1024 * 1024, // 50MB for archives with very many files
});
return parseUnrarOutput(stdout);
const entries = parseUnrarTechnical(stdout);
if (entries.length === 0) {
// Log a sample of the output so we can diagnose format changes
log.warn(
{ file: firstPartPath, sample: stdout.slice(0, 500) },
"unrar lt returned no parseable entries"
);
}
return entries;
} catch (err) {
log.warn({ err, file: firstPartPath }, "Failed to read RAR contents");
return []; // Fallback: return empty on error
return [];
}
}
/**
* Parse the tabular output of `unrar l -v`.
* Parse `unrar lt` output: header followed by per-file key:value blocks
* separated by blank lines.
*
* Example output format:
* Archive: test.rar
* Details: RAR 5
* Example block:
*
* Attributes Size Packed Ratio Date Time CRC-32 Name
* ----------- --------- --------- ----- -------- ----- -------- ----
* ...A.... 12345 10234 83% 2024-01-15 10:30 DEADBEEF folder/file.stl
* ----------- --------- --------- ----- -------- ----- -------- ----
* Name: folder/file.stl
* Type: File
* Size: 12345
* Packed size: 10234
* Ratio: 83%
* mtime: 2024-01-15 10:30:00,000000000
* Attributes: ..A....
* CRC32: DEADBEEF
* Host OS: Windows
* Compression: RAR 5.0(v50) -m3 -md=32M
*/
function parseUnrarOutput(output: string): FileEntry[] {
function parseUnrarTechnical(output: string): FileEntry[] {
const entries: FileEntry[] = [];
const lines = output.split("\n");
// Split into blocks on blank lines, then on each block read key:value pairs.
const blocks = output.split(/\r?\n\s*\r?\n/);
let inFileList = false;
let separatorCount = 0;
for (const block of blocks) {
const fields = parseBlock(block);
if (!fields) continue;
for (const line of lines) {
const trimmed = line.trim();
// Only File entries (skip Directory, and anything missing the basics)
if (fields.type && fields.type.toLowerCase() !== "file") continue;
if (!fields.name || fields.size === undefined) continue;
// Detect separator lines (------- pattern)
if (/^-{5,}/.test(trimmed)) {
separatorCount++;
if (separatorCount === 1) {
inFileList = true;
} else if (separatorCount >= 2) {
inFileList = false;
}
continue;
}
if (!inFileList) continue;
// Parse file entry line
// Format: Attributes Size Packed Ratio Date Time CRC Name
const match = trimmed.match(
/^\S+\s+(\d+)\s+(\d+)\s+\d+%\s+\S+\s+\S+\s+([0-9A-Fa-f]+)\s+(.+)$/
);
if (match) {
const [, uncompressedStr, compressedStr, crc32, filePath] = match;
// Skip directory entries (typically end with / or have size 0 with dir attributes)
if (filePath.endsWith("/") || filePath.endsWith("\\")) continue;
const ext = path.extname(filePath).toLowerCase();
entries.push({
path: filePath,
fileName: path.basename(filePath),
extension: ext ? ext.slice(1) : null,
compressedSize: BigInt(compressedStr),
uncompressedSize: BigInt(uncompressedStr),
crc32: crc32.toLowerCase(),
});
}
const filePath = fields.name;
const ext = path.extname(filePath).toLowerCase();
entries.push({
path: filePath,
fileName: path.basename(filePath),
extension: ext ? ext.slice(1) : null,
uncompressedSize: BigInt(fields.size),
compressedSize: fields.packedSize !== undefined
? BigInt(fields.packedSize)
: BigInt(fields.size),
crc32: fields.crc32 ? fields.crc32.toLowerCase() : null,
});
}
return entries;
}
interface BlockFields {
name?: string;
type?: string;
size?: string;
packedSize?: string;
crc32?: string;
}
function parseBlock(block: string): BlockFields | null {
// Skip the archive-header block (contains "Archive:" / "Details:" lines
// and lacks a Name field).
if (!/^\s*Name:/m.test(block)) return null;
const fields: BlockFields = {};
const lines = block.split(/\r?\n/);
for (const line of lines) {
// Match " key: value" with arbitrary leading whitespace and a multi-word
// key (e.g. "Packed size", "Host OS").
const m = line.match(/^\s*([A-Za-z][A-Za-z0-9 ]*?)\s*:\s*(.*)$/);
if (!m) continue;
const key = m[1].trim().toLowerCase();
const value = m[2].trim();
if (key === "name") fields.name = value;
else if (key === "type") fields.type = value;
else if (key === "size") fields.size = value;
else if (key === "packed size") fields.packedSize = value;
else if (key === "crc32" || key === "blake2sp" || key === "checksum") {
// unrar may report BLAKE2sp for newer archives instead of CRC32.
// Either way we just store it as a hex string in our crc32 field.
fields.crc32 = value;
}
}
return fields;
}

View File

@@ -0,0 +1,58 @@
import type { FileEntry } from "./zip-reader.js";
/**
* Mapping from file extensions to slicer tags. Each tag groups a family of
* extensions that mean the same thing for end users — "this archive contains
* files I can open in <slicer>".
*
* Extensions are matched case-insensitively without the leading dot.
*/
const SLICER_EXTENSION_MAP: Record<string, string> = {
// Lychee Slicer
lys: "lychee",
lyt: "lychee",
lyc: "lychee",
// ChituBox / Anycubic / Phrozen / Elegoo (resin printers)
chitubox: "chitubox",
ctb: "chitubox",
cbddlp: "chitubox",
// Anycubic Photon family
photon: "anycubic",
pwmo: "anycubic",
pwmx: "anycubic",
pwmb: "anycubic",
pwma: "anycubic",
pws: "anycubic",
pwsq: "anycubic",
phz: "anycubic",
// Bambu / Prusa
"3mf": "bambu",
bgcode: "bambu",
// FDM gcode (generic)
gcode: "fdm",
// Mango / generic resin formats sometimes seen in releases
mfp: "mango",
mfpv: "mango",
osla: "mango",
};
/**
* Derive a deduplicated list of slicer tags from an archive's file listing.
* Returns an empty array if no recognised slicer-specific files are present
* (e.g., the archive is just STLs without pre-supports).
*/
export function extractSlicerTags(entries: FileEntry[]): string[] {
const tags = new Set<string>();
for (const entry of entries) {
if (!entry.extension) continue;
const ext = entry.extension.toLowerCase();
const tag = SLICER_EXTENSION_MAP[ext];
if (tag) tags.add(tag);
}
return [...tags].sort();
}

343
worker/src/backfill.ts Normal file
View File

@@ -0,0 +1,343 @@
import path from "path";
import { mkdir, rm } from "fs/promises";
import { db } from "./db/client.js";
import { config } from "./util/config.js";
import { childLogger } from "./util/logger.js";
import { withTdlibMutex } from "./util/mutex.js";
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
import { downloadFile } from "./tdlib/download.js";
import { getActiveAccounts } from "./db/queries.js";
import { readZipCentralDirectory } from "./archive/zip-reader.js";
import { readRarContents } from "./archive/rar-reader.js";
import { read7zContents } from "./archive/sevenz-reader.js";
import { extractSlicerTags } from "./archive/slicer-tags.js";
import type { FileEntry } from "./archive/zip-reader.js";
const log = childLogger("backfill");
/**
* Re-extract file listings for Packages whose fileCount is 0 — usually
* caused by historical bugs in the archive readers (e.g. the RAR parser
* that silently returned [] for every archive before 0bdd4ba).
*
* For each candidate Package:
* 1. Download all destMessageIds from the destination channel
* 2. Run the appropriate reader (ZIP / RAR / 7Z) on the assembled files
* 3. Insert PackageFile rows + update Package.fileCount
* 4. Clean up the temp files
*
* Triggered via pg_notify "backfill_filelists" with optional payload
* `{"limit": N, "archiveType": "RAR"}` — both fields optional, defaults
* are limit=100, archiveType=any.
*/
export async function processBackfillRequest(payloadJson: string): Promise<void> {
let limit = 100;
let archiveTypeFilter: "ZIP" | "RAR" | "SEVEN_Z" | undefined;
try {
const parsed = JSON.parse(payloadJson) as { limit?: number; archiveType?: string };
if (typeof parsed.limit === "number" && parsed.limit > 0) limit = parsed.limit;
if (parsed.archiveType === "ZIP" || parsed.archiveType === "RAR" || parsed.archiveType === "SEVEN_Z") {
archiveTypeFilter = parsed.archiveType;
}
} catch {
// Empty / invalid payload — use defaults
}
const candidates = await db.package.findMany({
where: {
fileCount: 0,
destChannelId: { not: null },
destMessageId: { not: null },
archiveType: archiveTypeFilter
? archiveTypeFilter
: { in: ["ZIP", "RAR", "SEVEN_Z"] },
},
select: {
id: true,
fileName: true,
fileSize: true,
archiveType: true,
destChannelId: true,
destMessageId: true,
destMessageIds: true,
isMultipart: true,
partCount: true,
},
orderBy: { createdAt: "asc" },
take: limit,
});
if (candidates.length === 0) {
log.info({ archiveTypeFilter }, "Backfill: no candidates with fileCount=0");
return;
}
log.info(
{ count: candidates.length, archiveTypeFilter },
"Backfill: starting batch"
);
const accounts = await getActiveAccounts();
if (accounts.length === 0) {
log.warn("Backfill: no authenticated accounts — aborting");
return;
}
// Prefer the Premium account if available (faster downloads, larger files)
const account = accounts.find((a) => a.isPremium) ?? accounts[0];
await withTdlibMutex(account.phone, "backfill", async () => {
const { client } = await createTdlibClient({ id: account.id, phone: account.phone });
try {
// Load chats so TDLib knows about the destination chat
try {
await client.invoke({
_: "getChats",
chat_list: { _: "chatListMain" },
limit: 1000,
});
} catch {
// May already be loaded
}
let processed = 0;
let succeeded = 0;
let failed = 0;
for (const pkg of candidates) {
processed++;
const ctx = { packageId: pkg.id, fileName: pkg.fileName };
try {
await processOnePackage(client, pkg, ctx);
succeeded++;
} catch (err) {
failed++;
log.warn({ err, ...ctx }, "Backfill failed for package");
}
}
log.info(
{ processed, succeeded, failed, archiveTypeFilter },
"Backfill batch complete"
);
} finally {
await closeTdlibClient(client).catch(() => {});
}
});
}
interface BackfillPackage {
id: string;
fileName: string;
fileSize: bigint;
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT" | string;
destChannelId: string | null;
destMessageId: bigint | null;
destMessageIds: bigint[];
isMultipart: boolean;
partCount: number;
}
async function processOnePackage(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
pkg: BackfillPackage,
ctx: { packageId: string; fileName: string }
): Promise<void> {
if (!pkg.destChannelId || !pkg.destMessageId) {
log.debug(ctx, "Skipping: no destination channel/message");
return;
}
// Look up the destination channel's Telegram ID
const destChannel = await db.telegramChannel.findUnique({
where: { id: pkg.destChannelId },
select: { telegramId: true },
});
if (!destChannel) {
throw new Error("Destination channel not found in DB");
}
const chatId = Number(destChannel.telegramId);
// Resolve which message IDs to download. The Package may carry a
// single destMessageId or multiple destMessageIds (for multipart).
const messageIds: bigint[] =
pkg.destMessageIds.length > 0
? pkg.destMessageIds
: pkg.destMessageId
? [pkg.destMessageId]
: [];
if (messageIds.length === 0) {
throw new Error("Package has no destination message IDs");
}
const tempDir = path.join(config.tempDir, `backfill_${pkg.id}`);
await mkdir(tempDir, { recursive: true });
try {
const partPaths: string[] = [];
for (let i = 0; i < messageIds.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = (await client.invoke({
_: "getMessage",
chat_id: chatId,
message_id: Number(messageIds[i]),
})) as unknown as {
content?: { document?: { file_name?: string; document?: { id: number; size: number } } };
};
const doc = message?.content?.document;
if (!doc?.document?.id) {
throw new Error(`Destination message ${messageIds[i]} has no document`);
}
const fileId = String(doc.document.id);
const fileName = doc.file_name ?? `${pkg.id}.part${i + 1}`;
const localPath = path.join(tempDir, fileName);
await downloadFile(
client,
fileId,
localPath,
BigInt(doc.document.size),
fileName
);
partPaths.push(localPath);
}
// Run the appropriate reader on the assembled file(s)
let entries: FileEntry[] = [];
if (pkg.archiveType === "ZIP") {
entries = await readZipCentralDirectory(partPaths);
} else if (pkg.archiveType === "RAR") {
// unrar auto-discovers sibling parts when in the same directory
entries = await readRarContents(partPaths[0]);
} else if (pkg.archiveType === "SEVEN_Z") {
entries = await read7zContents(partPaths[0]);
} else {
log.debug({ ...ctx, archiveType: pkg.archiveType }, "Skipping unsupported archive type");
return;
}
if (entries.length === 0) {
log.warn(ctx, "Reader returned 0 entries — archive may be encrypted or corrupt");
return;
}
// Also derive slicer tags from the file list so the backfilled packages
// gain the same search/filter context as newly-ingested ones.
const slicerTags = extractSlicerTags(entries);
// Write everything in a single transaction so a partial backfill never
// leaves the Package half-indexed.
await db.$transaction(async (tx) => {
// Re-check fileCount inside the transaction: another worker might
// have backfilled this package between our read and write.
const current = await tx.package.findUnique({
where: { id: pkg.id },
select: { fileCount: true, tags: true },
});
if (current && current.fileCount > 0) {
log.debug({ ...ctx, existingFileCount: current.fileCount }, "Already backfilled by another worker — skipping");
return;
}
await tx.packageFile.deleteMany({ where: { packageId: pkg.id } });
await tx.packageFile.createMany({
data: entries.map((e) => ({
packageId: pkg.id,
path: e.path,
fileName: e.fileName,
extension: e.extension,
compressedSize: e.compressedSize,
uncompressedSize: e.uncompressedSize,
crc32: e.crc32,
})),
});
// Merge slicer tags with whatever's already on the Package (preserve
// channel category, manual tags, etc.).
const existingTags = current?.tags ?? [];
const mergedTags = [...new Set([...existingTags, ...slicerTags])];
await tx.package.update({
where: { id: pkg.id },
data: { fileCount: entries.length, tags: mergedTags },
});
});
log.info({ ...ctx, fileCount: entries.length }, "Backfilled file list");
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Cheap pure-DB backfill: walk Packages that already have PackageFile rows
* but no slicer tags, recompute the tags from their extensions, and merge
* with the existing tag list. No downloads, no TDLib.
*
* Trigger:
* SELECT pg_notify('backfill_slicer_tags', '{"limit":1000}');
*/
export async function processSlicerTagBackfill(payloadJson: string): Promise<void> {
let limit = 1000;
try {
const parsed = JSON.parse(payloadJson) as { limit?: number };
if (typeof parsed.limit === "number" && parsed.limit > 0) limit = parsed.limit;
} catch {
// Use default
}
// KNOWN_TAGS = the slicer tags we know how to derive. A Package missing
// all of these is a candidate for recompute. extractSlicerTags is safe
// to run on every package (returns [] for archives with no slicer files),
// but filtering up-front avoids walking the entire DB.
const KNOWN_TAGS = ["lychee", "chitubox", "anycubic", "bambu", "fdm", "mango"];
const candidates = await db.package.findMany({
where: {
fileCount: { gt: 0 },
NOT: { tags: { hasSome: KNOWN_TAGS } },
},
select: {
id: true,
tags: true,
files: { select: { extension: true } },
},
orderBy: { createdAt: "asc" },
take: limit,
});
if (candidates.length === 0) {
log.info("Slicer tag backfill: no candidates");
return;
}
log.info({ count: candidates.length }, "Slicer tag backfill: starting");
let updated = 0;
for (const pkg of candidates) {
const fileEntries = pkg.files.map((f) => ({
path: "",
fileName: "",
extension: f.extension,
compressedSize: 0n,
uncompressedSize: 0n,
crc32: null as string | null,
}));
const slicerTags = extractSlicerTags(fileEntries);
if (slicerTags.length === 0) continue;
const merged = [...new Set([...pkg.tags, ...slicerTags])];
if (merged.length === pkg.tags.length) continue;
await db.package.update({ where: { id: pkg.id }, data: { tags: merged } });
updated++;
}
log.info({ candidates: candidates.length, updated }, "Slicer tag backfill: done");
}

View File

@@ -82,6 +82,8 @@ export interface CreatePackageStubInput {
sourceChannelId: string;
sourceMessageId: bigint;
sourceTopicId?: bigint | null;
/** TDLib remote.unique_id of the first part — for future dedup. */
remoteUniqueId?: string | null;
destChannelId: string;
destMessageId: bigint;
destMessageIds: bigint[];
@@ -111,6 +113,7 @@ export async function createPackageStub(
sourceChannelId: input.sourceChannelId,
sourceMessageId: input.sourceMessageId,
sourceTopicId: input.sourceTopicId ?? undefined,
remoteUniqueId: input.remoteUniqueId ?? undefined,
destChannelId: input.destChannelId,
destMessageId: input.destMessageId,
destMessageIds: input.destMessageIds,
@@ -189,6 +192,73 @@ export async function packageExistsBySourceMessage(
return pkg !== null;
}
/**
* Strongest pre-download dedup signal: a Package in this channel already
* has a matching TDLib remote.unique_id. The unique_id is stable across
* reposts of the exact same file content, so a hit is a guaranteed
* (lossless) duplicate. No false positives.
*
* Falls back to the older findRepostedPackage (name + size) for packages
* that were ingested before we started capturing remote.unique_id.
*/
export async function findPackageByRemoteUniqueId(
sourceChannelId: string,
remoteUniqueId: string
): Promise<{
id: string;
destMessageId: bigint | null;
sourceTopicId: bigint | null;
} | null> {
return db.package.findFirst({
where: {
sourceChannelId,
remoteUniqueId,
destMessageId: { not: null },
},
orderBy: { sourceTopicId: { sort: "desc", nulls: "last" } },
select: { id: true, destMessageId: true, sourceTopicId: true },
});
}
/**
* Detect a likely repost: same source channel + same fileName + same total
* fileSize already exists with destMessageId set. Used to skip downloads
* when the channel admin re-posts the same file under a new message ID
* (which `packageExistsBySourceMessage` cannot catch because the message ID
* is different).
*
* Returns the existing package's destMessageId for logging/observability,
* or null if no match. Approximate: same name + same total size is an
* extremely strong signal that it's the same content, but theoretically
* two unrelated files could collide. If that ever happens, the new file
* gets treated as a duplicate and is lost; the user can manually re-link
* via the UI by removing the existing Package.
*/
export async function findRepostedPackage(
sourceChannelId: string,
fileName: string,
fileSize: bigint
): Promise<{
id: string;
destMessageId: bigint | null;
sourceTopicId: bigint | null;
} | null> {
return db.package.findFirst({
where: {
sourceChannelId,
fileName,
fileSize,
destMessageId: { not: null },
},
// Prefer the existing Package with the most specific (non-NULL)
// sourceTopicId, so when the user is re-scanning and the file already
// exists in a specific topic, the audit log shows the most informative
// match. NULLS LAST in DESC order achieves that for any non-null IDs.
orderBy: { sourceTopicId: { sort: "desc", nulls: "last" } },
select: { id: true, destMessageId: true, sourceTopicId: true },
});
}
/**
* Delete orphaned Package rows that have the same content hash but never
* completed the upload (destMessageId is null). Called before creating a
@@ -299,6 +369,8 @@ export interface ActivityUpdate {
downloadedBytes?: bigint | null;
totalBytes?: bigint | null;
downloadPercent?: number | null;
currentTopicId?: bigint | null;
currentAccountChannelMapId?: string | null;
messagesScanned?: number;
zipsFound?: number;
zipsDuplicate?: number;
@@ -326,6 +398,10 @@ export async function updateRunActivity(
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
...(activity.currentTopicId !== undefined && { currentTopicId: activity.currentTopicId }),
...(activity.currentAccountChannelMapId !== undefined && {
currentAccountChannelMapId: activity.currentAccountChannelMapId,
}),
},
});
}
@@ -340,6 +416,8 @@ const CLEAR_ACTIVITY = {
downloadedBytes: null,
totalBytes: null,
downloadPercent: null,
currentTopicId: null,
currentAccountChannelMapId: null,
lastActivityAt: new Date(),
};
@@ -385,6 +463,73 @@ export async function updateLastProcessedMessage(
});
}
export interface ScanStateUpdate {
/** New watermark to persist. Use the same value the caller would have
* passed to updateLastProcessedMessage / upsertTopicProgress. */
lastProcessedMessageId: bigint | null;
/** True if the scan found archives OR has retryable SkippedPackages
* pending. The caller computes this via the trulyIdle formula. */
lastScanFoundArchives: boolean;
/** Pre-incremented value of consecutiveEmptyScans. Caller passes:
* trulyIdle ? prev + 1 : 0
* We do the arithmetic outside the helper so the helper stays a pure
* setter — easier to reason about. */
consecutiveEmptyScans: number;
}
/**
* Atomically update an AccountChannelMap's watermark and scan-state fields.
* Replaces the older updateLastProcessedMessage for the post-scan write.
* Sets lastScannedAt = NOW() server-side.
*/
export async function upsertChannelScanState(
mappingId: string,
update: ScanStateUpdate
) {
return db.accountChannelMap.update({
where: { id: mappingId },
data: {
lastProcessedMessageId: update.lastProcessedMessageId ?? undefined,
lastScannedAt: new Date(),
lastScanFoundArchives: update.lastScanFoundArchives,
consecutiveEmptyScans: update.consecutiveEmptyScans,
},
});
}
/**
* Atomically upsert a TopicProgress row with the new watermark + scan-state
* fields. Same semantics as upsertChannelScanState but for forum topics.
*/
export async function upsertTopicScanState(
accountChannelMapId: string,
topicId: bigint,
topicName: string | null,
update: ScanStateUpdate
) {
return db.topicProgress.upsert({
where: {
accountChannelMapId_topicId: { accountChannelMapId, topicId },
},
create: {
accountChannelMapId,
topicId,
topicName,
lastProcessedMessageId: update.lastProcessedMessageId,
lastScannedAt: new Date(),
lastScanFoundArchives: update.lastScanFoundArchives,
consecutiveEmptyScans: update.consecutiveEmptyScans,
},
update: {
topicName,
lastProcessedMessageId: update.lastProcessedMessageId ?? undefined,
lastScannedAt: new Date(),
lastScanFoundArchives: update.lastScanFoundArchives,
consecutiveEmptyScans: update.consecutiveEmptyScans,
},
});
}
export async function markStaleRunsAsFailed() {
return db.ingestionRun.updateMany({
where: { status: "RUNNING" },
@@ -522,6 +667,45 @@ export async function upsertTopicProgress(
});
}
/**
* Ensure a TopicProgress row exists for every discovered topic so the admin
* UI can list and toggle them — including brand-new topics. Inserts missing
* rows only (fetchEnabled defaults to true); never overwrites watermarks,
* scan-state, or the user's fetchEnabled choice on rows that already exist.
*/
export async function ensureTopicProgressRows(
mappingId: string,
topics: { topicId: bigint; name: string | null }[]
): Promise<void> {
if (topics.length === 0) return;
await db.topicProgress.createMany({
data: topics.map((t) => ({
accountChannelMapId: mappingId,
topicId: t.topicId,
topicName: t.name,
})),
skipDuplicates: true,
});
}
/**
* Read the CURRENT per-topic fetch flag straight from the DB. Returns true
* when no row exists yet (default = enabled). Called fresh inside the topic
* loop so a mid-run toggle is honoured for topics not yet started.
*/
export async function isTopicFetchEnabled(
mappingId: string,
topicId: bigint
): Promise<boolean> {
const row = await db.topicProgress.findUnique({
where: {
accountChannelMapId_topicId: { accountChannelMapId: mappingId, topicId },
},
select: { fetchEnabled: true },
});
return row?.fetchEnabled ?? true;
}
// ── Channel fetch requests (DB-mediated communication with web app) ──
export async function getChannelFetchRequest(requestId: string) {
@@ -625,6 +809,7 @@ export async function upsertSkippedPackage(data: {
errorMessage: data.errorMessage ?? null,
fileName: data.fileName,
fileSize: data.fileSize,
attemptCount: { increment: 1 },
createdAt: new Date(),
},
create: {
@@ -642,6 +827,26 @@ export async function upsertSkippedPackage(data: {
});
}
/**
* Return source-message IDs in a channel whose SkippedPackage attemptCount has
* reached or exceeded the cap — these are treated as "permanently failed for
* now" so the watermark can advance past them. The user can manually retry via
* the UI to reset the SkippedPackage record.
*/
export async function getCappedSkippedMessageIds(
sourceChannelId: string,
cap: number
): Promise<Set<bigint>> {
const rows = await db.skippedPackage.findMany({
where: {
sourceChannelId,
attemptCount: { gte: cap },
},
select: { sourceMessageId: true },
});
return new Set(rows.map((r) => r.sourceMessageId));
}
export async function deleteSkippedPackage(
sourceChannelId: string,
sourceMessageId: bigint
@@ -651,6 +856,63 @@ export async function deleteSkippedPackage(
});
}
/**
* Find SkippedPackages for a given account+channel that are still eligible
* for auto-retry (attemptCount below the cap). Used at the start of a scan
* to pull the watermark back so we don't strand failed messages forever
* after the watermark has advanced past them.
*
* For non-forum channels, pass `topicId: null` to get rows with NULL topic.
* For forum channels, pass the topic ID to scope to that topic only.
*/
export async function getRetryableSkippedMessageIds(args: {
accountId: string;
sourceChannelId: string;
topicId: bigint | null;
cap: number;
}): Promise<bigint[]> {
const rows = await db.skippedPackage.findMany({
where: {
accountId: args.accountId,
sourceChannelId: args.sourceChannelId,
sourceTopicId: args.topicId,
attemptCount: { lt: args.cap },
},
select: { sourceMessageId: true },
orderBy: { sourceMessageId: "asc" },
});
return rows.map((r) => r.sourceMessageId);
}
/**
* Update a Package's source topic when a more specific topic context is
* discovered for the same content. Used when findRepostedPackage matches
* an existing Package whose topic is less specific (e.g., "General") than
* the topic we just encountered the file in.
*
* Also updates the creator if the new topic name is more informative than
* the existing creator (i.e., the existing creator was derived from a
* less-specific topic name like "General").
*/
export async function updatePackageTopicContext(
packageId: string,
newTopicId: bigint,
newTopicName: string | null
): Promise<void> {
await db.package.update({
where: { id: packageId },
data: {
sourceTopicId: newTopicId,
// Only overwrite creator if the new topic name is meaningful (non-empty,
// non-General). Keeps explicit creator values from filename or admin
// input intact.
...(newTopicName && newTopicName !== "General"
? { creator: newTopicName }
: {}),
},
});
}
export async function createOrFindPackageGroup(input: {
mediaAlbumId: string;
sourceChannelId: string;

View File

@@ -6,6 +6,7 @@ import { processFetchRequest } from "./worker.js";
import { processExtractRequest } from "./extract-listener.js";
import { rebuildPackageDatabase } from "./rebuild.js";
import { processManualUpload } from "./manual-upload.js";
import { processBackfillRequest, processSlicerTagBackfill } from "./backfill.js";
import { generateInviteLink, createSupergroup, searchPublicChat } from "./tdlib/chats.js";
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
import { triggerImmediateCycle } from "./scheduler.js";
@@ -58,6 +59,8 @@ async function connectListener(): Promise<void> {
await pgClient.query("LISTEN archive_extract");
await pgClient.query("LISTEN rebuild_packages");
await pgClient.query("LISTEN manual_upload");
await pgClient.query("LISTEN backfill_filelists");
await pgClient.query("LISTEN backfill_slicer_tags");
pgClient.on("notification", (msg) => {
if (msg.channel === "channel_fetch" && msg.payload) {
@@ -76,6 +79,10 @@ async function connectListener(): Promise<void> {
handleRebuildPackages(msg.payload);
} else if (msg.channel === "manual_upload" && msg.payload) {
handleManualUpload(msg.payload);
} else if (msg.channel === "backfill_filelists") {
handleBackfillFilelists(msg.payload ?? "{}");
} else if (msg.channel === "backfill_slicer_tags") {
handleBackfillSlicerTags(msg.payload ?? "{}");
}
});
@@ -101,7 +108,7 @@ async function connectListener(): Promise<void> {
}
});
log.info("Fetch listener started (channel_fetch, generate_invite, create_destination, ingestion_trigger, join_channel, archive_extract, rebuild_packages, manual_upload)");
log.info("Fetch listener started (channel_fetch, generate_invite, create_destination, ingestion_trigger, join_channel, archive_extract, rebuild_packages, manual_upload, backfill_filelists, backfill_slicer_tags)");
} catch (err) {
log.error({ err }, "Failed to start fetch listener — retrying");
scheduleReconnect();
@@ -528,3 +535,30 @@ function handleManualUpload(uploadId: string): void {
.then(() => processManualUpload(uploadId))
.catch((err) => log.error({ err, uploadId }, "Manual upload processing failed"));
}
// ── Backfill file-list handler ──
//
// Trigger via:
// SELECT pg_notify('backfill_filelists', '{"limit":50,"archiveType":"RAR"}');
//
// Both fields are optional. archiveType filters to one of ZIP/RAR/SEVEN_Z.
// Default limit is 100. The handler queues so multiple notifications run
// sequentially (no concurrent TDLib downloads competing for the mutex).
function handleBackfillFilelists(payload: string): void {
fetchQueue = fetchQueue
.then(() => processBackfillRequest(payload))
.catch((err) => log.error({ err, payload }, "Backfill request failed"));
}
// ── Slicer tag backfill handler ──
//
// Trigger:
// SELECT pg_notify('backfill_slicer_tags', '{"limit":1000}');
//
// Pure-DB pass over Packages that have file lists but no slicer tags.
// No downloads, no TDLib involvement — fast and safe.
function handleBackfillSlicerTags(payload: string): void {
fetchQueue = fetchQueue
.then(() => processSlicerTagBackfill(payload))
.catch((err) => log.error({ err, payload }, "Slicer tag backfill failed"));
}

View File

@@ -308,14 +308,15 @@ async function scanDestinationChannel(
}>(client, {
_: "searchChatMessages",
chat_id: Number(chatId),
// No topic context for a flat destination scan. TDLib 1.8.64+ replaced
// `message_thread_id` / `saved_messages_topic_id` with a single
// optional `topic_id`; for a flat scan we just omit it.
query: "",
from_message_id: currentFromId,
offset: 0,
limit: 100,
filter: { _: "searchMessagesFilterDocument" },
sender_id: null,
message_thread_id: 0,
saved_messages_topic_id: 0,
});
if (!result.messages || result.messages.length === 0) break;

View File

@@ -78,18 +78,31 @@ export async function recoverIncompleteUploads(): Promise<void> {
let resetCount = 0;
let verifiedCount = 0;
let unknownCount = 0;
let wrongContentCount = 0;
// Batch size for getMessages. TDLib accepts up to ~100 IDs per call.
// Using 100 means 20k packages → ~200 round-trips instead of 20k.
const BATCH_SIZE = 100;
for (const [, channelPackages] of byChannel) {
for (const pkg of channelPackages) {
const exists = await verifyMessageExists(
// Group packages by destChannelId (already done) — within each group,
// process in batches via getMessages (plural).
for (let i = 0; i < channelPackages.length; i += BATCH_SIZE) {
const batch = channelPackages.slice(i, i + BATCH_SIZE);
const batchResults = await verifyMessagesBatch(
client,
destChannel.telegramId,
pkg.destMessageId!
batch.map((p) => p.destMessageId!)
);
if (exists) {
verifiedCount++;
} else {
for (let j = 0; j < batch.length; j++) {
const pkg = batch[j];
const result = batchResults[j];
if (result.state === "exists") {
verifiedCount++;
} else if (result.state === "deleted") {
log.warn(
{
packageId: pkg.id,
@@ -100,21 +113,50 @@ export async function recoverIncompleteUploads(): Promise<void> {
);
await resetPackageDestination(pkg.id);
resetCount++;
} else if (result.state === "wrong-content") {
// The message exists but isn't a document anymore (got cleared /
// replaced). Treat as missing so we re-upload.
log.warn(
{
packageId: pkg.id,
fileName: pkg.fileName,
destMessageId: Number(pkg.destMessageId),
contentType: result.contentType,
},
"Destination message is not a document, resetting package for re-upload"
);
await resetPackageDestination(pkg.id);
wrongContentCount++;
} else {
// Unknown — TDLib couldn't tell us. Don't reset, but DO count this
// so the summary line shows recovery wasn't 100% successful.
unknownCount++;
log.warn(
{
packageId: pkg.id,
fileName: pkg.fileName,
destMessageId: Number(pkg.destMessageId),
reason: result.reason.slice(0, 200),
},
"Could not verify destination message — will retry on next startup"
);
}
}
}
}
if (resetCount > 0) {
log.info(
{ resetCount, verifiedCount, totalChecked: packages.length },
"Upload recovery complete — packages reset for re-processing"
);
} else {
log.info(
{ verifiedCount, totalChecked: packages.length },
"Upload recovery complete — all destination messages verified"
);
}
log.info(
{
verifiedCount,
resetCount,
wrongContentCount,
unknownCount,
totalChecked: packages.length,
},
unknownCount === 0
? "Upload recovery complete"
: "Upload recovery complete — some packages could not be verified, will retry next startup"
);
} catch (err) {
log.error({ err }, "Upload recovery failed (non-fatal, will retry next startup)");
} finally {
@@ -124,15 +166,77 @@ export async function recoverIncompleteUploads(): Promise<void> {
}
}
type VerifyResult =
| { state: "exists" }
| { state: "deleted" }
| { state: "wrong-content"; contentType: string }
| { state: "unknown"; reason: string };
/**
* Check whether a message exists in a Telegram chat.
* Returns false if the message was deleted or never existed.
* Batch version of verifyMessageExists. Calls TDLib's getMessages (plural)
* with up to ~100 message IDs at once. Returns one VerifyResult per input
* ID, in input order. Missing messages come back as null in TDLib's response
* — translated to {state: "deleted"} here.
*
* Falls back to per-message verification on any error so that one bad batch
* doesn't lose all verification for that chunk.
*/
async function verifyMessagesBatch(
client: Client,
chatTelegramId: bigint,
messageIds: bigint[]
): Promise<VerifyResult[]> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (await withFloodWait(
() =>
client.invoke({
_: "getMessages",
chat_id: Number(chatTelegramId),
message_ids: messageIds.map((id) => Number(id)),
}),
"getMessages:verify"
)) as { messages?: (null | { content?: { _: string } })[] };
const messages = result.messages ?? [];
return messageIds.map((_id, i) => {
const m = messages[i];
if (!m || !m.content) return { state: "deleted" };
if (m.content._ !== "messageDocument") {
return { state: "wrong-content", contentType: String(m.content._) };
}
return { state: "exists" };
});
} catch (err) {
// If the whole batch errors out, fall back to per-message verification.
log.warn(
{ err, batchSize: messageIds.length, chatTelegramId: chatTelegramId.toString() },
"getMessages batch failed, falling back to per-message verification"
);
const out: VerifyResult[] = [];
for (const id of messageIds) {
out.push(await verifyMessageExists(client, chatTelegramId, id));
}
return out;
}
}
/**
* Check whether a message exists in a Telegram chat and is the document we
* uploaded. Returns a discriminated result instead of a bare boolean so the
* caller can distinguish "definitely gone" (reset) from "couldn't reach TG"
* (leave alone, try again next startup).
*
* Previous version conflated all non-404 errors with "exists", which masked
* recovery completely when TDLib had a degraded connection — the worker
* would log "all destination messages verified" even though it had answered
* questions it couldn't actually answer.
*/
async function verifyMessageExists(
client: Client,
chatTelegramId: bigint,
messageId: bigint
): Promise<boolean> {
): Promise<VerifyResult> {
try {
const result = await withFloodWait(
() =>
@@ -144,44 +248,37 @@ async function verifyMessageExists(
"getMessage:verify"
);
// TDLib returns the message object if it exists.
// A deleted message may return with content type "messageChatDeleteMessage"
// or the call may throw. Check that we got a real message with content.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msg = result as any;
if (!msg || !msg.content) {
return false;
return { state: "deleted" };
}
// Check that the message has document content (our uploads are documents)
// A message that exists but has no document content was likely cleared/replaced
if (msg.content._ !== "messageDocument") {
log.debug(
{
messageId: Number(messageId),
contentType: msg.content._,
},
"Destination message exists but is not a document"
);
return false;
return { state: "wrong-content", contentType: String(msg.content._) };
}
return true;
return { state: "exists" };
} catch (err) {
// TDLib throws "Message not found" (error code 404) for deleted messages
const message = err instanceof Error ? err.message : String(err);
const errMessage = err instanceof Error ? err.message : String(err);
const code = (err as { code?: number })?.code;
if (code === 404 || message.includes("not found") || message.includes("Not Found")) {
return false;
// Hard "the message is definitely gone" signals from TDLib:
// - HTTP 404
// - "Message not found" / "MESSAGE_ID_INVALID" error strings
const lower = errMessage.toLowerCase();
if (
code === 404 ||
lower.includes("message not found") ||
lower.includes("message_id_invalid") ||
lower.includes("messageidinvalid") ||
lower.includes("not found")
) {
return { state: "deleted" };
}
// For other errors (network issues, etc.), assume the message exists
// to avoid incorrectly resetting packages due to transient failures
log.warn(
{ err, messageId: Number(messageId) },
"Could not verify message (assuming it exists)"
);
return true;
// Everything else (network, connection, TDLib internal) is genuinely
// unknown — do NOT claim "verified".
return { state: "unknown", reason: errMessage };
}
}

View File

@@ -19,6 +19,12 @@ let activeCyclePromise: Promise<void> | null = null;
*/
const CYCLE_TIMEOUT_MS = (parseInt(process.env.WORKER_CYCLE_TIMEOUT_MINUTES ?? "240", 10)) * 60 * 1000;
/** Read-only access to the current cycle counter for code that needs to
* apply per-cycle modulo logic (e.g. the cold-channel backoff). */
export function getCurrentCycle(): number {
return cycleCount;
}
/**
* Run one ingestion cycle:
* 1. Authenticate any PENDING accounts (triggers SMS code flow + auto-fetch channels)

View File

@@ -5,6 +5,36 @@ import { withFloodWait } from "../util/retry.js";
const log = childLogger("chats");
/**
* Collect chat folder IDs to widen the loadChats sweep across all folder
* chat lists. In TDLib 1.8.64+ there's no synchronous getChatFolders call —
* the folder list arrives via updateChatFolders. We listen for it briefly
* (200ms) and fall back to an empty list if nothing arrives; chats inside
* folders are still reachable via chatListMain so this only loses some
* preemptive cache warming.
*/
async function collectFolderIds(
client: Client
): Promise<{ _: "chatListFolder"; chat_folder_id: number }[]> {
return new Promise((resolve) => {
const ids: number[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handler = (update: any) => {
if (update?._ === "updateChatFolders") {
const folders = update.chat_folders as { id: number }[] | undefined;
if (folders) {
for (const f of folders) ids.push(f.id);
}
}
};
client.on("update", handler);
setTimeout(() => {
client.off("update", handler);
resolve(ids.map((id) => ({ _: "chatListFolder" as const, chat_folder_id: id })));
}, 200);
});
}
export interface TelegramChatInfo {
chatId: bigint;
title: string;
@@ -37,21 +67,16 @@ export async function getAccountChats(
// First, load all chats into TDLib's cache using loadChats (the proper API).
// loadChats returns 404 when all chats have been loaded.
// Then use getChats to retrieve the IDs for enrichment.
// Load from main, archive, AND chat folders to cover all chat types.
const folderLists: { _: "chatListFolder"; chat_folder_id: number }[] = [];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const folders = (await client.invoke({ _: "getChatFolders" })) as any;
if (folders?.chat_folders) {
for (const f of folders.chat_folders) {
folderLists.push({ _: "chatListFolder", chat_folder_id: f.id });
}
}
} catch {
// getChatFolders may not be available in older TDLib versions
}
//
// Folder-specific loading (chatListFolder) was removed in TDLib 1.8.64+ —
// getChatFolders (plural) is no longer a callable method, only the
// updateChatFolders event. The chats inside folders are still reachable
// via chatListMain so this isn't a functional regression.
const folderLists: { _: "chatListFolder"; chat_folder_id: number }[] =
await collectFolderIds(client);
const chatLists: Record<string, unknown>[] = [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chatLists: any[] = [
{ _: "chatListMain" },
{ _: "chatListArchive" },
...folderLists,
@@ -282,3 +307,63 @@ export async function searchPublicChat(
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Return the chat's server-side last_message.id from TDLib's local cache.
* Used by the channel-scan-skip guard to short-circuit a paginated
* searchChatMessages when nothing has changed since our watermark.
*
* Returns null when the chat has no last_message (empty channel) or the
* call fails — callers must treat null as "unknown" and run the scan.
*/
export async function getChannelLastMessageId(
client: Client,
chatId: bigint
): Promise<bigint | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chat = (await client.invoke({
_: "getChat",
chat_id: Number(chatId),
})) as { last_message?: { id?: number } };
const id = chat.last_message?.id;
return id ? BigInt(id) : null;
} catch (err) {
log.debug({ err, chatId: chatId.toString() }, "getChannelLastMessageId failed");
return null;
}
}
/**
* Return the forum topic's last_message_id from TDLib. Same purpose as
* getChannelLastMessageId but scoped to a single topic in a forum
* supergroup. TDLib's `getForumTopic` returns a `forumTopic` whose `info`
* field contains the last_message_id.
*
* Returns null on failure or empty topic — caller treats as "unknown".
*/
export async function getForumTopicLastMessageId(
client: Client,
chatId: bigint,
topicId: bigint
): Promise<bigint | null> {
try {
// TDLib 1.8.64 uses `forum_topic_id` (renamed from `message_thread_id`
// in the request) — consistent with the rest of the forum-topic API
// surface in this version.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const topic = (await client.invoke({
_: "getForumTopic",
chat_id: Number(chatId),
forum_topic_id: Number(topicId),
})) as { last_message?: { id?: number }; info?: { last_message_id?: number } };
const id = topic.last_message?.id ?? topic.info?.last_message_id;
return id ? BigInt(id) : null;
} catch (err) {
log.debug(
{ err, chatId: chatId.toString(), topicId: topicId.toString() },
"getForumTopicLastMessageId failed"
);
return null;
}
}

View File

@@ -39,7 +39,15 @@ interface TdMessage {
id: number;
date: number;
media_album_id?: string;
// TDLib 1.8.50 exposed `reply_to_message_id` directly on the message.
// 1.8.64+ replaced it with a tagged-union `reply_to: MessageReplyTo`.
// Read both for resilience across versions.
reply_to_message_id?: number;
reply_to?: {
_: string;
chat_id?: number;
message_id?: number;
};
content: {
_: string;
document?: {
@@ -51,6 +59,10 @@ interface TdMessage {
path?: string;
is_downloading_completed?: boolean;
};
remote?: {
/** Stable identifier across reposts of the same file content. */
unique_id?: string;
};
};
};
photo?: {
@@ -62,6 +74,24 @@ interface TdMessage {
};
}
/**
* Pick the right "the message I'm replying to" ID across TDLib versions.
* - 1.8.50 and earlier expose it directly as `reply_to_message_id`.
* - 1.8.64+ expose `reply_to: MessageReplyTo` (tagged union); a reply to
* a regular message has `_: "messageReplyToMessage"` with `message_id`.
* - Story replies (`_: "messageReplyToStory"`) intentionally return null
* here — they aren't useful for our reply-chain grouping.
*/
function extractReplyToMessageId(msg: TdMessage): bigint | undefined {
if (msg.reply_to_message_id) {
return BigInt(msg.reply_to_message_id);
}
if (msg.reply_to && msg.reply_to._ === "messageReplyToMessage" && msg.reply_to.message_id) {
return BigInt(msg.reply_to.message_id);
}
return undefined;
}
interface TdFile {
id: number;
size: number;
@@ -115,7 +145,10 @@ export async function invokeWithTimeout<T>(
}
}, timeoutMs);
(client.invoke(request) as Promise<T>)
// The tdl 8.1+ types are very strict about the literal `_` field;
// our generic wrapper passes arbitrary requests, so cast through any.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(client.invoke(request as any) as Promise<T>)
.then((result) => {
if (!settled) {
settled = true;
@@ -195,12 +228,14 @@ export async function getChannelMessages(
const result = await invokeWithTimeout<{ messages: TdMessage[]; total_count?: number }>(client, {
_: "searchChatMessages",
chat_id: Number(chatId),
// No topic_id for a flat (non-forum) channel scan. TDLib 1.8.64+
// dropped the top-level `message_thread_id: 0` we used to pass; the
// type-narrow now is "omit the field entirely if not in a topic".
query: "",
from_message_id: fromMessageId,
offset: 0,
limit: Math.min(limit, 100),
filter,
message_thread_id: 0,
});
if (!result.messages || result.messages.length === 0) break;
@@ -226,8 +261,9 @@ export async function getChannelMessages(
fileSize: BigInt(doc.document.size),
date: new Date(msg.date * 1000),
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
replyToMessageId: msg.reply_to_message_id ? BigInt(msg.reply_to_message_id) : undefined,
replyToMessageId: extractReplyToMessageId(msg),
caption: msg.content?.caption?.text || undefined,
remoteUniqueId: doc.document.remote?.unique_id || undefined,
});
continue;
}

View File

@@ -64,7 +64,11 @@ export async function getForumTopicList(
const topics: ForumTopic[] = [];
let offsetDate = 0;
let offsetMessageId = 0;
let offsetMessageThreadId = 0;
// TDLib 1.8.64+ renamed `offset_message_thread_id` → `offset_forum_topic_id`
// in the getForumTopics request, and `next_offset_message_thread_id` →
// `next_offset_forum_topic_id` in the response. Individual topic infos
// also moved from `info.message_thread_id` → `info.forum_topic_id`.
let offsetForumTopicId = 0;
let pageCount = 0;
// eslint-disable-next-line no-constant-condition
@@ -80,12 +84,16 @@ export async function getForumTopicList(
const prevOffsetDate = offsetDate;
const prevOffsetMessageId = offsetMessageId;
const prevOffsetMessageThreadId = offsetMessageThreadId;
const prevOffsetForumTopicId = offsetForumTopicId;
const result = await invokeWithTimeout<{
topics?: {
info?: {
// Both names — 1.8.50 used the first, 1.8.64+ uses the second.
// Read both so a future TDLib downgrade or transition build is
// still handled.
message_thread_id?: number;
forum_topic_id?: number;
name?: string;
is_general?: boolean;
};
@@ -93,45 +101,49 @@ export async function getForumTopicList(
next_offset_date?: number;
next_offset_message_id?: number;
next_offset_message_thread_id?: number;
next_offset_forum_topic_id?: number;
}>(client, {
_: "getForumTopics",
chat_id: Number(chatId),
query: "",
offset_date: offsetDate,
offset_message_id: offsetMessageId,
offset_message_thread_id: offsetMessageThreadId,
offset_forum_topic_id: offsetForumTopicId,
limit: 100,
});
if (!result.topics || result.topics.length === 0) break;
for (const t of result.topics) {
if (!t.info?.message_thread_id) continue;
const topicId = t.info?.forum_topic_id ?? t.info?.message_thread_id;
if (!topicId) continue;
topics.push({
topicId: BigInt(t.info.message_thread_id),
name: t.info.is_general ? "General" : (t.info.name ?? "Unnamed"),
topicId: BigInt(topicId),
name: t.info?.is_general ? "General" : (t.info?.name ?? "Unnamed"),
});
}
// Check if there are more pages
const nextForumTopicId =
result.next_offset_forum_topic_id ?? result.next_offset_message_thread_id;
if (
!result.next_offset_date &&
!result.next_offset_message_id &&
!result.next_offset_message_thread_id
!nextForumTopicId
) {
break;
}
offsetDate = result.next_offset_date ?? 0;
offsetMessageId = result.next_offset_message_id ?? 0;
offsetMessageThreadId = result.next_offset_message_thread_id ?? 0;
offsetForumTopicId = nextForumTopicId ?? 0;
// Stuck detection: if offsets didn't advance, break
if (
offsetDate === prevOffsetDate &&
offsetMessageId === prevOffsetMessageId &&
offsetMessageThreadId === prevOffsetMessageThreadId
offsetForumTopicId === prevOffsetForumTopicId
) {
log.warn(
{ chatId: chatId.toString(), topicCount: topics.length },
@@ -210,6 +222,7 @@ export async function getTopicMessages(
document?: {
id: number;
size: number;
remote?: { unique_id?: string };
};
};
photo?: {
@@ -226,14 +239,20 @@ export async function getTopicMessages(
}>(client, {
_: "searchChatMessages",
chat_id: Number(chatId),
// TDLib 1.8.64+ replaced the top-level `message_thread_id` and
// `saved_messages_topic_id` parameters with a single tagged-union
// `topic_id: MessageTopic$Input`. For a forum topic, use the
// messageTopicForum variant carrying the forum_topic_id.
topic_id: {
_: "messageTopicForum",
forum_topic_id: Number(topicId),
},
query: "",
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,
});
if (!result.messages || result.messages.length === 0) break;
@@ -257,6 +276,7 @@ export async function getTopicMessages(
fileSize: BigInt(doc.document.size),
date: new Date(msg.date * 1000),
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
remoteUniqueId: doc.document.remote?.unique_id || undefined,
});
continue;
}

View File

@@ -119,23 +119,43 @@ async function sendWithRetry(
continue;
}
// Stall or timeout — retry with a cooldown
// Stall or timeout — fail fast and let the caller recreate the TDLib
// client. Retrying on the same degraded event stream wastes ~15 min
// per attempt because the underlying issue (missing send-success
// events) is client-level, not transient. The set ends up in
// SkippedPackage and the caller's watermark cap ensures it gets
// retried next cycle on a fresh client.
const errMsg = err instanceof Error ? err.message : "";
if (errMsg.includes("stalled") || errMsg.includes("timed out")) {
if (!isLastAttempt) {
log.warn(
{ fileName, attempt: attempt + 1, maxRetries: MAX_UPLOAD_RETRIES },
"Upload stalled/timed out — retrying"
);
await sleep(10_000);
continue;
}
// All stall retries exhausted — throw UploadStallError so the caller
// knows the TDLib client's event stream is likely degraded and can
// recreate the client before continuing.
throw new UploadStallError(
`Upload stalled after ${MAX_UPLOAD_RETRIES} retries for ${fileName}`
log.warn(
{ fileName, attempt: attempt + 1 },
"Upload stalled — failing fast so caller can recreate TDLib client"
);
throw new UploadStallError(
`Upload stalled for ${fileName}: ${errMsg}`
);
}
// Transient Telegram server-side error (HTTP 5xx returned via
// updateMessageSendFailed). These are NOT FLOOD_WAIT, NOT stalls — just
// TG having a bad moment. They typically resolve on a short backoff, so
// retry up to MAX_UPLOAD_RETRIES with linear backoff before giving up.
const lowerMsg = errMsg.toLowerCase();
const isTransientServerError =
lowerMsg.includes("internal server error") ||
lowerMsg.includes("internal error") ||
lowerMsg.includes("server error") ||
lowerMsg.includes("bad gateway") ||
lowerMsg.includes("service unavailable") ||
lowerMsg.includes("gateway timeout");
if (isTransientServerError && !isLastAttempt) {
const backoffMs = 15_000 * (attempt + 1) + Math.random() * 5_000;
log.warn(
{ fileName, attempt: attempt + 1, maxRetries: MAX_UPLOAD_RETRIES, backoffMs: Math.round(backoffMs) },
`Transient Telegram server error — retrying after backoff`
);
await sleep(backoffMs);
continue;
}
throw err;
@@ -168,6 +188,12 @@ async function sendAndWaitForUpload(
let lastProgressBytes = 0;
let lastProgressTime = Date.now();
// Events for our message can arrive before `sendMessage` resolves
// (TDLib emits them while our .then() is still in the microtask queue).
// Buffer them and replay once tempMsgId is known.
let pendingSuccess: { oldMsgId: number; finalId: number } | null = null;
let pendingFailure: { oldMsgId: number; errorMsg: string; code?: number } | null = null;
// Timeout: 20 minutes per GB, minimum 15 minutes
const timeoutMs = Math.max(
15 * 60_000,
@@ -204,6 +230,26 @@ async function sendAndWaitForUpload(
}
}, 30_000);
const completeWithSuccess = (finalId: number) => {
if (settled) return;
settled = true;
cleanup();
log.info(
{ fileName, tempMsgId, finalMsgId: finalId },
"Upload confirmed by Telegram"
);
resolve(BigInt(finalId));
};
const completeWithFailure = (errorMsg: string, code?: number) => {
if (settled) return;
settled = true;
cleanup();
const error = new Error(`Upload failed for ${fileName}: ${errorMsg}`);
(error as Error & { code?: number }).code = code;
reject(error);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleUpdate = (update: any) => {
// Track upload progress via updateFile events
@@ -234,33 +280,29 @@ async function sendAndWaitForUpload(
// The money event: upload succeeded, we get the final server message ID
if (update?._ === "updateMessageSendSucceeded") {
const msg = update.message;
const oldMsgId = update.old_message_id;
if (tempMsgId !== null && oldMsgId === tempMsgId) {
if (!settled) {
settled = true;
cleanup();
const finalId = BigInt(msg.id);
log.info(
{ fileName, tempMsgId, finalMsgId: Number(finalId) },
"Upload confirmed by Telegram"
);
resolve(finalId);
}
const oldMsgId: number = update.old_message_id;
if (tempMsgId === null) {
// Race: event arrived before our .then() assigned tempMsgId.
// Buffer it and process once tempMsgId is known.
pendingSuccess = { oldMsgId, finalId: msg.id };
return;
}
if (oldMsgId === tempMsgId) {
completeWithSuccess(msg.id);
}
}
// Upload failed
if (update?._ === "updateMessageSendFailed") {
const oldMsgId = update.old_message_id;
if (tempMsgId !== null && oldMsgId === tempMsgId) {
if (!settled) {
settled = true;
cleanup();
const errorMsg = update.error?.message ?? "Unknown upload error";
const error = new Error(`Upload failed for ${fileName}: ${errorMsg}`);
(error as Error & { code?: number }).code = update.error?.code;
reject(error);
}
const oldMsgId: number = update.old_message_id;
const errorMsg: string = update.error?.message ?? "Unknown upload error";
const code: number | undefined = update.error?.code;
if (tempMsgId === null) {
pendingFailure = { oldMsgId, errorMsg, code };
return;
}
if (oldMsgId === tempMsgId) {
completeWithFailure(errorMsg, code);
}
}
};
@@ -304,6 +346,13 @@ async function sendAndWaitForUpload(
{ fileName, tempMsgId },
"Message queued, waiting for upload confirmation"
);
// Replay any event that arrived before we knew tempMsgId
if (pendingSuccess && pendingSuccess.oldMsgId === tempMsgId) {
completeWithSuccess(pendingSuccess.finalId);
} else if (pendingFailure && pendingFailure.oldMsgId === tempMsgId) {
completeWithFailure(pendingFailure.errorMsg, pendingFailure.code);
}
})
.catch((err) => {
if (!settled) {

View File

@@ -20,4 +20,26 @@ export const config = {
apiDelayMs: 1000,
/** Max retries for rate-limited requests */
maxRetries: 5,
/** After this many failed attempts on the same source message, the worker
* stops auto-retrying and lets the watermark advance past it. The user can
* manually retry via the UI to reset and try again. */
maxSkipAttempts: parseInt(process.env.WORKER_MAX_SKIP_ATTEMPTS ?? "5", 10),
/** Window in which a recent successful empty scan lets us skip the next
* scan entirely. Default 5 minutes. */
skipRecentScanWindowMs: parseInt(
process.env.WORKER_SKIP_RECENT_SCAN_WINDOW_MS ?? "300000",
10
),
/** After this many consecutive empty scans, a channel/topic enters
* backoff and is only scanned every Nth cycle. */
emptyScanBackoffThreshold: parseInt(
process.env.WORKER_EMPTY_SCAN_BACKOFF_THRESHOLD ?? "5",
10
),
/** While in backoff, scan only every Nth cycle. Default 5 = scan every
* fifth cycle = once every ~5 hours given the 60-min default interval. */
emptyScanBackoffEveryNth: parseInt(
process.env.WORKER_EMPTY_SCAN_BACKOFF_EVERY_NTH ?? "5",
10
),
} as const;

File diff suppressed because it is too large Load Diff