/** * Pure helpers for saving inbound Telegram files to disk (mode 1). * * When a user sends a non-image file to the bot, we download it and write it * into the configured directory (or Pi's working directory), then tell the * model the absolute path so it can open the file with its normal tools. This * module only computes names/sizes — the actual download and write live in the * composition root, so this stays unit-testable. */ import type { AttachmentRef } from "./media"; /** A file that was saved to disk, as reported to the model in the prompt. */ export interface SavedFileNote { /** Absolute path the file was written to. */ path: string; /** Attachment kind (document/video/audio/voice/animation). */ kind: string; /** Size in bytes actually written. */ bytes: number; mimeType?: string; } /** A minimal extension guess for files that arrive without a filename. */ const MIME_EXTENSION: Record = { "application/pdf": "pdf", "application/zip": "zip", "application/json": "json", "text/plain": "txt", "text/csv": "csv", "audio/mpeg": "mp3", "audio/ogg": "ogg", "video/mp4": "mp4", "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", }; /** Strip directory separators and control chars so a filename can't escape the dir. */ function sanitizeFileName(name: string): string { const cleaned = name .replace(/[/\\]+/g, "_") // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars from a filename is intentional .replace(/[-]+/g, "") .replace(/^\.+/, "") .trim(); return cleaned || "file"; } /** A default base name for an attachment that carries no filename of its own. */ function defaultBaseName(ref: AttachmentRef): string { const ext = ref.mimeType ? MIME_EXTENSION[ref.mimeType] : undefined; const stamp = ref.fileId.slice(-8).replace(/[^a-zA-Z0-9]/g, ""); return ext ? `telegram-${ref.kind}-${stamp}.${ext}` : `telegram-${ref.kind}-${stamp}`; } /** * Choose a safe, unique filename for a saved attachment. Uses the message's own * filename when present, else a stable default from the kind/mime. If the name * is already taken (in `used`), a numeric suffix is inserted before the * extension. The chosen name is added to `used`. */ export function resolveSaveName(ref: AttachmentRef, used: Set): string { const base = sanitizeFileName(ref.fileName ?? defaultBaseName(ref)); if (!used.has(base)) { used.add(base); return base; } const dot = base.lastIndexOf("."); const stem = dot > 0 ? base.slice(0, dot) : base; const ext = dot > 0 ? base.slice(dot) : ""; for (let i = 1; ; i++) { const candidate = `${stem}-${i}${ext}`; if (!used.has(candidate)) { used.add(candidate); return candidate; } } } /** Human-readable byte size, e.g. `1.2 MB`. */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB"]; let value = bytes / 1024; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; }