1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5
6import type { ChatAttachment } from "@t3tools/contracts";
7import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
8
9import {
10 normalizeAttachmentRelativePath,
11 resolveAttachmentRelativePath,
12} from "./attachmentPaths.ts";
13import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
14
15const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
16const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
17const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
18const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
19const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}";
20const ATTACHMENT_ID_PATTERN = new RegExp(
21 `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`,
22 "i",
23);
24
25export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending";
26const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
27const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000;
28
29export function toSafeThreadAttachmentSegment(threadId: string): string | null {
30 const segment = threadId
31 .trim()
32 .toLowerCase()
33 .replace(/[^a-z0-9_-]+/gi, "-")
34 .replace(/-+/g, "-")
35 .replace(/^[-_]+|[-_]+$/g, "")
36 .slice(0, ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS)
37 .replace(/[-_]+$/g, "");
38 if (segment.length === 0) {
39 return null;
40 }
41 return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
42}
43
44export function attachmentFileExtension(fileName: string): string {
45 const extension = NodePath.extname(fileName).toLowerCase();
46 // ".part" is reserved for in-flight uploads; a stored "archive.part" would
47 // look stale to sweepStalePendingAttachments and get deleted.
48 if (extension === ".part" || !/^\.[a-z0-9]{1,10}$/.test(extension)) {
49 return ".bin";
50 }
51 return extension;
52}
53
54function attachmentIdExtensionSuffix(extension: string | undefined): string {
55 if (!extension) {
56 return "";
57 }
58 const normalized = extension.replace(/^\./, "").toLowerCase();
59 return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized)
60 ? `-${normalized}`
61 : "-bin";
62}
63
64export function createPendingAttachmentId(extension?: string): string {
65 return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
66}
67
68export function parseAttachmentUuid(attachmentId: string): string | null {
69 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
70 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
71 return null;
72 }
73 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;
74}
75
76export function parseAttachmentFileExtension(attachmentId: string): string | null {
77 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
78 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
79 return null;
80 }
81 return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null;
82}
83
84export function createAttachmentId(threadId: string, extension?: string): string | null {
85 const threadSegment = toSafeThreadAttachmentSegment(threadId);
86 if (!threadSegment) {
87 return null;
88 }
89 return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
90}
91
92export function createDeterministicAttachmentId(
93 threadId: string,
94 stableKey: string,
95): string | null {
96 const threadSegment = toSafeThreadAttachmentSegment(threadId);
97 if (!threadSegment) return null;
98 const hash = NodeCrypto.createHash("sha256")
99 .update(JSON.stringify([threadId, stableKey]))
100 .digest("hex")
101 .slice(0, 32);
102 const uuid = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20)}`;
103 return `${threadSegment}-${uuid}`;
104}
105
106/**
107 * Stable key for an inline chat attachment. A retry resends the same bytes and
108 * keeps its id. Editing a queued message reuses the message id, so the bytes
109 * also stop a new image from taking an earlier image's id and file. Clients
110 * cache attachment images by id, which relies on ids never changing bytes.
111 */
112export function chatAttachmentStableKey(
113 messageId: string,
114 index: number,
115 bytes: Uint8Array,
116): string {
117 const digest = NodeCrypto.createHash("sha256").update(bytes).digest("hex");
118 return `${messageId}:${index}:${digest}`;
119}
120
121export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
122 const normalizedId = normalizeAttachmentRelativePath(attachmentId);
123 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
124 return null;
125 }
126 const match = normalizedId.match(ATTACHMENT_ID_PATTERN);
127 if (!match) {
128 return null;
129 }
130 return match[1]?.toLowerCase() ?? null;
131}
132
133/**
134 * Attachments a thread's tool calls published: `html_render` pages and
135 * captured MCP App documents. Only ids minted for this thread count, so
136 * deleting a fork never removes its source's pages.
137 */
138export function threadHtmlRenderAttachmentIds(
139 threadId: string,
140 items: Iterable<{ readonly toolName: string | null | undefined; readonly output?: unknown }>,
141) {
142 const segment = toSafeThreadAttachmentSegment(threadId);
143 if (segment === null) return [];
144 return Array.from(items).flatMap((item) => {
145 const attachmentId =
146 htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId;
147 return attachmentId !== undefined &&
148 parseThreadSegmentFromAttachmentId(attachmentId) === segment
149 ? [attachmentId]
150 : [];
151 });
152}
153
154/** Null for attachment types this build does not know; callers skip those. */
155export function attachmentRelativePath(attachment: ChatAttachment): string | null {
156 switch (attachment.type) {
157 case "image": {
158 const extension = inferImageExtension({
159 mimeType: attachment.mimeType,
160 fileName: attachment.name,
161 });
162 return `${attachment.id}${extension}`;
163 }
164 case "file":
165 return `${attachment.id}${attachmentFileExtension(attachment.name)}`;
166 default:
167 return null;
168 }
169}
170
171export function resolveAttachmentPath(input: {
172 readonly attachmentsDir: string;
173 readonly attachment: ChatAttachment;
174}): string | null {
175 const relativePath = attachmentRelativePath(input.attachment);
176 if (!relativePath) {
177 return null;
178 }
179 return resolveAttachmentRelativePath({
180 attachmentsDir: input.attachmentsDir,
181 relativePath,
182 });
183}
184
185export function attachmentPathsById(input: {
186 readonly attachmentsDir: string;
187 readonly attachmentId: string;
188}): ReadonlyArray<string> {
189 const normalizedId = normalizeAttachmentRelativePath(input.attachmentId);
190 if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) return [];
191 const fileExtension = parseAttachmentFileExtension(normalizedId);
192 const extensions = fileExtension
193 ? [`.${fileExtension.toLowerCase()}`]
194 : ATTACHMENT_FILENAME_EXTENSIONS;
195 return extensions.flatMap((extension) => {
196 const file = resolveAttachmentRelativePath({
197 attachmentsDir: input.attachmentsDir,
198 relativePath: `${normalizedId}${extension}`,
199 });
200 return file ? [file] : [];
201 });
202}
203
204export function resolveAttachmentPathById(input: {
205 readonly attachmentsDir: string;
206 readonly attachmentId: string;
207}): string | null {
208 return attachmentPathsById(input).find((file) => NodeFS.existsSync(file)) ?? null;
209}
210
211export type AttachmentClaimPlan =
212 | {
213 readonly ok: true;
214 readonly finalId: string;
215 readonly currentPath: string;
216 readonly finalPath: string;
217 }
218 | { readonly ok: false; readonly reason: string };
219
220export function planAttachmentClaim(input: {
221 readonly attachmentsDir: string;
222 readonly threadId: string;
223 readonly attachmentId: string;
224}): AttachmentClaimPlan {
225 const uuid = parseAttachmentUuid(input.attachmentId);
226 const requestedSegment = parseThreadSegmentFromAttachmentId(input.attachmentId);
227 if (!uuid || !requestedSegment) {
228 return { ok: false, reason: "invalid attachment id" };
229 }
230
231 if (!toSafeThreadAttachmentSegment(input.threadId)) {
232 return { ok: false, reason: "invalid thread id" };
233 }
234 if (requestedSegment !== PENDING_ATTACHMENT_THREAD_SEGMENT) {
235 return { ok: false, reason: "attachment must be a pending upload" };
236 }
237
238 const currentPath = resolveAttachmentPathById({
239 attachmentsDir: input.attachmentsDir,
240 attachmentId: input.attachmentId,
241 });
242 if (!currentPath) {
243 return { ok: false, reason: "attachment not found (removed or expired)" };
244 }
245 const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined;
246 const finalId = createAttachmentId(input.threadId, fileExtension);
247 if (!finalId) {
248 return { ok: false, reason: "failed to create attachment id" };
249 }
250
251 const expectedFinalPath = resolveAttachmentRelativePath({
252 attachmentsDir: input.attachmentsDir,
253 relativePath: `${finalId}${NodePath.extname(currentPath)}`,
254 });
255 if (!expectedFinalPath) {
256 return { ok: false, reason: "failed to resolve attachment path" };
257 }
258 return {
259 ok: true,
260 finalId,
261 currentPath,
262 finalPath: expectedFinalPath,
263 };
264}
265
266export function sweepStalePendingAttachments(input: {
267 readonly attachmentsDir: string;
268 readonly nowMs: number;
269}): { readonly deleted: number } {
270 let entries: string[];
271 try {
272 entries = NodeFS.readdirSync(input.attachmentsDir);
273 } catch {
274 return { deleted: 0 };
275 }
276
277 let deleted = 0;
278 for (const entry of entries) {
279 const isPartial = entry.endsWith(".part");
280 if (!isPartial) {
281 const attachmentId = parseAttachmentIdFromRelativePath(entry);
282 if (
283 !attachmentId ||
284 parseThreadSegmentFromAttachmentId(attachmentId) !== PENDING_ATTACHMENT_THREAD_SEGMENT
285 ) {
286 continue;
287 }
288 }
289
290 const resolved = resolveAttachmentRelativePath({
291 attachmentsDir: input.attachmentsDir,
292 relativePath: entry,
293 });
294 if (!resolved) {
295 continue;
296 }
297 try {
298 const maxAgeMs = isPartial ? PARTIAL_UPLOAD_MAX_AGE_MS : PENDING_ATTACHMENT_MAX_AGE_MS;
299 if (input.nowMs - NodeFS.statSync(resolved).mtimeMs > maxAgeMs) {
300 NodeFS.unlinkSync(resolved);
301 deleted += 1;
302 }
303 } catch {
304 continue;
305 }
306 }
307
308 return { deleted };
309}
310
311export function parseAttachmentIdFromRelativePath(relativePath: string): string | null {
312 const normalized = normalizeAttachmentRelativePath(relativePath);
313 if (!normalized || normalized.includes("/")) {
314 return null;
315 }
316 const extensionIndex = normalized.lastIndexOf(".");
317 if (extensionIndex <= 0) {
318 return null;
319 }
320 const id = normalized.slice(0, extensionIndex);
321 return id.length > 0 && !id.includes(".") ? id : null;
322}