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) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:27:07 +02:00
co-authored by Claude Opus 4.8
parent 7ddf13053f
commit 09ee9da9cc
2 changed files with 75 additions and 5 deletions
+35 -3
View File
@@ -1012,12 +1012,25 @@ export async function createAutoGroup(input: {
// ── Provenance backfill ── // ── 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( export async function findPlaceholderCandidate(
destChannelId: string, destChannelId: string,
fileName: string, fileName: string,
fileSize: bigint, fileSize: bigint,
): Promise<{ id: string; archiveType: string; fileCount: number } | null> { ): Promise<PlaceholderCandidate | null> {
return db.package.findFirst({ // 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: { where: {
fileName, fileName,
fileSize, fileSize,
@@ -1029,9 +1042,28 @@ export async function findPlaceholderCandidate(
{ sourceMessageId: 0n }, { 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" }, 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)[]> { export async function getPackageFileCrcs(packageId: string): Promise<(string | null)[]> {
+40 -2
View File
@@ -1,7 +1,8 @@
import { childLogger } from "./util/logger.js"; import { childLogger } from "./util/logger.js";
import { downloadFileRange } from "./tdlib/range-download.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 { parseZipCentralDirectoryFromTail, MIN_ZIP_TAIL_BYTES } from "./archive/central-directory.js";
import { fingerprintsMatch } from "./archive/fingerprint.js"; import { fingerprintsMatch, crcFingerprint } from "./archive/fingerprint.js";
import { import {
findPlaceholderCandidate, findPlaceholderCandidate,
getPackageFileCrcs, getPackageFileCrcs,
@@ -49,6 +50,28 @@ async function readScannedZipListing(
return null; return null;
} }
async function readZipListingFromDestination(
client: Client,
destChatTelegramId: bigint,
destMessageId: bigint,
fileSize: bigint,
): Promise<FileEntry[] | null> {
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( export async function tryProvenanceBackfill(
args: BackfillArgs, args: BackfillArgs,
): Promise<{ backfilled: boolean; confidence?: "fingerprint" | "name-size" }> { ): Promise<{ backfilled: boolean; confidence?: "fingerprint" | "name-size" }> {
@@ -62,9 +85,24 @@ export async function tryProvenanceBackfill(
entries = await readScannedZipListing(args.client, args.scannedFileId, args.fileSize); entries = await readScannedZipListing(args.client, args.scannedFileId, args.fileSize);
if (entries) { if (entries) {
const candidateCrcs = await getPackageFileCrcs(candidate.id); 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, 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)) { if (fingerprintsMatch(entries, candidateEntries)) {
confidence = "fingerprint"; confidence = "fingerprint";
} else { } else {