merge: close silent-drop gaps in attachment detection

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 23:34:09 +02:00
co-authored by Claude Sonnet 5
7 changed files with 397 additions and 40 deletions
+247 -2
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { detectArchive, isArchiveAttachment } from "./detect.js";
describe("detectArchive — 7z numbered multipart (pack.7z.001, pack.7z.002, ...)", () => {
describe("detectArchive — numbered volumes (pack.EXT.001, pack.EXT.002, ...)", () => {
it("recognizes a 7z multipart part as an archive attachment", () => {
expect(isArchiveAttachment("Lost Adventures Vol2.7z.001")).toBe(true);
});
@@ -12,7 +12,7 @@ describe("detectArchive — 7z numbered multipart (pack.7z.001, pack.7z.002, ...
baseName: "Lost Adventures Vol2.7z",
partNumber: 1,
format: "7Z",
pattern: "SEVENZ_NUMBERED",
pattern: "ARCHIVE_NUMBERED",
});
});
@@ -36,4 +36,249 @@ describe("detectArchive — 7z numbered multipart (pack.7z.001, pack.7z.002, ...
pattern: "SINGLE",
});
});
it("recognizes numbered ZIP volumes", () => {
expect(detectArchive("Big Pack.zip.001")).toEqual({
baseName: "Big Pack.zip",
partNumber: 1,
format: "ZIP",
pattern: "ARCHIVE_NUMBERED",
});
});
it("recognizes numbered RAR volumes (previously dropped silently)", () => {
expect(detectArchive("Big Pack.rar.001")).toEqual({
baseName: "Big Pack.rar",
partNumber: 1,
format: "RAR",
pattern: "ARCHIVE_NUMBERED",
});
});
it("derives the format from the archive extension, not a hardcoded value", () => {
expect(detectArchive("A.zip.004")?.format).toBe("ZIP");
expect(detectArchive("A.RAR.004")?.format).toBe("RAR");
expect(detectArchive("A.7z.004")?.format).toBe("7Z");
});
it("accepts hand-renamed two-digit volumes for every format", () => {
expect(detectArchive("Pack.zip.01")).toEqual({
baseName: "Pack.zip",
partNumber: 1,
format: "ZIP",
pattern: "ARCHIVE_NUMBERED",
});
expect(detectArchive("Pack.rar.02")?.partNumber).toBe(2);
expect(detectArchive("Pack.7z.03")?.partNumber).toBe(3);
});
it("still accepts four-or-more-digit volumes", () => {
expect(detectArchive("Pack.7z.0001")).toEqual({
baseName: "Pack.7z",
partNumber: 1,
format: "7Z",
pattern: "ARCHIVE_NUMBERED",
});
expect(detectArchive("Pack.zip.10001")?.partNumber).toBe(10001);
});
it("does not match a single-digit suffix (too ambiguous with real extensions)", () => {
expect(detectArchive("Pack.zip.1")).toBeNull();
});
});
describe("detectArchive — pattern ordering safety", () => {
it("does not let ZIP_LEGACY swallow a .7z.NNN name", () => {
expect(detectArchive("Pack.7z.001")?.pattern).toBe("ARCHIVE_NUMBERED");
expect(detectArchive("Pack.7z.001")?.format).toBe("7Z");
});
it("does not let ARCHIVE_NUMBERED swallow legacy .zNN names", () => {
expect(detectArchive("Pack.z01")).toEqual({
baseName: "Pack",
partNumber: 1,
format: "ZIP",
pattern: "ZIP_LEGACY",
});
});
it("does not let ARCHIVE_NUMBERED swallow legacy .rNN names", () => {
expect(detectArchive("Pack.r00")).toEqual({
baseName: "Pack",
partNumber: 0,
format: "RAR",
pattern: "RAR_LEGACY",
});
expect(detectArchive("Pack.r01")?.pattern).toBe("RAR_LEGACY");
});
it("keeps .partN.rar on the RAR_PART pattern", () => {
expect(detectArchive("Pack.part2.rar")).toEqual({
baseName: "Pack",
partNumber: 2,
format: "RAR",
pattern: "RAR_PART",
});
});
});
describe("detectArchive — filename normalization", () => {
it("recognizes a name with a trailing space and reports the trimmed baseName", () => {
expect(detectArchive("Pack.zip ")).toEqual({
baseName: "Pack",
partNumber: -1,
format: "ZIP",
pattern: "SINGLE",
});
});
it("recognizes a name with a leading space", () => {
expect(detectArchive(" Pack.rar")).toEqual({
baseName: "Pack",
partNumber: -1,
format: "RAR",
pattern: "SINGLE",
});
});
it("recognizes a multipart name with surrounding whitespace", () => {
expect(detectArchive("\tPack.rar.002 \n")).toEqual({
baseName: "Pack.rar",
partNumber: 2,
format: "RAR",
pattern: "ARCHIVE_NUMBERED",
});
});
it("recognizes a name with a trailing dot", () => {
expect(detectArchive("Pack.zip.")).toEqual({
baseName: "Pack",
partNumber: -1,
format: "ZIP",
pattern: "SINGLE",
});
});
it("recognizes a name with a trailing dot followed by a space", () => {
expect(detectArchive("Pack.part3.rar. ")?.pattern).toBe("RAR_PART");
});
it("still returns null for a whitespace-only or empty name", () => {
expect(detectArchive(" ")).toBeNull();
expect(detectArchive("")).toBeNull();
});
});
describe("detectArchive — self-extracting RAR first volume (.partN.exe)", () => {
it("recognizes Pack.part1.exe as the first volume of a RAR_PART set", () => {
expect(detectArchive("Pack.part1.exe")).toEqual({
baseName: "Pack",
partNumber: 1,
format: "RAR",
pattern: "RAR_PART",
});
});
it("groups the SFX first volume with its .rar continuation volumes", () => {
const sfx = detectArchive("Pack.part1.exe");
const cont = detectArchive("Pack.part2.rar");
expect(sfx?.baseName).toBe(cont?.baseName);
expect(sfx?.format).toBe(cont?.format);
});
it("does not open the door to arbitrary .exe attachments", () => {
expect(detectArchive("Installer.exe")).toBeNull();
expect(detectArchive("Pack.exe")).toBeNull();
});
});
describe("detectArchive — standalone documents", () => {
it("keeps recognizing the pre-existing document extensions", () => {
expect(detectArchive("Model.stl")).toEqual({
baseName: "Model",
partNumber: -1,
format: "DOCUMENT",
pattern: "SINGLE",
});
expect(detectArchive("Sheet.pdf")?.format).toBe("DOCUMENT");
});
it("recognizes slicer-project formats", () => {
for (const name of [
"Bust.lys",
"Bust.lyt",
"Bust.lymesh",
"Bust.chitubox",
"Bust.ctp",
"Bust.ctb",
"Bust.cbddlp",
"Bust.photon",
"Bust.pwmx",
"Bust.pwmo",
"Bust.pws",
"Bust.sl1",
"Bust.goo",
"Bust.phz",
"Bust.pm3",
"Bust.form",
]) {
expect(detectArchive(name), name).toEqual({
baseName: "Bust",
partNumber: -1,
format: "DOCUMENT",
pattern: "SINGLE",
});
}
});
it("recognizes 3D-model and CAD formats", () => {
for (const name of [
"Bust.fbx",
"Bust.ply",
"Bust.glb",
"Bust.gltf",
"Bust.3ds",
"Bust.max",
"Bust.c4d",
"Bust.ztl",
"Bust.zpr",
"Bust.mtl",
"Bust.f3d",
"Bust.scad",
"Bust.igs",
"Bust.iges",
"Bust.sldprt",
"Bust.skp",
"Bust.wrl",
]) {
expect(detectArchive(name), name).toEqual({
baseName: "Bust",
partNumber: -1,
format: "DOCUMENT",
pattern: "SINGLE",
});
}
});
it("recognizes the .blend1 autosave sibling of .blend", () => {
expect(detectArchive("Scene.blend")?.format).toBe("DOCUMENT");
expect(detectArchive("Scene.blend1")).toEqual({
baseName: "Scene",
partNumber: -1,
format: "DOCUMENT",
pattern: "SINGLE",
});
});
it("does NOT recognize image attachments (they must not become packages)", () => {
for (const name of ["Preview.jpg", "Preview.jpeg", "Preview.png", "Preview.webp", "Preview.gif"]) {
expect(detectArchive(name), name).toBeNull();
}
});
it("returns null for unrelated files", () => {
expect(detectArchive("notes.txt")).toBeNull();
expect(detectArchive("song.mp3")).toBeNull();
expect(detectArchive("noextension")).toBeNull();
});
});
+42 -35
View File
@@ -4,25 +4,29 @@ export interface MultipartInfo {
baseName: string;
partNumber: number;
format: ArchiveFormat;
pattern: "ZIP_NUMBERED" | "ZIP_LEGACY" | "RAR_PART" | "RAR_LEGACY" | "SEVENZ_NUMBERED" | "SINGLE";
pattern: "ARCHIVE_NUMBERED" | "ZIP_LEGACY" | "RAR_PART" | "RAR_LEGACY" | "SINGLE";
}
const patterns: {
regex: RegExp;
format: ArchiveFormat;
/** A fixed format, or one derived from the match for patterns spanning several formats. */
format: ArchiveFormat | ((match: RegExpMatchArray) => ArchiveFormat);
pattern: MultipartInfo["pattern"];
getBaseName: (match: RegExpMatchArray) => string;
getPartNumber: (match: RegExpMatchArray) => number;
}[] = [
// pack.zip.001, pack.zip.002
// pack.zip.001, pack.rar.001, pack.7z.001 (numbered volume split — one pattern for
// every format, so a new format can never be silently dropped for lack of its own entry).
// {2,} digits also picks up hand-renamed sets like pack.rar.01.
{
regex: /^(.+\.zip)\.(\d{3,})$/i,
format: "ZIP",
pattern: "ZIP_NUMBERED",
getBaseName: (m) => m[1],
getPartNumber: (m) => parseInt(m[2], 10),
regex: /^(.+\.(zip|7z|rar))\.(\d{2,})$/i,
// The regex only ever captures zip/7z/rar, so uppercasing yields a valid ArchiveFormat.
format: (m) => m[2].toUpperCase() as ArchiveFormat,
pattern: "ARCHIVE_NUMBERED",
getBaseName: (m) => m[1], // includes the archive extension
getPartNumber: (m) => parseInt(m[3], 10),
},
// pack.z01, pack.z02 (legacy split — final part is pack.zip)
// pack.z01, pack.z02 (legacy split — pack.zip is the FINAL disk of the set)
{
regex: /^(.+)\.z(\d{2,})$/i,
format: "ZIP",
@@ -30,15 +34,16 @@ const patterns: {
getBaseName: (m) => m[1],
getPartNumber: (m) => parseInt(m[2], 10),
},
// pack.part1.rar, pack.part2.rar
// pack.part1.rar, pack.part2.rar — .exe covers a self-extracting first volume
// (pack.part1.exe + pack.part2.rar + ...), which is still a RAR volume set.
{
regex: /^(.+)\.part(\d+)\.rar$/i,
regex: /^(.+)\.part(\d+)\.(rar|exe)$/i,
format: "RAR",
pattern: "RAR_PART",
getBaseName: (m) => m[1],
getPartNumber: (m) => parseInt(m[2], 10),
},
// pack.r00, pack.r01 (legacy split — final part is pack.rar)
// pack.r00, pack.r01 (legacy split — pack.rar is the FIRST volume, .r00 onwards follow it)
{
regex: /^(.+)\.r(\d{2,})$/i,
format: "RAR",
@@ -46,50 +51,53 @@ const patterns: {
getBaseName: (m) => m[1],
getPartNumber: (m) => parseInt(m[2], 10),
},
// pack.7z.001, pack.7z.002 (native 7z volume split)
{
regex: /^(.+\.7z)\.(\d{3,})$/i,
format: "7Z",
pattern: "SEVENZ_NUMBERED",
getBaseName: (m) => m[1],
getPartNumber: (m) => parseInt(m[2], 10),
},
];
/** Extensions we recognize as fetchable documents (archives + standalone files) */
const DOCUMENT_EXTENSIONS = /\.(pdf|stl|obj|3mf|step|stp|blend|gcode|svg|dxf|ai|eps|psd)$/i;
/** Extensions we recognize as fetchable documents (archives + standalone files).
* Deliberately excludes image formats — previews posted as uncompressed documents
* must go through the photo-matching path, not become packages of their own. */
const DOCUMENT_EXTENSIONS =
/\.(pdf|stl|obj|3mf|step|stp|blend1|blend|gcode|svg|dxf|ai|eps|psd|lys|lyt|lymesh|chitubox|ctp|ctb|cbddlp|photon|pwmx|pwmo|pws|sl1|goo|phz|pm3|fbx|ply|glb|gltf|3ds|max|c4d|ztl|zpr|mtl|f3d|scad|igs|iges|sldprt|form|skp|wrl)$/i;
/**
* Detect if a filename is an archive and extract multipart info.
*/
export function detectArchive(fileName: string): MultipartInfo | null {
// TDLib hands us `document.file_name` verbatim and every pattern below is `$`-anchored,
// so "Pack.zip " or "Pack.zip." would otherwise be dropped without a trace. Trailing dots
// are stripped too: no filesystem or archiver can produce a meaningful one (Windows
// silently drops them), so a trailing dot is always cosmetic damage from a re-upload,
// never part of the real name. The normalized value is used for matching AND baseName.
const name = fileName.trim().replace(/[.\s]+$/, "");
if (!name) return null;
// Check multipart patterns first
for (const p of patterns) {
const match = fileName.match(p.regex);
const match = name.match(p.regex);
if (match) {
return {
baseName: p.getBaseName(match),
partNumber: p.getPartNumber(match),
format: p.format,
format: typeof p.format === "function" ? p.format(match) : p.format,
pattern: p.pattern,
};
}
}
// Single .zip file — could be a standalone or the final part of a ZIP_LEGACY set
if (/\.zip$/i.test(fileName)) {
if (/\.zip$/i.test(name)) {
return {
baseName: fileName.replace(/\.zip$/i, ""),
baseName: name.replace(/\.zip$/i, ""),
partNumber: -1, // -1 signals "could be single or final legacy part"
format: "ZIP",
pattern: "SINGLE",
};
}
// Single .rar file — could be standalone or final part of RAR_LEGACY set
if (/\.rar$/i.test(fileName)) {
// Single .rar file — could be standalone or the FIRST part of a RAR_LEGACY set
if (/\.rar$/i.test(name)) {
return {
baseName: fileName.replace(/\.rar$/i, ""),
baseName: name.replace(/\.rar$/i, ""),
partNumber: -1,
format: "RAR",
pattern: "SINGLE",
@@ -97,20 +105,19 @@ export function detectArchive(fileName: string): MultipartInfo | null {
}
// Single .7z file
if (/\.7z$/i.test(fileName)) {
if (/\.7z$/i.test(name)) {
return {
baseName: fileName.replace(/\.7z$/i, ""),
baseName: name.replace(/\.7z$/i, ""),
partNumber: -1,
format: "7Z",
pattern: "SINGLE",
};
}
// Standalone documents (PDFs, STLs, 3D files, etc.)
if (DOCUMENT_EXTENSIONS.test(fileName)) {
const ext = fileName.match(DOCUMENT_EXTENSIONS)![0];
// Standalone documents (PDFs, STLs, 3D files, slicer projects, etc.)
if (DOCUMENT_EXTENSIONS.test(name)) {
return {
baseName: fileName.replace(DOCUMENT_EXTENSIONS, ""),
baseName: name.replace(DOCUMENT_EXTENSIONS, ""),
partNumber: -1,
format: "DOCUMENT",
pattern: "SINGLE",
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { groupArchiveSets, type TelegramMessage } from "./multipart.js";
let nextId = 1000n;
function msg(fileName: string): TelegramMessage {
const id = nextId++;
return {
id,
fileName,
fileId: `file-${id}`,
fileSize: 1024n,
date: new Date("2026-01-01T00:00:00Z"),
};
}
function names(files: string[]): string[] {
const sets = groupArchiveSets(files.map(msg));
expect(sets).toHaveLength(1);
return sets[0].parts.map((p) => p.fileName);
}
describe("groupArchiveSets — legacy split part ordering", () => {
it("puts the bare .rar FIRST in a RAR_LEGACY set (it is volume 1)", () => {
expect(names(["Pack.r01", "Pack.rar", "Pack.r00"])).toEqual([
"Pack.rar",
"Pack.r00",
"Pack.r01",
]);
});
it("puts the bare .zip LAST in a ZIP_LEGACY set (it is the final disk)", () => {
expect(names(["Pack.z02", "Pack.zip", "Pack.z01"])).toEqual([
"Pack.z01",
"Pack.z02",
"Pack.zip",
]);
});
it("marks both legacy sets as multipart with the right format", () => {
const rar = groupArchiveSets([msg("Pack.rar"), msg("Pack.r00")])[0];
expect(rar.isMultipart).toBe(true);
expect(rar.type).toBe("RAR");
const zip = groupArchiveSets([msg("Pack.zip"), msg("Pack.z01")])[0];
expect(zip.isMultipart).toBe(true);
expect(zip.type).toBe("ZIP");
});
it("orders numbered volume sets by part number", () => {
expect(names(["Pack.rar.003", "Pack.rar.001", "Pack.rar.002"])).toEqual([
"Pack.rar.001",
"Pack.rar.002",
"Pack.rar.003",
]);
});
it("orders .partN sets by part number with an SFX first volume", () => {
expect(names(["Pack.part3.rar", "Pack.part1.exe", "Pack.part2.rar"])).toEqual([
"Pack.part1.exe",
"Pack.part2.rar",
"Pack.part3.rar",
]);
});
it("treats unrelated singles as their own non-multipart sets", () => {
const sets = groupArchiveSets([msg("A.zip"), msg("B.rar")]);
expect(sets).toHaveLength(2);
expect(sets.every((s) => !s.isMultipart)).toBe(true);
expect(sets.every((s) => s.parts.length === 1)).toBe(true);
});
});
+10 -3
View File
@@ -78,10 +78,17 @@ export function groupArchiveSets(messages: TelegramMessage[]): ArchiveSet[] {
}
}
// Sort by part number (singles get a very high number so they come last — they're the final part)
// Sort by part number. A bare single (partNumber -1) sits at a different end of the
// set depending on the legacy scheme: in a .zip/.z01/.z02 set the bare pack.zip is the
// FINAL disk, but in a .rar/.r00/.r01 set the bare pack.rar is volume 1 and .r00
// onwards follow it. Getting this backwards makes parts[0] a headerless continuation
// volume, which breaks listing and mislabels the package.
const singleRank = multipartEntries.some((e) => e.info.pattern === "RAR_LEGACY")
? -1 // before .r00
: 999999;
allEntries.sort((a, b) => {
const aNum = a.info.partNumber === -1 ? 999999 : a.info.partNumber;
const bNum = b.info.partNumber === -1 ? 999999 : b.info.partNumber;
const aNum = a.info.partNumber === -1 ? singleRank : a.info.partNumber;
const bNum = b.info.partNumber === -1 ? singleRank : b.info.partNumber;
return aNum - bNum;
});
+8
View File
@@ -333,6 +333,14 @@ async function scanDestinationChannel(
fileSize: BigInt(doc.document.size),
date: new Date(msg.date * 1000),
});
} else if (doc?.file_name) {
// Not matched by any pattern in archive/detect.ts, so it is dropped without a
// packages/skipped_packages row. Grep "unrecognized attachment" to find naming
// schemes we do not handle yet.
log.debug(
{ chatId: chatId.toString(), messageId: msg.id, fileName: doc.file_name },
"Skipping unrecognized attachment (no archive/document pattern matched)"
);
}
}
+9
View File
@@ -267,6 +267,15 @@ export async function getChannelMessages(
});
continue;
}
if (doc?.file_name) {
// Not matched by any pattern in archive/detect.ts, so it is dropped without a
// packages/skipped_packages row. Grep "unrecognized attachment" to find naming
// schemes we do not handle yet.
log.debug(
{ chatId: chatId.toString(), messageId: msg.id, fileName: doc.file_name },
"Skipping unrecognized attachment (no archive/document pattern matched)"
);
}
// Check for photo messages (potential previews)
const photo = msg.content?.photo;
+9
View File
@@ -280,6 +280,15 @@ export async function getTopicMessages(
});
continue;
}
if (doc?.file_name) {
// Not matched by any pattern in archive/detect.ts, so it is dropped without a
// packages/skipped_packages row. Grep "unrecognized attachment" to find naming
// schemes we do not handle yet.
log.debug(
{ chatId: chatId.toString(), topicId: topicId.toString(), messageId: msg.id, fileName: doc.file_name },
"Skipping unrecognized attachment (no archive/document pattern matched)"
);
}
// Check for photo messages (potential previews)
const photo = msg.content?.photo;