/** * Where the bytes of an inbound file land. * * The transport already turns a file message into a `` marker inside the message text, so the model sees the NAME * and never the content. That is worse than seeing nothing: handed a file name * and no bytes, a model guesses what the log said and answers as though it had * read it. This module makes the name true — every file a message carried is * streamed into the conversation's workspace, and a note riding the same text * says where it now is. * * The split with `images.ts` is deliberate: this module owns "how bytes reach * the disk", `images.ts` owns "how an image becomes a content block the model * can see". An image passes through both. The other direction — how bytes leave * a workspace for a chat — is `outbound-file.ts`, and shares nothing with this * but the containment primitive both ask the filesystem for. * * The per-file limit can only be judged AFTER the download. A * `ResourceDescriptor` carries no size and the transport offers no size probe, * so the verdict is `bytesWritten` and an over-limit file is unlinked the * moment it lands. The bytes really do touch the disk once; that is the * transport's surface, not a choice made here. * * Sanitization below buys filesystem safety and nothing else. A sanitized name * is still attacker-chosen text that rides into the model's prompt, and the * defense at that layer is the standing presence line, not this one. It also * cannot see a symlink, which is why the landing directory is canonicalized * before anything is written into it: the name is safe and the PATH still has * to be proven to be inside the workspace. * @module dsh-lark-channel/files */ import { createHash } from 'node:crypto' import { realpathSync } from 'node:fs' import { mkdir, rmdir, unlink } from 'node:fs/promises' import { basename, extname, join, resolve } from 'node:path' import type { NormalizedMessage, ResourceDescriptor } from '@larksuite/channel' import { canonicalPathOf, isWithinContainer } from './containment.ts' import { failureDetail, formatBytesForChat } from './format.ts' /** The channel's own directory inside a workspace. */ const CHANNEL_DIRECTORY = '.dsh-lark' /** Where inbound files land under it, one directory per message. */ const INBOX_DIRECTORY = 'inbox' /** * How many per-file budgets one message may spend. Deliberately not * configurable: its only job is to stop "twenty 20 MB files in one message", * and it moves with the per-file limit by construction — a knob of its own * would answer no question the per-file limit leaves open. */ export const MESSAGE_BYTES_FACTOR = 3 /** * The longest name a file may land under. Far below every filesystem's own * per-component limit, which leaves the duplicate counter room to append `-2` * without a second truncation pass. */ const MAX_NAME_LENGTH = 120 /** Stems Windows reserves, whatever extension follows them. */ const RESERVED_STEMS = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i /** What a name sanitized down to nothing lands under. */ const FALLBACK_NAME = 'file' /** The inbound half of the transport, as this module uses it. */ export interface InboundFilePort { downloadResourceToFile( messageId: string, fileKey: string, type: 'image' | 'file', destPath: string, ): Promise<{ contentType?: string; bytesWritten: number }> } /** One inbound file that reached the workspace. */ export interface LandedFile { readonly fileKey: string readonly type: 'file' | 'image' | 'audio' | 'video' /** Absolute path on disk. */ readonly path: string readonly bytes: number readonly contentType?: string | undefined /** The sanitized name it landed under. */ readonly fileName: string } /** What one message's files became. */ export interface CollectedFiles { readonly landed: readonly LandedFile[] /** One line per thing the model must know about, exactly as it rides the text. */ readonly notes: readonly string[] } /** How {@link collectInboundFiles} is bounded and where it writes. */ export interface InboundOptions { /** The conversation's workspace, i.e. what `/cd` currently points at. */ readonly workspace: string /** Whether this deployment accepts files at all. */ readonly enabled: boolean readonly maxFileBytes: number /** Operator console line. */ readonly report: (line: string) => void /** * Whether this workspace has yet to be told where inbound files land; the * `.gitignore` hint rides the first landing rather than every message. */ readonly hintWorkspace?: boolean | undefined } /** Everything a message can carry except a sticker: a sticker is not a file. */ type LandableResource = ResourceDescriptor & { type: LandedFile['type'] } /** * Whether one resource is bytes worth keeping. * @param resource - the descriptor the transport normalized. * @returns true for everything but a sticker. */ function isLandableResource(resource: ResourceDescriptor): resource is LandableResource { return resource.type !== 'sticker' } /** * The type the transport wants for one resource. Its own `ResourceType` knows * only images and files; audio and video are files that happen to play. * @param type - the resource's normalized kind. * @returns the download type to pass. */ function downloadTypeOf(type: LandedFile['type']): 'image' | 'file' { return type === 'image' ? 'image' : 'file' } /** * Suffix a name Windows reserves. The reservation covers the stem, so `CON.txt` * is as unusable as `CON` — and a Linux host still receives files that a * Windows colleague will later unzip. * @param name - a sanitized single path component. * @returns the name, with the stem suffixed when it is reserved. */ function escapeReservedName(name: string): string { const [stem = '', ...rest] = name.split('.') return RESERVED_STEMS.test(stem) ? [`${stem}_`, ...rest].join('.') : name } /** * Shorten an over-long name without losing what tells a human — and a * `file` command — what kind of file it is. * @param name - a sanitized single path component. * @returns the name within {@link MAX_NAME_LENGTH}. */ function truncateKeepingExtension(name: string): string { if (name.length <= MAX_NAME_LENGTH) return name const extension = extname(name) // An "extension" that eats the whole budget is not one worth keeping. if (extension.length === 0 || extension.length >= MAX_NAME_LENGTH) return name.slice(0, MAX_NAME_LENGTH) return `${name.slice(0, MAX_NAME_LENGTH - extension.length)}${extension}` } /** * Turn the sender's file name into one component safe to join onto a directory. * * The order is the substance. `basename` runs first because the input may be a * whole path; the separator pass cannot then be dropped, because POSIX * `basename` does not split on `\` and a Windows-shaped name reaches a Linux * host unchanged. Stripping leading dots is what turns `..` — a name that * escapes the directory — into nothing at all. * * Filesystem safety is all this buys. The name still rides into the note the * model reads; that layer's defense is the standing presence line. * @param name - the sender's file name, absent when the message carried none. * @returns a single path component, never empty. */ export function sanitizeFileName(name: string | undefined): string { const stripped = basename(name ?? '') .replace(/[\x00-\x1f\x7f]/g, '') .replace(/[/\\]/g, '_') .replace(/^\.+/, '') const safe = truncateKeepingExtension(escapeReservedName(stripped)) return safe === '' ? FALLBACK_NAME : safe } /** * The name one file lands under, given what already landed beside it. * Collisions are scoped to a single message's directory, so the counter only * ever separates the same name sent twice in one breath. * @param taken - names already claimed in this directory; the chosen one joins them. * @param name - the sanitized name. * @returns the name to write: `app.log`, then `app-2.log`. */ function claimFileName(taken: Set, name: string): string { const extension = extname(name) const stem = name.slice(0, name.length - extension.length) let candidate = name for (let ordinal = 2; taken.has(candidate); ordinal += 1) candidate = `${stem}-${ordinal}${extension}` taken.add(candidate) return candidate } /** * The directory one message's files land in. A component per message scopes * name collisions to that message and keeps the listing in send order. * * The stamp is UTC: the name only has to be stable and sortable, and the local * zone of whichever host ran the channel is not something a later reader can * recover anyway. The digest is what ties a directory back to its message. * * `resolve`, not `join`: every path this module hands out is absolute, because a * relative one rides into the note the model reads and points at whichever * directory that model's own tools happen to run in. * @param msg - the inbound message. * @param workspace - the conversation's workspace directory. * @returns the absolute directory path. */ function inboxDirectoryFor(msg: NormalizedMessage, workspace: string): string { const sentAt = Number.isFinite(msg.createTime) && msg.createTime > 0 ? msg.createTime : Date.now() const stamp = new Date(sentAt).toISOString().slice(0, 19).replace(/:/g, '') const digest = createHash('sha256').update(msg.messageId).digest('hex').slice(0, 8) return resolve(workspace, CHANNEL_DIRECTORY, INBOX_DIRECTORY, `${stamp}-${digest}`) } /** * Remove a file that must not stay. No second note on failure — the one * explaining the skip is already written, and complaining twice about a file * nobody was promised is noise. The operator does hear about a real failure: * the note then says "not saved" while the file sits in the workspace, and * nobody else is in a position to notice that they disagree. * @param path - the file to unlink. * @param report - operator console line. */ async function discardFile(path: string, report: (line: string) => void): Promise { await unlink(path).catch((error: unknown) => { // Nothing there is the outcome asked for, not a failure: a download that // died before it created the file leaves nothing to remove. if ((error as { code?: unknown } | null)?.code === 'ENOENT') return report(`lark-channel: removing ${path} failed, so it stays in the workspace: ${failureDetail(error)}`) }) } /** * Remove one message's directory once nothing landed in it. `rmdir` and not a * recursive remove: it refuses a directory with anything in it, so a mistake * here can never take bytes with it. A failure is swallowed and not reported — * unlike a file that would not go away, a leftover empty directory contradicts * nothing anyone was told. * @param path - the message directory. */ async function discardDirectory(path: string): Promise { await rmdir(path).catch(() => {}) } /** * The note naming what landed and where. Absolute paths, because nothing here * knows which directory the model's own tools run in. * @param landed - the files on disk. * @returns the note as it rides the message text. */ function landedNote(landed: readonly LandedFile[]): string { return `(收到 ${landed.length} 个文件,已存到工作区:\n${landed.map(file => `- ${file.path}`).join('\n')})` } /** * Land every file one message carried in the conversation's workspace. * * Nothing is dropped silently: a file that is refused, too big, or fails to * download leaves a note the model reads, because a model that received a file * and believes it did not is worse off than one that knows what it is missing. * @param msg - the inbound message. * @param port - transport used to stream the bytes to disk. * @param options - workspace, switch, and the per-file budget. * @returns the files on disk and the notes to append to the text. */ export async function collectInboundFiles( msg: NormalizedMessage, port: InboundFilePort, options: InboundOptions, ): Promise { const resources = msg.resources.filter(isLandableResource) if (resources.length === 0) return { landed: [], notes: [] } if (!options.enabled) { // Told, not hidden: someone who attaches a log is talking about the log. // // "Not saved" and not "not received": with `attachImages` on, an image this // switch keeps off the disk is STILL attached as a content block by // `images.ts` — a deliberate ruling — so a note claiming the channel never // received it would contradict the picture sitting right beside it. What // this switch actually costs the model is the path, and that is what it says. return { landed: [], notes: [`(用户发送了 ${resources.length} 个文件,本渠道未把它们存入工作区,因此没有可读取的路径:receiveFiles 未开启)`], } } const requested = inboxDirectoryFor(msg, options.workspace) // The PROVEN directory, and what every path below is built from. Spelled and // canonical are two different directories whenever a link sits between them, // and a proof taken on one of them says nothing about the other. let directory: string try { // The transport streams into an existing directory; it does not make one. await mkdir(requested, { recursive: true }) // And the directory it just made has to be PROVEN inside the workspace // rather than merely spelled that way. The sanitizer cannot see a symlink — // it judges one name, while `resolve` and `join` fold `..` and then answer // "inside the workspace" for a path whose own components lead anywhere at // all: a `.dsh-lark/inbox` pointing elsewhere takes `mkdir` with it, and // the sender's next file lands in the link's target under a name the sender // chose. So containment is asked of the filesystem and not of the string, // the same way outbound refuses to trust a path it has not canonicalized // (ADR 0004) — and a write earns that question at least as much as a read. // // The canonical form is then KEPT and joined onto, because a proof is only // worth anything on the path the bytes actually travel: writing through // `requested` after canonicalizing it would leave the same link free to be // swapped in between the check and the download, and the check would have // been about a directory nothing was written to. const landing = realpathSync(requested) const container = canonicalPathOf(options.workspace) ?? resolve(options.workspace) if (!isWithinContainer(landing, container)) { throw new Error('the inbox directory does not resolve inside the workspace') } directory = landing } catch (error) { const detail = failureDetail(error) // Nothing was downloaded, so whatever this attempt did create is an empty // directory nobody was promised — including one made through a link. await discardDirectory(requested) options.report(`lark-channel: could not create the inbox directory ${requested}: ${detail}`) return { landed: [], notes: [`(收到 ${resources.length} 个文件,但无法在工作区创建 ${CHANNEL_DIRECTORY}/${INBOX_DIRECTORY}/:${detail})`], } } const landed: LandedFile[] = [] const skipped: string[] = [] const claimed = new Set() // One name for the ceiling, so what the note quotes and what the loop // enforces cannot drift apart. const messageCeiling = options.maxFileBytes * MESSAGE_BYTES_FACTOR let budget = messageCeiling for (const [index, resource] of resources.entries()) { const fileName = claimFileName(claimed, sanitizeFileName(resource.fileName)) const destination = join(directory, fileName) try { const { contentType, bytesWritten } = await port.downloadResourceToFile( msg.messageId, resource.fileKey, downloadTypeOf(resource.type), destination, ) if (bytesWritten > options.maxFileBytes) { await discardFile(destination, options.report) skipped.push( `(文件 ${fileName} 有 ${formatBytesForChat(bytesWritten)},` + `超过单个文件上限 ${formatBytesForChat(options.maxFileBytes)},未保存)`, ) continue } if (bytesWritten > budget) { // The message total is a ceiling, not a hint: the file that would break // it goes back off the disk, and everything behind it stays undownloaded. await discardFile(destination, options.report) skipped.push( `(单条消息总量上限 ${formatBytesForChat(messageCeiling)} 已用尽,` + `还有 ${resources.length - index} 个文件未保存)`, ) break } budget -= bytesWritten landed.push({ fileKey: resource.fileKey, type: resource.type, path: destination, bytes: bytesWritten, ...contentType === undefined ? {} : { contentType }, fileName, }) } catch (error) { const detail = failureDetail(error) // A half-streamed file is indistinguishable from a complete one once it // sits in the workspace, so the remains of a failed download go away. await discardFile(destination, options.report) options.report(`lark-channel: downloading ${fileName} of message ${msg.messageId} failed: ${detail}`) skipped.push(`(文件 ${fileName} 下载失败:${detail})`) } } // A message whose every file was refused would otherwise leave its directory // behind, one empty `-/` per rejected message, accumulating in // exactly the directory the `.gitignore` hint sends people to look at. if (landed.length === 0) await discardDirectory(directory) return { landed, notes: [ ...landed.length === 0 ? [] : [landedNote(landed)], ...skipped, // Only worth saying once something is actually sitting in the repository. ...options.hintWorkspace === true && landed.length > 0 ? [`(提示:${CHANNEL_DIRECTORY}/ 未被 git 忽略,可加入 .gitignore)`] : [], ], } }