feat: add preview management, channel controls, invite polish, and recovery

- Auto-extract preview images from ZIP/RAR/7z archives during ingestion
- Upload custom preview images via package drawer
- Select preview from archive contents with on-demand extraction UI
- Manually add Telegram channels by t.me link, username, or invite link
- Invite code UX: bulk create, copy link, usage tracking, delete confirm
- Incomplete upload recovery: verify dest messages on worker startup
- Rebuild package DB by scanning destination channel with live progress

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-03-22 00:09:59 +01:00
parent bf093cdfca
commit ab558e00f5
26 changed files with 3028 additions and 98 deletions

View File

@@ -0,0 +1,111 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { childLogger } from "../util/logger.js";
import type { FileEntry } from "../archive/zip-reader.js";
const execFileAsync = promisify(execFile);
const log = childLogger("preview-extract");
/** Max bytes we'll accept for an extracted preview image (2MB). */
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
/** Image extensions we consider valid previews, in priority order. */
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png"]);
/**
* Pick the best preview image from the file entries list.
*
* Prefers files that look like dedicated preview images (01.jpg, insta.jpg,
* preview.jpg) over arbitrary images buried in subdirectories.
* Skips images that are suspiciously large (>2MB uncompressed).
*/
export function pickPreviewFile(entries: FileEntry[]): FileEntry | null {
const candidates = entries.filter((e) => {
if (!e.extension || !IMAGE_EXTENSIONS.has(e.extension.toLowerCase())) return false;
// Skip very large images — they're probably textures, not previews
if (e.uncompressedSize > BigInt(MAX_PREVIEW_BYTES)) return false;
return true;
});
if (candidates.length === 0) return null;
// Score candidates: lower depth + preview-like names win
const scored = candidates.map((entry) => {
const depth = entry.path.split("/").length - 1;
const nameLower = entry.fileName.toLowerCase();
let nameScore = 10; // default
// Known preview-like names get priority
if (/^(preview|thumb|cover|insta)\b/i.test(nameLower)) {
nameScore = 0;
} else if (/^0*[1-2]\.(jpe?g|png)$/i.test(nameLower)) {
// 01.jpg, 1.jpg, 02.jpg — common preview filenames
nameScore = 1;
} else if (/^0*[3-9]\.(jpe?g|png)$/i.test(nameLower)) {
nameScore = 2;
}
return { entry, score: nameScore + depth };
});
scored.sort((a, b) => a.score - b.score);
return scored[0].entry;
}
/**
* Extract a single file from an archive and return its contents as a Buffer.
*
* Uses the appropriate CLI tool based on archive type:
* - ZIP: unzip -p
* - RAR: unrar p -inul
* - 7Z: 7z e -so
*/
export async function extractPreviewImage(
archivePath: string,
archiveType: "ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT",
filePath: string
): Promise<Buffer | null> {
if (archiveType === "DOCUMENT") return null;
try {
let stdout: Buffer;
if (archiveType === "ZIP") {
const result = await execFileAsync("unzip", ["-p", archivePath, filePath], {
timeout: 15000,
maxBuffer: MAX_PREVIEW_BYTES,
encoding: "buffer",
});
stdout = result.stdout as unknown as Buffer;
} else if (archiveType === "RAR") {
const result = await execFileAsync("unrar", ["p", "-inul", archivePath, filePath], {
timeout: 15000,
maxBuffer: MAX_PREVIEW_BYTES,
encoding: "buffer",
});
stdout = result.stdout as unknown as Buffer;
} else {
// SEVEN_Z
const result = await execFileAsync("7z", ["e", "-so", archivePath, filePath], {
timeout: 15000,
maxBuffer: MAX_PREVIEW_BYTES,
encoding: "buffer",
});
stdout = result.stdout as unknown as Buffer;
}
if (stdout.length === 0) {
log.warn({ archivePath, filePath }, "Extracted preview image is empty");
return null;
}
log.debug(
{ archivePath, filePath, bytes: stdout.length },
"Extracted preview image from archive"
);
return stdout;
} catch (err) {
log.warn({ err, archivePath, filePath }, "Failed to extract preview image from archive");
return null;
}
}