UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

216 lines (215 loc) 10.3 kB
import { s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js"; import { i as formatErrorMessage } from "./errors-BXgSefBE.js"; import { r as MAX_IMAGE_BYTES } from "./constants-Mf57IYS0.js"; import { l as mimeTypeFromFilePath, r as extensionForMime } from "./mime-C8mVE2Bw.js"; import { n as estimateBase64DecodedBytes } from "./base64-l6yrCyHc.js"; import { i as deleteMediaBuffer, u as saveMediaBuffer } from "./store-C9M_0A1p.js"; import { w as sniffMimeFromBase64 } from "./openclaw-tools-C0nKaVVY.js"; //#region src/gateway/chat-attachments.ts const OFFLOAD_THRESHOLD_BYTES = 2e6; const TEXT_ONLY_OFFLOAD_LIMIT = 10; /** Resolve the maximum decoded attachment size accepted for chat image inputs. */ function resolveChatAttachmentMaxBytes(cfg) { const configured = cfg.agents?.defaults?.mediaMaxMb; return Math.floor((typeof configured === "number" && Number.isFinite(configured) && configured > 0 ? configured : 20) * 1024 * 1024); } var UnsupportedAttachmentError = class extends Error { constructor(reason, message) { super(message); this.name = "UnsupportedAttachmentError"; this.reason = reason; } }; var MediaOffloadError = class extends Error { constructor(message, options) { super(message, options); this.name = "MediaOffloadError"; this.cause = options?.cause; } }; function normalizeMime(mime) { if (!mime) return; return normalizeOptionalLowercaseString(mime.split(";")[0]) || void 0; } function isImageMime(mime) { return typeof mime === "string" && mime.startsWith("image/"); } function isGenericContainerMime(mime) { return mime === "application/zip" || mime === "application/octet-stream"; } function shouldIgnoreImageMimeHint(params) { return isGenericContainerMime(params.sniffedMime) && isImageMime(params.hintedMime); } function isSpecificMime(mime) { return Boolean(mime && !isGenericContainerMime(mime)); } function resolveAttachmentMime(params) { const trustedProvidedMime = shouldIgnoreImageMimeHint({ sniffedMime: params.sniffedMime, hintedMime: params.providedMime }) ? void 0 : params.providedMime; const trustedLabelMime = shouldIgnoreImageMimeHint({ sniffedMime: params.sniffedMime, hintedMime: params.labelMime }) ? void 0 : params.labelMime; return isSpecificMime(params.sniffedMime) && params.sniffedMime || isSpecificMime(trustedProvidedMime) && trustedProvidedMime || isSpecificMime(trustedLabelMime) && trustedLabelMime || params.sniffedMime || trustedProvidedMime || trustedLabelMime || "application/octet-stream"; } function isValidBase64(value) { if (value.length === 0 || value.length % 4 !== 0) return false; return /^[A-Za-z0-9+/]+={0,2}$/.test(value); } function verifyDecodedSize(buffer, estimatedBytes, label) { if (Math.abs(buffer.byteLength - estimatedBytes) > 3) throw new Error(`attachment ${label}: base64 contains invalid characters (expected ~${estimatedBytes} bytes decoded, got ${buffer.byteLength})`); } function ensureExtension(label, mime) { if (/\.[a-zA-Z0-9]+$/.test(label)) return label; const ext = extensionForMime(mime) ?? ""; return ext ? `${label}${ext}` : label; } function assertSavedMedia(value, label) { if (value === null || typeof value !== "object" || !("id" in value) || typeof value.id !== "string") throw new Error(`attachment ${label}: saveMediaBuffer returned an unexpected shape`); const id = value.id; if (id.length === 0) throw new Error(`attachment ${label}: saveMediaBuffer returned an empty media ID`); if (id.includes("/") || id.includes("\\") || id.includes("\0")) throw new Error(`attachment ${label}: saveMediaBuffer returned an unsafe media ID (contains path separator or null byte)`); const path = value.path; if (typeof path !== "string" || path.length === 0) throw new Error(`attachment ${label}: saveMediaBuffer returned no on-disk path`); return { id, path }; } function normalizeAttachment(att, idx, opts) { const mime = att.mimeType ?? ""; const content = att.content; const label = att.fileName || att.type || `attachment-${idx + 1}`; if (typeof content !== "string") throw new Error(`attachment ${label}: content must be base64 string`); if (opts.requireImageMime && !mime.startsWith("image/")) throw new Error(`attachment ${label}: only image/* supported`); let base64 = content.trim(); if (opts.stripDataUrlPrefix) { const dataUrlMatch = /^data:[^;]+;base64,(.*)$/.exec(base64); if (dataUrlMatch) base64 = dataUrlMatch[1]; } return { label, mime, base64 }; } async function parseMessageWithAttachments(message, attachments, opts) { const maxBytes = opts?.maxBytes ?? 20 * 1024 * 1024; const log = opts?.log; const shouldForceImageOffload = opts?.supportsImages === false; const supportsInlineImages = opts?.supportsInlineImages !== false; const acceptNonImage = opts?.acceptNonImage !== false; if (!attachments || attachments.length === 0) return { message, images: [], imageOrder: [], offloadedRefs: [] }; const images = []; const imageOrder = []; const offloadedRefs = []; let updatedMessage = message; let textOnlyImageOffloadCount = 0; const savedMediaIds = []; try { for (const [idx, att] of attachments.entries()) { if (!att) continue; const { base64: b64, label, mime } = normalizeAttachment(att, idx, { stripDataUrlPrefix: true, requireImageMime: false }); if (b64.length === 0) throw new UnsupportedAttachmentError("empty-payload", `attachment ${label}: empty payload`); if (!isValidBase64(b64)) throw new Error(`attachment ${label}: invalid base64 content`); const sizeBytes = estimateBase64DecodedBytes(b64); if (sizeBytes > maxBytes) throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`); const providedMime = normalizeMime(mime); const sniffedMime = normalizeMime(await sniffMimeFromBase64(b64)); const finalMime = resolveAttachmentMime({ sniffedMime, providedMime, labelMime: normalizeMime(mimeTypeFromFilePath(label)) }); if (sniffedMime && providedMime && !isGenericContainerMime(providedMime) && sniffedMime !== providedMime) { const usedSource = finalMime === sniffedMime ? "sniffed" : finalMime === providedMime ? "provided" : "label-derived"; log?.warn(`attachment ${label}: mime mismatch (${providedMime} -> ${sniffedMime}), using ${usedSource}`); } const isImage = isImageMime(finalMime); if (isImage && !supportsInlineImages && !shouldForceImageOffload) throw new UnsupportedAttachmentError("text-only-image", `attachment ${label}: active model does not accept image inputs`); if (!isImage && !acceptNonImage) throw new UnsupportedAttachmentError("unsupported-non-image", `attachment ${label}: non-image attachments (${finalMime}) are not supported on this entrypoint`); if (isImage && sizeBytes > 6291456) throw new Error(`attachment ${label}: image exceeds size limit (${sizeBytes} > ${MAX_IMAGE_BYTES} bytes)`); if (shouldForceImageOffload && isImage && textOnlyImageOffloadCount >= TEXT_ONLY_OFFLOAD_LIMIT) { log?.warn(`attachment ${label}: dropping image because text-only offload limit ${TEXT_ONLY_OFFLOAD_LIMIT} was reached`); updatedMessage += "\n[image attachment omitted: text-only attachment limit reached]"; continue; } if (!(shouldForceImageOffload || !isImage || sizeBytes > OFFLOAD_THRESHOLD_BYTES)) { images.push({ type: "image", data: b64, mimeType: finalMime }); imageOrder.push("inline"); continue; } const buffer = Buffer.from(b64, "base64"); verifyDecodedSize(buffer, sizeBytes, label); let savedMedia; try { savedMedia = assertSavedMedia(await saveMediaBuffer(buffer, finalMime, "inbound", maxBytes, ensureExtension(label, finalMime)), label); } catch (err) { throw new MediaOffloadError(`[Gateway Error] Failed to save intercepted media to disk: ${formatErrorMessage(err)}`, { cause: err }); } savedMediaIds.push(savedMedia.id); const mediaRef = `media://inbound/${savedMedia.id}`; if (isImage) updatedMessage += `\n[media attached: ${mediaRef}]`; log?.info?.(shouldForceImageOffload && isImage ? `[Gateway] Offloaded image for text-only model. Saved: ${mediaRef}` : `[Gateway] Offloaded attachment (${finalMime}). Saved: ${mediaRef}`); offloadedRefs.push({ mediaRef, id: savedMedia.id, path: savedMedia.path, mimeType: finalMime, label, sizeBytes }); if (isImage) { imageOrder.push("offloaded"); if (shouldForceImageOffload) textOnlyImageOffloadCount++; } } } catch (err) { if (savedMediaIds.length > 0) await Promise.allSettled(savedMediaIds.map((id) => deleteMediaBuffer(id, "inbound"))); throw err; } return { message: updatedMessage !== message ? updatedMessage.trimEnd() : message, images, imageOrder, offloadedRefs }; } //#endregion //#region src/gateway/server-methods/attachment-normalize.ts function normalizeAttachmentContent(content) { if (typeof content === "string") return content; if (ArrayBuffer.isView(content)) return Buffer.from(content.buffer, content.byteOffset, content.byteLength).toString("base64"); if (content instanceof ArrayBuffer) return Buffer.from(content).toString("base64"); } /** Convert permissive RPC attachment payloads into the bounded chat attachment shape. */ function normalizeRpcAttachmentsToChatAttachments(attachments) { return attachments?.map((a) => { const sourceRecord = a?.source && typeof a.source === "object" ? a.source : void 0; const sourceType = typeof sourceRecord?.type === "string" ? sourceRecord.type : void 0; const sourceMimeType = typeof sourceRecord?.media_type === "string" ? sourceRecord.media_type : void 0; const sourceContent = sourceType === "base64" ? normalizeAttachmentContent(sourceRecord?.data) : void 0; return { type: typeof a?.type === "string" ? a.type : void 0, mimeType: typeof a?.mimeType === "string" ? a.mimeType : sourceMimeType, fileName: typeof a?.fileName === "string" ? a.fileName : void 0, content: normalizeAttachmentContent(a?.content) ?? sourceContent }; }).filter((a) => a.content) ?? []; } //#endregion export { resolveChatAttachmentMaxBytes as a, parseMessageWithAttachments as i, MediaOffloadError as n, UnsupportedAttachmentError as r, normalizeRpcAttachmentsToChatAttachments as t };