| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | |
| 6 | import type { ChatAttachment } from "@t3tools/contracts"; |
| 7 | import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; |
| 8 | |
| 9 | import { |
| 10 | normalizeAttachmentRelativePath, |
| 11 | resolveAttachmentRelativePath, |
| 12 | } from "./attachmentPaths.ts"; |
| 13 | import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; |
| 14 | |
| 15 | const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; |
| 16 | const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; |
| 17 | const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; |
| 18 | const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; |
| 19 | const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}"; |
| 20 | const ATTACHMENT_ID_PATTERN = new RegExp( |
| 21 | `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`, |
| 22 | "i", |
| 23 | ); |
| 24 | |
| 25 | export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; |
| 26 | const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; |
| 27 | const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; |
| 28 | |
| 29 | export 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 | |
| 44 | export 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 | |
| 54 | function 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 | |
| 64 | export function createPendingAttachmentId(extension?: string): string { |
| 65 | return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`; |
| 66 | } |
| 67 | |
| 68 | export 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 | |
| 76 | export 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 | |
| 84 | export 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 | |
| 92 | export 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 | */ |
| 112 | export 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 | |
| 121 | export 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 | */ |
| 138 | export 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. */ |
| 155 | export 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 | |
| 171 | export 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 | |
| 185 | export 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 | |
| 204 | export 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 | |
| 211 | export 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 | |
| 220 | export 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 | |
| 266 | export 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 | |
| 311 | export 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 | } |