From 09ee9da9cce88218cb4cf916be32ee26d20e0b39 Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Thu, 23 Jul 2026 14:27:07 +0200 Subject: [PATCH] feat(worker): fingerprint listing-less ZIP candidates via destination copy Rebuild-created placeholder candidates have no PackageFile CRCs, so name-side fingerprinting can't confirm them. When the stored candidate fingerprint is incomplete, read the candidate's own copy from its destination message and fingerprint against that instead of falling straight to name+size confidence. Co-Authored-By: Claude Opus 4.8 (1M context) --- worker/src/db/queries.ts | 38 +++++++++++++++++++++++++--- worker/src/provenance-backfill.ts | 42 +++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/worker/src/db/queries.ts b/worker/src/db/queries.ts index ef3101f..a899336 100644 --- a/worker/src/db/queries.ts +++ b/worker/src/db/queries.ts @@ -1012,12 +1012,25 @@ export async function createAutoGroup(input: { // ── Provenance backfill ── +export interface PlaceholderCandidate { + id: string; + archiveType: string; + fileCount: number; + fileSize: bigint; + destMessageId: bigint | null; + destMessageIds: bigint[]; + destChannel: { telegramId: bigint } | null; +} + export async function findPlaceholderCandidate( destChannelId: string, fileName: string, fileSize: bigint, -): Promise<{ id: string; archiveType: string; fileCount: number } | null> { - return db.package.findFirst({ +): Promise { + // Package has no direct `destChannel` relation (only the scalar + // `destChannelId`), so resolve the destination TelegramChannel's + // telegramId with a follow-up lookup rather than a Prisma include. + const row = await db.package.findFirst({ where: { fileName, fileSize, @@ -1029,9 +1042,28 @@ export async function findPlaceholderCandidate( { sourceMessageId: 0n }, ], }, - select: { id: true, archiveType: true, fileCount: true }, + select: { + id: true, archiveType: true, fileCount: true, fileSize: true, + destMessageId: true, destMessageIds: true, destChannelId: true, + }, orderBy: { indexedAt: "asc" }, }); + if (!row) return null; + const destChannel = row.destChannelId + ? await db.telegramChannel.findUnique({ + where: { id: row.destChannelId }, + select: { telegramId: true }, + }) + : null; + return { + id: row.id, + archiveType: row.archiveType, + fileCount: row.fileCount, + fileSize: row.fileSize, + destMessageId: row.destMessageId, + destMessageIds: row.destMessageIds, + destChannel, + }; } export async function getPackageFileCrcs(packageId: string): Promise<(string | null)[]> { diff --git a/worker/src/provenance-backfill.ts b/worker/src/provenance-backfill.ts index da17dd6..b656a8e 100644 --- a/worker/src/provenance-backfill.ts +++ b/worker/src/provenance-backfill.ts @@ -1,7 +1,8 @@ import { childLogger } from "./util/logger.js"; import { downloadFileRange } from "./tdlib/range-download.js"; +import { invokeWithTimeout } from "./tdlib/download.js"; import { parseZipCentralDirectoryFromTail, MIN_ZIP_TAIL_BYTES } from "./archive/central-directory.js"; -import { fingerprintsMatch } from "./archive/fingerprint.js"; +import { fingerprintsMatch, crcFingerprint } from "./archive/fingerprint.js"; import { findPlaceholderCandidate, getPackageFileCrcs, @@ -49,6 +50,28 @@ async function readScannedZipListing( return null; } +async function readZipListingFromDestination( + client: Client, + destChatTelegramId: bigint, + destMessageId: bigint, + fileSize: bigint, +): Promise { + try { + // Resolve the destination message's document file id. + const msg = (await invokeWithTimeout(client, { + _: "getMessage", + chat_id: Number(destChatTelegramId), + message_id: Number(destMessageId), + })) as { content?: { document?: { document?: { id: number } } } }; + const fid = msg?.content?.document?.document?.id; + if (!fid) return null; + return await readScannedZipListing(client, String(fid), fileSize); + } catch (err) { + log.warn({ err, destMessageId: Number(destMessageId) }, "destination ZIP listing read failed"); + return null; + } +} + export async function tryProvenanceBackfill( args: BackfillArgs, ): Promise<{ backfilled: boolean; confidence?: "fingerprint" | "name-size" }> { @@ -62,9 +85,24 @@ export async function tryProvenanceBackfill( entries = await readScannedZipListing(args.client, args.scannedFileId, args.fileSize); if (entries) { const candidateCrcs = await getPackageFileCrcs(candidate.id); - const candidateEntries: FileEntry[] = candidateCrcs.map((crc) => ({ + let candidateEntries: FileEntry[] = candidateCrcs.map((crc) => ({ path: "", fileName: "", extension: null, compressedSize: 0n, uncompressedSize: 0n, crc32: crc, })); + const destMessageId = + candidate.destMessageIds.length > 0 + ? candidate.destMessageIds[candidate.destMessageIds.length - 1] + : candidate.destMessageId; + if (!crcFingerprint(candidateEntries).complete && destMessageId && candidate.destChannel) { + const destEntries = await readZipListingFromDestination( + args.client, + candidate.destChannel.telegramId, + destMessageId, + candidate.fileSize, + ); + if (destEntries) { + candidateEntries = destEntries; + } + } if (fingerprintsMatch(entries, candidateEntries)) { confidence = "fingerprint"; } else {