feat(verify): pre-upload integrity test, post-upload read-back, batched recovery
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>
This commit is contained in:
2026-05-24 08:56:50 +02:00
co-authored by Claude Opus 4.7
parent c4d9be83bd
commit 04effed825
3 changed files with 240 additions and 6 deletions
+67 -6
View File
@@ -81,17 +81,28 @@ export async function recoverIncompleteUploads(): Promise<void> {
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 result = 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 (result.state === "exists") {
verifiedCount++;
} else if (result.state === "deleted") {
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,
@@ -130,6 +141,7 @@ export async function recoverIncompleteUploads(): Promise<void> {
"Could not verify destination message — will retry on next startup"
);
}
}
}
}
@@ -160,6 +172,55 @@ type VerifyResult =
| { state: "wrong-content"; contentType: string }
| { state: "unknown"; reason: string };
/**
* 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