Prioritize native Telegram forwarding over download+reupload for source channels that allow it, reusing the ranged archive-listing readers to keep indexing complete without a local download. Falls back to the existing download+reupload pipeline per-channel (when forwarding is blocked) and per-archive (when ranged listing fails).
12 KiB
Forward-priority ingestion — design
Date: 2026-07-30 Status: Approved (design), pending spec review → implementation plan
Problem
The worker ingests every archive the same way regardless of whether it needs to: download the full file from the source channel, then re-upload the full file to the destination (archive) channel. That download+reupload round-trip was originally necessary because some source channels have "restrict saving content" (protected content) enabled, which blocks Telegram-native forwarding — for those channels there is no alternative to moving the bytes through the worker.
But most source channels do NOT restrict forwarding. For those, the round-trip is pure waste: Telegram can copy the message from source chat to destination chat server-side, with no bytes ever passing through the worker. The worker still needs to end up with the same outcome it has today — a destination-channel copy, a dedup-safe identity, and a full inner-file listing — just without paying for a download and re-upload to get there.
Separately, feat/ranged-archive-listing (merged to master ahead of this feature) already built
exactly the missing piece: reading a ZIP/RAR/7z archive's inner-file listing via small ranged
reads against the file wherever it currently lives (source channel, destination channel — doesn't
matter), with no full download. It was built for backfilling listings onto already-deduped
placeholder packages. This feature generalizes that same capability to fresh ingestion, and pairs
it with a new native-forward upload path.
Goals
- For channels that allow forwarding: skip download and re-upload entirely for new archives. Use Telegram-native forwarding from source chat to destination chat, and the existing ranged-listing readers to index inner files, with no full download in the common case.
- For channels that block forwarding (or when forwarding isn't yet known): keep today's download+reupload pipeline exactly as-is.
- Every ingested package — regardless of path — ends up with the same outcome as today: a
Packagerow with a valid dedup identity,destMessageId/destMessageIds, creator, tags, and a full inner-file listing (PackageFilerows). Indexing completeness must not regress. - If the cheap ranged listing fails for a specific archive (bad/unsupported header, CLI error, etc.) in an otherwise-forwarding-eligible channel, fall back to today's full download+reupload pipeline for that one archive — never forward with an empty or partial listing.
Non-goals
- No ranged single-entry preview extraction. Forward-path packages still get a preview when a channel photo message matches (cheap, unrelated to archive bytes); when there's no matching photo, forward-path packages simply have no preview, same as any package where preview extraction fails today. In-archive preview extraction (unzip/unrar/7z against a local file) stays as a download-path-only feature. May be revisited as a follow-up if it turns out to matter.
- No reprocessing of already-ingested packages. This only changes behavior for newly-scanned archives going forward.
- No change to the bot's user-delivery leg (
bot/src/tdlib/client.tscopyMessageToUser) — it already sends viainputFileRemotewith no download, and is unaffected by this feature. - No change to
config.maxZipSizeMBor the multipart byte-level split/repack logic. The existing size guard runs before either path is chosen, so nothing above the cap reaches the forward path's fallback-to-download step either. Splitting simply never engages on the forward path — a forwarded message is already within whatever size Telegram accepted when it was first uploaded.
Approaches considered
A — Branch inside the existing pipeline (chosen). Add one fork point in
processOneArchiveSet, immediately after the existing pre-download dedup checks: if the channel
allows forwarding, attempt the ranged-listing + forward path; on any failure, fall through into
today's download-based code for that one archive, unchanged. Smallest diff; reuses the existing
dedup/retry/watermark machinery as-is; matches the file's existing forum-vs-non-forum branching
style.
B — Separate pipeline per channel. Decide once per channel and route the whole channel through
either a "forward module" or the existing "download module." Cleaner separation on paper, but
duplicates the SkippedPackage/stall/watermark bookkeeping that currently lives once in
processArchiveSets/processOneArchiveSet — higher regression risk in a large orchestration file
with no tests at that level. Rejected.
C — Strategy-object refactor. Extract an IngestStrategy interface (download / forward)
and slim processOneArchiveSet to delegate to it. The more "proper" abstraction, but it's a
structural refactor of already-battle-tested code that doesn't need it for this feature to work.
Rejected — can revisit later if a third strategy ever appears.
Sequencing
feat/ranged-archive-listing merges to master first, as-is (it's complete and serves a different
purpose already). This feature is built on a fresh branch off master afterward.
Components
1. TelegramChannel.allowsForwarding (new column, new migration)
Boolean? — nullable, null means "not yet checked". Refreshed from TDLib's chat
protected-content flag (exact field name to be confirmed against the pinned tdl/TDLib version
via docs lookup during implementation — expected to be chat.has_protected_content) at the same
point the worker already calls getChat per channel per cycle, mirroring the existing
isForum/setChannelForum read-and-persist pattern precisely. null or false both route to the
download path — a channel never uses the forward path on unverified permission.
2. Shared ranged-listing dispatcher
readScannedListingRanged (plus RangedPart, tdlibRangeReader, and the format-specific
ZIP/RAR/7z readers) currently live inside provenance-backfill.ts. Promote the dispatcher (and
whatever it depends on) into a shared module (e.g. worker/src/archive/ranged/dispatch.ts) so
worker.ts can call the same no-download listing logic for fresh ingestion without a circular
import. provenance-backfill.ts switches to importing from the new shared location; behavior
unchanged for the existing backfill path.
3. forwardArchiveToChannel (new, worker/src/upload/forward.ts)
Mirrors uploadToChannel's shape and return type ({ messageId, messageIds }). Uses TDLib
forwardMessages to copy all parts of an archive set from the source chat to the destination chat
in one batch call (message IDs in original order), wrapped in the same flood-wait/retry handling
style as uploadToChannel. Followed by the same destination read-back verification style as
today's post-upload check (getMessage on each new destination message ID, confirm a document is
present).
4. Dedup identity for forward-path packages
Package.contentHash stays a required unique string, but forward-path packages can't hash real
bytes. Derivation order:
- If the ranged listing's CRC32s are complete (ZIP/RAR today) — hash the sorted CRC32 list into a
synthetic
fingerprint:<hash>value, reusingarchive/fingerprint.ts's existingcrcFingerprint. - Otherwise (7z, or any incomplete-CRC case) — synthesize
forward:<remoteUniqueId>, following the existingrebuild:-prefixed placeholder-hash precedent inrebuild.ts.
Additionally, extend repost detection: before committing to the forward path, compare the new
listing's CRC fingerprint (via the existing compareFingerprints/fingerprintsMatch logic already
used in provenance-backfill.ts's ambiguous-candidate disambiguation) against recent Packages
sharing the same file name + size. A fingerprint match is treated as a duplicate and skipped, same
as today's findRepostedPackage handling — this is what lets a forwarded copy and a previously
fully-downloaded copy of the same archive still dedupe against each other, despite never sharing a
byte-hash-derived contentHash.
5. Fork point in processOneArchiveSet
All existing pre-download checks run first, completely unchanged, in the same order:
remote.unique_id match → packageExistsBySourceMessage → findRepostedPackage (name+size) →
cross-channel provenance backfill → size guard (maxZipSizeMB).
Then:
if channel.allowsForwarding === true:
entries = readScannedListingRanged(archiveType, client, scannedParts)
if entries is not null:
contentHash = deriveForwardContentHash(entries, remoteUniqueId)
if fingerprintRepostCheck(entries, fileName, fileSize) finds a match:
→ treat as duplicate, skip (same bookkeeping as today's dup path)
destResult = forwardArchiveToChannel(client, sourceChatId, partMessageIds, destChatId)
creator, tags ← derived from entries/filename/channel/topic, same as today
preview ← channel-photo match only (no in-archive extraction)
createPackageStub(...) + updatePackageWithMetadata(...), same as today
counters.zipsForwarded++
→ done
else:
→ fall through into the existing download/hash/split/upload flow below, unchanged
(log the fallback for observability)
else:
→ existing download/hash/split/upload flow, completely unchanged
6. Observability
New zipsForwarded counter alongside the existing zipsFound/zipsDuplicate/zipsIngested/
zipsBackfilled counters, surfaced the same way (run activity, ingestion run summary). A WARN-level
log line when a forwarding-eligible archive falls back to download (mirrors the existing
confidence: "ranged" | "full-download-fallback" logging convention from the ranged-listing
backfill work), so the fallback rate is visible without digging through debug logs.
Data flow
scan → pre-download dedup + size guard (unchanged)
→ channel.allowsForwarding?
true → ranged listing
ok → fingerprint dedup check → forward → stub + entries + tags (no in-archive preview) → done
null → [fall through] existing download pipeline
false/unknown → existing download pipeline (unchanged)
Error handling
forwardMessagesfailure (permission revoked mid-run, rate limit, transient Telegram error) — sameSkippedPackage/SystemNotificationbookkeeping as today's upload failures. ExtendinferSkipReasonto recognize forward-specific error text the same way it already recognizes upload errors.- Fingerprint-repost check finds multiple ambiguous same-name/size candidates that can't be
uniquely disambiguated — same
INTEGRITY_AUDITnotification pattern already used inprovenance-backfill.ts: don't guess, surface for manual triage. allowsForwardingunknown (channel just linked, not yet scanned by the refresh point) — treated asfalse; the download path runs. No channel uses an unverified forwarding permission.- Ranged listing throwing instead of returning
null— treated identically to returningnull(fall through to download), consistent with how the existing ranged readers already treat internal errors (they catch and returnnullthemselves).
Testing
- Unit tests (vitest, alongside the existing
archive/*.test.tsandarchive/ranged/*.test.tsfiles): the dedup-identity derivation function (fingerprint-hash vs remoteUniqueId-fallback branches), the extended fingerprint-based repost check, andforwardArchiveToChannel's request-building logic against a mocked TDLib client — same style as the existing ranged-reader tests (pure logic, no live TDLib). - Live verification (manual — matches this repo's existing convention that the large
worker.ts/worker.py-equivalent orchestration function has no automated test coverage and is verified live post-deploy): one forwarding-enabled test channel and one protected-content test channel. Confirm forward-path packages land with correct entries/tags/dedup identity anddestMessageIds; confirm the protected channel still goes through the unchanged full pipeline; confirm a deliberately-unparseable archive in a forwarding-enabled channel correctly falls back to download+reupload and still ends up fully indexed.
Rollout
Local build + deploy, following the same recipe as the ranged-archive-listing work: build
worker/Dockerfile locally, recreate the dragonsstash-worker container from the local image (no
pull, no GitHub push required). New DB migration for TelegramChannel.allowsForwarding. No
changes required to the bot or app services.