UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

618 lines (617 loc) 27 kB
import { y as resolveStateDir } from "./paths-mvMm5bYV.js"; import { S as safeFileURLToPath } from "./fs-safe-aqmM_n6V.js"; import { i as readLocalFileSafely } from "./secure-temp-dir-XAWcZnE2.js"; import { p as resolveUserPath } from "./utils-CCC-BEJH.js"; import { m as writeJson, o as tryReadJson } from "./json-files-2umMHm0W.js"; import { c as resolveDefaultAgentId } from "./agent-scope-config-CgCYpZfK.js"; import { i as getRuntimeConfig } from "./io-Gi7-pyU-.js"; import "./config-C9RxTsn1.js"; import { n as authorizeOperatorScopesForMethod } from "./method-scopes-BtdN3y7s.js"; import { t as isPassThroughRemoteMediaSource } from "./media-source-url-BL9SUd7E.js"; import "./local-file-access-CBe_wA_B.js"; import { d as saveMediaSource, t as MEDIA_MAX_BYTES, u as saveMediaBuffer } from "./store-C9M_0A1p.js"; import { o as createImageProcessor, p as readImageProbeFromHeader, s as getImageMetadata } from "./image-ops-PfTOpg74.js"; import { d as readSessionMessagesAsync } from "./session-utils.fs-D_8-X4jl.js"; import { u as loadSessionEntry } from "./session-utils-CHNCuY8a.js"; import "./media-services-DE3J1qM-.js"; import { n as assertLocalMediaAllowed } from "./local-media-access-Bw3v5URj.js"; import { a as sendJson, o as sendMethodNotAllowed, s as sendMissingScopeForbidden } from "./http-common-DunL6val.js"; import { l as resolveOpenAiCompatibleHttpOperatorScopes, t as authorizeGatewayHttpRequestOrReply, u as resolveOpenAiCompatibleHttpSenderIsOwner } from "./http-auth-utils-Du8mRZIw.js"; import "./http-utils-leZWdpma.js"; import path from "node:path"; import fs from "node:fs/promises"; import { randomUUID } from "node:crypto"; //#region src/gateway/managed-image-attachments.ts const OUTGOING_IMAGE_ROUTE_PREFIX = "/api/chat/media/outgoing"; const DEFAULT_TRANSIENT_OUTGOING_IMAGE_TTL_MS = 900 * 1e3; const MANAGED_OUTGOING_ATTACHMENT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const DATA_URL_RE = /^data:/i; const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/; const DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS = { maxBytes: 12 * 1024 * 1024, maxWidth: 4096, maxHeight: 4096, maxPixels: 2e7 }; const sessionManagedOutgoingAttachmentIndexCache = /* @__PURE__ */ new Map(); const MAX_SESSION_MANAGED_OUTGOING_ATTACHMENT_INDEX_CACHE_ENTRIES = 500; function buildSessionManagedOutgoingAttachmentIndexCacheKey(sessionKey, agentId) { return sessionKey === "global" && agentId ? `agent:${agentId}:global` : sessionKey; } function resolveManagedImageAttachmentLimits(config) { return { maxBytes: config?.maxBytes ?? DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS.maxBytes, maxWidth: config?.maxWidth ?? DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS.maxWidth, maxHeight: config?.maxHeight ?? DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS.maxHeight, maxPixels: config?.maxPixels ?? DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS.maxPixels }; } function formatLimitMiB(bytes) { if (bytes < 1024 * 1024) return `${bytes} bytes`; return Number.isInteger(bytes / (1024 * 1024)) ? `${bytes / (1024 * 1024)} MiB` : `${(bytes / (1024 * 1024)).toFixed(1)} MiB`; } function createManagedImageAttachmentError(message) { const error = new Error(message); error.name = "ManagedImageAttachmentError"; return error; } function isManagedImageAttachmentSafeError(error) { if (!(error instanceof Error)) return false; if (error.name === "ManagedImageAttachmentError") return true; return error.message.startsWith("Managed image attachment ") || error.message.startsWith("Invalid image data URL"); } function getSanitizedManagedImageAttachmentError(error, alt) { if (isManagedImageAttachmentSafeError(error)) return error; return createManagedImageAttachmentError(`Managed image attachment ${JSON.stringify(alt)} could not be prepared`); } function validateManagedImageBuffer(buffer, alt, limits) { if (buffer.byteLength > limits.maxBytes) throw createManagedImageAttachmentError(`Managed image attachment ${JSON.stringify(alt)} exceeds the ${formatLimitMiB(limits.maxBytes)} byte limit`); } function estimateBase64DecodedByteLength(base64) { const normalized = base64.replace(/\s+/g, ""); const paddingMatch = /=+$/u.exec(normalized); const padding = Math.min(paddingMatch?.[0].length ?? 0, 2); return Math.floor(normalized.length * 3 / 4) - padding; } function getManagedImageMetadataLimitError(metadata, alt, limits) { if (!metadata) return `Managed image attachment ${JSON.stringify(alt)} is missing readable dimensions`; if (metadata.width > limits.maxWidth) return `Managed image attachment ${JSON.stringify(alt)} exceeds the ${limits.maxWidth}px width limit`; if (metadata.height > limits.maxHeight) return `Managed image attachment ${JSON.stringify(alt)} exceeds the ${limits.maxHeight}px height limit`; if (metadata.width * metadata.height > limits.maxPixels) return `Managed image attachment ${JSON.stringify(alt)} exceeds the ${limits.maxPixels.toLocaleString("en-US")} pixel limit`; return null; } function orientManagedImageMetadata(buffer, metadata) { if (!metadata) return null; const orientation = readImageProbeFromHeader(buffer)?.orientation; return orientation === 5 || orientation === 6 || orientation === 7 || orientation === 8 ? { width: metadata.height, height: metadata.width } : metadata; } async function resizeManagedImageBufferToLimits(params) { const resized = await createImageProcessor().encode(params.buffer, { format: "auto", limits: { maxWidth: params.limits.maxWidth, maxHeight: params.limits.maxHeight, maxPixels: params.limits.maxPixels }, opaque: { format: "jpeg", quality: 92 }, transparent: { format: "png", compressionLevel: 9 }, transparency: "auto" }); return { buffer: resized.data, contentType: resized.mimeType, width: resized.width, height: resized.height }; } function resolveOutgoingRecordsDir(stateDir = resolveStateDir()) { return path.join(stateDir, "media", "outgoing", "records"); } function resolveOutgoingOriginalsDir(stateDir = resolveStateDir()) { return path.join(stateDir, "media", "outgoing", "originals"); } function resolveOutgoingRecordPath(attachmentId, stateDir = resolveStateDir()) { return path.join(resolveOutgoingRecordsDir(stateDir), `${attachmentId}.json`); } function buildOutgoingVariantUrl(sessionKey, attachmentId, variant) { return `${OUTGOING_IMAGE_ROUTE_PREFIX}/${encodeURIComponent(sessionKey)}/${attachmentId}/${variant}`; } function deriveAltText(source, index) { const fallback = `Generated image ${index + 1}`; try { if (/^https?:\/\//i.test(source)) { const parsed = new URL(source); return path.basename(parsed.pathname || "").trim() || fallback; } } catch {} return path.basename(source).trim() || fallback; } function resolveLocalMediaPath(source) { const trimmed = source.trim(); if (!trimmed || isPassThroughRemoteMediaSource(trimmed) || DATA_URL_RE.test(trimmed)) return; if (trimmed.startsWith("file://")) try { return safeFileURLToPath(trimmed); } catch { return; } if (trimmed.startsWith("~")) return resolveUserPath(trimmed); if (path.isAbsolute(trimmed) || WINDOWS_DRIVE_RE.test(trimmed)) return path.resolve(trimmed); } function parseImageDataUrl(source, alt, limits) { const trimmed = source.trim(); if (!trimmed.startsWith("data:")) return { kind: "not-data-url" }; const match = /^data:([^;,]+)(?:;[^,]*)*;base64,([A-Za-z0-9+/=\s]+)$/i.exec(trimmed); if (!match) throw new Error("Invalid image data URL"); const contentType = match[1]?.trim().toLowerCase() ?? ""; if (!contentType.startsWith("image/")) return { kind: "non-image-data-url" }; if (estimateBase64DecodedByteLength(match[2]) > limits.maxBytes) throw createManagedImageAttachmentError(`Managed image attachment ${JSON.stringify(alt)} exceeds the ${formatLimitMiB(limits.maxBytes)} byte limit`); return { kind: "image-data-url", buffer: Buffer.from(match[2].replace(/\s+/g, ""), "base64"), contentType }; } async function getVariantStats(filePath) { const { buffer: metadataBuffer, stat } = await readLocalFileSafely({ filePath }); const metadata = await getImageMetadata(metadataBuffer).catch(() => null) ?? { width: null, height: null }; return { width: metadata.width ?? null, height: metadata.height ?? null, sizeBytes: Number.isFinite(stat.size) ? stat.size : null }; } async function writeManagedImageRecord(record, stateDir = resolveStateDir()) { await writeJson(resolveOutgoingRecordPath(record.attachmentId, stateDir), record, { trailingNewline: true }); } async function deleteManagedImageRecordArtifacts(record, stateDir = resolveStateDir()) { const files = /* @__PURE__ */ new Set(); if (record.original?.path) files.add(record.original.path); let deletedFileCount = 0; for (const filePath of files) try { await fs.rm(filePath, { force: true }); deletedFileCount += 1; } catch {} try { await fs.rm(resolveOutgoingRecordPath(record.attachmentId, stateDir), { force: true }); } catch {} return deletedFileCount; } async function deleteOrphanManagedImageFiles(params) { let deletedFileCount = 0; for (const dir of [resolveOutgoingOriginalsDir(params.stateDir)]) { let names; try { names = await fs.readdir(dir); } catch { continue; } for (const name of names) { const filePath = path.join(dir, name); if (params.referencedPaths.has(filePath)) continue; try { if (!(await fs.stat(filePath)).isFile()) continue; } catch { continue; } try { await fs.rm(filePath, { force: true }); deletedFileCount += 1; } catch {} } } return deletedFileCount; } async function cleanupManagedOutgoingImageRecords(params) { const stateDir = params?.stateDir ?? resolveStateDir(); const nowMs = params?.nowMs ?? Date.now(); const transientMaxAgeMs = params?.transientMaxAgeMs ?? DEFAULT_TRANSIENT_OUTGOING_IMAGE_TTL_MS; const sessionKeyFilter = params?.sessionKey ?? null; const agentIdFilter = params?.agentId?.trim() || void 0; const defaultAgentId = sessionKeyFilter === "global" ? resolveDefaultAgentId(getRuntimeConfig()) : void 0; const forceDeleteSessionRecords = params?.forceDeleteSessionRecords === true; const recordsDir = resolveOutgoingRecordsDir(stateDir); let names; try { names = await fs.readdir(recordsDir); } catch { names = []; } let deletedRecordCount = 0; let deletedFileCount = 0; let retainedCount = 0; const retainedReferencedPaths = /* @__PURE__ */ new Set(); const transcriptAttachmentIndexCache = /* @__PURE__ */ new Map(); for (const name of names) { if (!name.endsWith(".json")) continue; const recordPath = path.join(recordsDir, name); const record = await tryReadJson(recordPath); if (!record) { try { await fs.rm(recordPath, { force: true }); } catch {} deletedRecordCount += 1; continue; } if (sessionKeyFilter && record.sessionKey !== sessionKeyFilter) { if (record.original?.path) retainedReferencedPaths.add(record.original.path); retainedCount += 1; continue; } if (sessionKeyFilter === "global" && record.sessionKey === "global" && (agentIdFilter && resolveManagedImageRecordAgentId(record, defaultAgentId) !== agentIdFilter || !agentIdFilter && typeof record.agentId === "string" && record.agentId.trim())) { if (record.original?.path) retainedReferencedPaths.add(record.original.path); retainedCount += 1; continue; } let shouldDelete; if (forceDeleteSessionRecords && (!sessionKeyFilter || record.sessionKey === sessionKeyFilter)) shouldDelete = true; else if (record.messageId) shouldDelete = !await recordMatchesTranscriptMessage(record, transcriptAttachmentIndexCache); else { const createdAtMs = Date.parse(record.createdAt); shouldDelete = Number.isFinite(createdAtMs) && nowMs - createdAtMs >= transientMaxAgeMs; } if (shouldDelete) { deletedRecordCount += 1; deletedFileCount += await deleteManagedImageRecordArtifacts(record, stateDir); } else { if (record.original?.path) retainedReferencedPaths.add(record.original.path); retainedCount += 1; } } deletedFileCount += await deleteOrphanManagedImageFiles({ stateDir, referencedPaths: retainedReferencedPaths }); return { deletedRecordCount, deletedFileCount, retainedCount }; } function resolveManagedImageRecordAgentId(record, defaultAgentId) { return record.agentId?.trim() || defaultAgentId; } async function readManagedImageRecord(attachmentId, stateDir = resolveStateDir()) { try { const raw = await fs.readFile(resolveOutgoingRecordPath(attachmentId, stateDir), "utf-8"); return JSON.parse(raw); } catch { return null; } } function buildManagedImageBlock(record) { const fullUrl = buildOutgoingVariantUrl(record.sessionKey, record.attachmentId, "full"); return { type: "image", url: fullUrl, openUrl: fullUrl, alt: record.alt, mimeType: record.original.contentType, width: record.original.width, height: record.original.height }; } function buildManagedOutgoingAttachmentRefKey(messageId, attachmentId) { return `${messageId}::${attachmentId}`; } function buildManagedImageResizeWarningBlock(params) { return { type: "text", text: `[Image warning] ${params.alt} exceeded gateway dimension/pixel limits and was resized from ${params.originalWidth}×${params.originalHeight} to ${params.resizedWidth}×${params.resizedHeight}.` }; } function toRecordFilename(filePath) { return path.basename(filePath).trim() || null; } function asArray(value) { return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim()) : []; } function parseManagedOutgoingRoute(value) { try { const match = new URL(value, "http://localhost").pathname.match(/^\/api\/chat\/media\/outgoing\/([^/]+)\/([^/]+)\/full$/); if (!match) return null; if (!MANAGED_OUTGOING_ATTACHMENT_ID_RE.test(match[2])) return null; return { sessionKey: decodeURIComponent(match[1]), attachmentId: match[2] }; } catch { return null; } } function collectManagedOutgoingAttachmentRefs(blocks, expectedSessionKey) { const refs = /* @__PURE__ */ new Map(); for (const block of blocks ?? []) { if (block?.type !== "image") continue; for (const candidate of [block.url, block.openUrl]) { if (typeof candidate !== "string") continue; const parsed = parseManagedOutgoingRoute(candidate); if (!parsed) continue; if (expectedSessionKey && parsed.sessionKey !== expectedSessionKey) continue; refs.set(parsed.attachmentId, { attachmentId: parsed.attachmentId, sessionKey: parsed.sessionKey }); } } return [...refs.values()]; } function getCachedSessionManagedOutgoingAttachmentIndex(sessionKey, agentId, stat) { const cacheKey = buildSessionManagedOutgoingAttachmentIndexCacheKey(sessionKey, agentId); const cached = sessionManagedOutgoingAttachmentIndexCache.get(cacheKey); if (!cached) return null; if (cached.transcriptPath !== stat.transcriptPath || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) { sessionManagedOutgoingAttachmentIndexCache.delete(cacheKey); return null; } sessionManagedOutgoingAttachmentIndexCache.delete(cacheKey); sessionManagedOutgoingAttachmentIndexCache.set(cacheKey, cached); return cached.index; } function setCachedSessionManagedOutgoingAttachmentIndex(sessionKey, agentId, stat, index) { sessionManagedOutgoingAttachmentIndexCache.set(buildSessionManagedOutgoingAttachmentIndexCacheKey(sessionKey, agentId), { transcriptPath: stat.transcriptPath, mtimeMs: stat.mtimeMs, size: stat.size, index }); while (sessionManagedOutgoingAttachmentIndexCache.size > MAX_SESSION_MANAGED_OUTGOING_ATTACHMENT_INDEX_CACHE_ENTRIES) { const oldestKey = sessionManagedOutgoingAttachmentIndexCache.keys().next().value; if (!oldestKey) break; sessionManagedOutgoingAttachmentIndexCache.delete(oldestKey); } } async function getSessionManagedOutgoingAttachmentIndex(sessionKey, cache, agentId) { const cacheKey = buildSessionManagedOutgoingAttachmentIndexCacheKey(sessionKey, agentId); if (cache?.has(cacheKey)) return cache.get(cacheKey) ?? null; const { storePath, entry } = loadSessionEntry(sessionKey, sessionKey === "global" && agentId ? { agentId } : void 0); const sessionId = entry?.sessionId; if (!sessionId) { cache?.set(cacheKey, null); return null; } let transcriptStat = null; const transcriptPath = typeof entry?.sessionFile === "string" ? entry.sessionFile.trim() : ""; if (transcriptPath) try { const stat = await fs.stat(transcriptPath); transcriptStat = { transcriptPath, mtimeMs: stat.mtimeMs, size: stat.size }; const cachedIndex = getCachedSessionManagedOutgoingAttachmentIndex(sessionKey, agentId, transcriptStat); if (cachedIndex) { cache?.set(cacheKey, cachedIndex); return cachedIndex; } } catch { sessionManagedOutgoingAttachmentIndexCache.delete(cacheKey); } const messages = await readSessionMessagesAsync(sessionId, storePath, entry.sessionFile, { mode: "full", reason: "managed outgoing attachment index" }); const index = /* @__PURE__ */ new Set(); for (const message of messages) { const messageId = (message?.["__openclaw"])?.id; if (typeof messageId !== "string" || !messageId) continue; for (const ref of collectManagedOutgoingAttachmentRefs(Array.isArray(message?.content) ? message.content : [], sessionKey)) index.add(buildManagedOutgoingAttachmentRefKey(messageId, ref.attachmentId)); } if (transcriptStat) setCachedSessionManagedOutgoingAttachmentIndex(sessionKey, agentId, transcriptStat, index); cache?.set(cacheKey, index); return index; } async function recordMatchesTranscriptMessage(record, cache) { if (!record.messageId) return false; return (await getSessionManagedOutgoingAttachmentIndex(record.sessionKey, cache, record.agentId))?.has(buildManagedOutgoingAttachmentRefKey(record.messageId, record.attachmentId)) ?? false; } async function attachManagedOutgoingImagesToMessage(params) { const messageId = params.messageId.trim(); if (!messageId) return; const refs = collectManagedOutgoingAttachmentRefs(params.blocks); if (refs.length === 0) return; await Promise.all(refs.map(async ({ attachmentId, sessionKey }) => { const record = await readManagedImageRecord(attachmentId, params.stateDir); if (!record || record.sessionKey !== sessionKey) return; if (record.messageId === messageId && record.retentionClass === "history") return; await writeManagedImageRecord({ ...record, messageId, retentionClass: "history", updatedAt: (/* @__PURE__ */ new Date()).toISOString() }, params.stateDir); })); } async function createManagedOutgoingImageBlocks(params) { const sessionKey = params.sessionKey.trim(); if (!sessionKey) return []; const mediaUrls = asArray(params.mediaUrls); if (mediaUrls.length === 0) return []; const stateDir = params.stateDir ?? resolveStateDir(); const limits = resolveManagedImageAttachmentLimits(params.limits); const blocks = []; for (const [index, mediaUrl] of mediaUrls.entries()) { const fallbackAlt = `Generated image ${index + 1}`; const parsedDataUrl = parseImageDataUrl(mediaUrl, fallbackAlt, limits); const alt = parsedDataUrl.kind === "image-data-url" ? fallbackAlt : deriveAltText(mediaUrl, index); if (parsedDataUrl.kind === "non-image-data-url") continue; let savedOriginalPath = null; try { let resizeWarning = null; if (parsedDataUrl.kind === "image-data-url") validateManagedImageBuffer(parsedDataUrl.buffer, alt, limits); let savedOriginal = parsedDataUrl.kind === "image-data-url" ? await saveMediaBuffer(parsedDataUrl.buffer, parsedDataUrl.contentType, "outgoing/originals", limits.maxBytes, `generated-image-${index + 1}`) : await (async () => { const localMediaPath = resolveLocalMediaPath(mediaUrl); if (localMediaPath) await assertLocalMediaAllowed(localMediaPath, params.localRoots); return await saveMediaSource(mediaUrl, void 0, "outgoing/originals", Math.max(limits.maxBytes, MEDIA_MAX_BYTES)); })(); savedOriginalPath = savedOriginal.path; let savedOriginalContentType = savedOriginal.contentType; if (!savedOriginalContentType?.startsWith("image/")) { await fs.rm(savedOriginal.path, { force: true }).catch(() => {}); savedOriginalPath = null; continue; } if (savedOriginal.size > limits.maxBytes) throw createManagedImageAttachmentError(`Managed image attachment ${JSON.stringify(alt)} exceeds the ${formatLimitMiB(limits.maxBytes)} byte limit`); let originalBuffer = parsedDataUrl.kind === "image-data-url" ? parsedDataUrl.buffer : (await readLocalFileSafely({ filePath: savedOriginal.path })).buffer; validateManagedImageBuffer(originalBuffer, alt, limits); let originalStats = await getVariantStats(savedOriginal.path); if (originalStats.sizeBytes != null && originalStats.sizeBytes > limits.maxBytes) throw createManagedImageAttachmentError(`Managed image attachment ${JSON.stringify(alt)} exceeds the ${formatLimitMiB(limits.maxBytes)} byte limit`); const originalMetadata = originalStats.width != null && originalStats.height != null ? { width: originalStats.width, height: originalStats.height } : await getImageMetadata(originalBuffer); const originalDisplayMetadata = orientManagedImageMetadata(originalBuffer, originalMetadata); let effectiveMetadata = originalDisplayMetadata; let metadataLimitError = getManagedImageMetadataLimitError(effectiveMetadata, alt, limits); for (let resizeAttempt = 0; metadataLimitError; resizeAttempt += 1) { if (!effectiveMetadata) throw createManagedImageAttachmentError(metadataLimitError); if (resizeAttempt >= 3) throw createManagedImageAttachmentError(metadataLimitError); const resized = await resizeManagedImageBufferToLimits({ buffer: originalBuffer, limits }); validateManagedImageBuffer(resized.buffer, alt, limits); const replacement = await saveMediaBuffer(resized.buffer, resized.contentType, "outgoing/originals", limits.maxBytes, toRecordFilename(savedOriginal.path) ?? `generated-image-${index + 1}`); await fs.rm(savedOriginal.path, { force: true }).catch(() => {}); savedOriginal = replacement; savedOriginalContentType = replacement.contentType ?? resized.contentType; savedOriginalPath = savedOriginal.path; originalBuffer = resized.buffer; originalStats = await getVariantStats(savedOriginal.path); effectiveMetadata = orientManagedImageMetadata(originalBuffer, originalStats.width != null && originalStats.height != null ? { width: originalStats.width, height: originalStats.height } : await getImageMetadata(originalBuffer)); metadataLimitError = getManagedImageMetadataLimitError(effectiveMetadata, alt, limits); if (!metadataLimitError) resizeWarning = buildManagedImageResizeWarningBlock({ alt, originalWidth: originalDisplayMetadata?.width ?? effectiveMetadata?.width ?? resized.width, originalHeight: originalDisplayMetadata?.height ?? effectiveMetadata?.height ?? resized.height, resizedWidth: effectiveMetadata?.width ?? resized.width, resizedHeight: effectiveMetadata?.height ?? resized.height }); } const record = { attachmentId: randomUUID(), sessionKey, ...sessionKey === "global" && params.agentId?.trim() ? { agentId: params.agentId.trim() } : {}, messageId: params.messageId ?? null, createdAt: (/* @__PURE__ */ new Date()).toISOString(), retentionClass: params.messageId ? "history" : "transient", alt, original: { path: savedOriginal.path, contentType: savedOriginalContentType, width: originalStats.width, height: originalStats.height, sizeBytes: originalStats.sizeBytes, filename: toRecordFilename(savedOriginal.path) } }; await writeManagedImageRecord(record, stateDir); blocks.push(buildManagedImageBlock(record)); if (resizeWarning) blocks.push(resizeWarning); } catch (error) { if (savedOriginalPath) await fs.rm(savedOriginalPath, { force: true }).catch(() => {}); const sanitizedError = getSanitizedManagedImageAttachmentError(error, alt); if (params.continueOnPrepareError) { params.onPrepareError?.(sanitizedError); continue; } throw sanitizedError; } } return blocks; } function sendStatus(res, statusCode, body) { if (res.writableEnded) return; res.statusCode = statusCode; res.setHeader("content-type", "text/plain; charset=utf-8"); res.end(body); } function safeAttachmentFilename(value) { const fallback = "generated-image"; return (value ?? fallback).replace(/[\r\n"\\]/g, "_").trim() || fallback; } async function handleManagedOutgoingImageHttpRequest(req, res, opts) { const match = new URL(req.url ?? "/", "http://localhost").pathname.match(/^\/api\/chat\/media\/outgoing\/([^/]+)\/([^/]+)\/full$/); if (!match) return false; if (req.method !== "GET") { sendMethodNotAllowed(res, "GET"); return true; } const requestAuth = await authorizeGatewayHttpRequestOrReply({ req, res, auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter }); if (!requestAuth) return true; const scopeAuth = authorizeOperatorScopesForMethod("chat.history", resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth)); if (!scopeAuth.allowed) { sendMissingScopeForbidden(res, scopeAuth.missingScope); return true; } const encodedSessionKey = match[1]; const attachmentId = match[2]; if (!encodedSessionKey || !attachmentId) return false; if (!MANAGED_OUTGOING_ATTACHMENT_ID_RE.test(attachmentId)) { sendStatus(res, 404, "not found"); return true; } let sessionKey; try { sessionKey = decodeURIComponent(encodedSessionKey); } catch { sendStatus(res, 404, "not found"); return true; } const record = await readManagedImageRecord(attachmentId, opts.stateDir); if (!record || record.sessionKey !== sessionKey) { sendStatus(res, 404, "not found"); return true; } if (!resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth)) { sendJson(res, 403, { ok: false, error: { type: "forbidden", message: "owner access required" } }); return true; } if (!await recordMatchesTranscriptMessage(record)) { sendStatus(res, 404, "not found"); return true; } let body; try { body = (await readLocalFileSafely({ filePath: record.original.path })).buffer; } catch { sendStatus(res, 404, "not found"); return true; } res.statusCode = 200; res.setHeader("content-type", record.original.contentType || "application/octet-stream"); res.setHeader("content-length", String(body.byteLength)); res.setHeader("cache-control", "private, max-age=31536000, immutable"); res.setHeader("content-disposition", `inline; filename="${safeAttachmentFilename(record.original.filename)}"`); res.end(body); return true; } //#endregion export { handleManagedOutgoingImageHttpRequest as a, createManagedOutgoingImageBlocks as i, attachManagedOutgoingImagesToMessage as n, resolveManagedImageAttachmentLimits as o, cleanupManagedOutgoingImageRecords as r, DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS as t };