openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
301 lines (300 loc) • 13.3 kB
JavaScript
import "./src-vebZIeLe.js";
import { t as expectDefined } from "./expect-CyE8FADM.js";
import { r as formatUncaughtError, t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { r as MAX_IMAGE_BYTES } from "./constants-DUxuqQz8.js";
import { d as normalizeMimeType, l as kindFromMime, r as extensionForMime, u as mimeTypeFromFilePath } from "./mime-CVpcq9ju.js";
import { f as saveMediaBuffer, i as deleteMediaBuffer } from "./store-Ph57PZ9P.js";
import { n as estimateBase64DecodedBytes } from "./base64-Vw7DZYSc.js";
import { a as parseInboundMediaUri } from "./media-reference-ZG0ZXjCX.js";
import { t as probeMediaFilesWithinBudget } from "./media-probe-Dbs998-L.js";
import { t as sniffMimeFromBase64 } from "./sniff-mime-from-base64-DAna5uch.js";
import { t as formatForLog } from "./ws-log-Dkg9wKNU.js";
import "./chat-attachment-policy-F0KcWQR6.js";
//#region src/gateway/chat-attachments.ts
/** Deletes prepared inbound files that never reached a durable owner. */
async function discardPreparedInboundMedia(refs, log) {
const results = await Promise.allSettled(refs.map((ref) => deleteMediaBuffer(ref.id, "inbound")));
for (const [index, result] of results.entries()) if (result.status === "rejected" && log) log.warn(`failed to discard prepared inbound media ${refs[index]?.id}: ${formatErrorMessage(result.reason)}`);
}
const INLINE_IMAGE_DURABLE_OMISSION_MARKER = "[image attachment omitted: durable managed media claim unavailable]";
const OFFLOAD_THRESHOLD_BYTES = 2e6;
const TEXT_ONLY_OFFLOAD_LIMIT = 10;
const MAX_CHAT_ATTACHMENT_MEDIA_PROBES = 8;
const CHAT_ATTACHMENT_MEDIA_PROBE_CONCURRENCY = 2;
const CHAT_ATTACHMENT_MEDIA_PROBE_BUDGET_MS = 3e3;
async function enrichOffloadedMediaMetadata(refs) {
const candidates = refs.flatMap((ref) => {
const kind = kindFromMime(ref.mimeType);
return kind === "audio" || kind === "video" ? [{
kind,
ref
}] : [];
});
const metadata = await probeMediaFilesWithinBudget(candidates.map(({ kind, ref }) => ({
filePath: ref.path,
kind
})), {
budgetMs: CHAT_ATTACHMENT_MEDIA_PROBE_BUDGET_MS,
concurrency: CHAT_ATTACHMENT_MEDIA_PROBE_CONCURRENCY,
maxProbes: MAX_CHAT_ATTACHMENT_MEDIA_PROBES
});
for (const [index, candidate] of candidates.entries()) Object.assign(candidate.ref, metadata[index]);
}
function logAttachmentFailure(log, label, err) {
const primary = formatUncaughtError(err);
const cause = err instanceof Error ? err.cause : void 0;
const causeText = cause === void 0 ? "" : formatUncaughtError(cause);
log.error(label, {
error: !causeText || causeText === primary ? primary : `${primary}\nCaused by: ${causeText}`,
consoleMessage: `${label}: ${formatForLog(err)}`
});
}
function stripImageMediaMarkers(message, refs) {
return refs.reduce((projected, ref) => {
const marker = ref.mimeType.startsWith("image/") ? `\n[media attached: ${ref.mediaRef}]` : "";
const index = marker ? projected.lastIndexOf(marker) : -1;
return index < 0 ? projected : projected.slice(0, index) + projected.slice(index + marker.length);
}, message);
}
async function persistInboundImagesForTranscript(params) {
const entries = [];
let omission = "none";
for (const image of params.images) try {
const saved = await saveMediaBuffer(Buffer.from(image.data, "base64"), image.mimeType, "inbound");
const trusted = assertSavedMedia(saved, `inline image ${image.sourceIndex + 1}`);
entries.push({
id: trusted.id,
path: trusted.path,
sourceIndex: image.sourceIndex,
imageKind: "inline",
fact: {
url: trusted.mediaRef,
contentType: saved.contentType ?? image.mimeType,
kind: "image",
sizeBytes: saved.size
}
});
} catch (err) {
omission = "inline-image-save-failed";
params.log.warn(`${params.logContext}: failed to persist inbound image (${image.mimeType}): ${formatErrorMessage(err)}`);
}
for (const ref of params.offloadedRefs) {
const fact = {
url: buildManagedInboundMediaRef(ref.id),
contentType: ref.mimeType,
kind: ref.kind,
fileName: ref.label,
sizeBytes: ref.sizeBytes,
...ref.durationMs !== void 0 ? { durationMs: ref.durationMs } : {},
...ref.width !== void 0 ? { width: ref.width } : {},
...ref.height !== void 0 ? { height: ref.height } : {},
...ref.mimeType.startsWith("image/") ? {} : { hydrationSuppressed: true }
};
entries.push({
id: ref.id,
path: ref.path,
sourceIndex: ref.sourceIndex,
...ref.mimeType.startsWith("image/") ? { imageKind: "offloaded" } : {},
fact
});
}
entries.sort((left, right) => left.sourceIndex - right.sourceIndex);
return {
entries,
omission
};
}
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 isGenericContainerMime(mime) {
return mime === "application/zip" || mime === "application/octet-stream";
}
function isBase64DataCharCode(code) {
return code >= 65 && code <= 90 || code >= 97 && code <= 122 || code >= 48 && code <= 57 || code === 43 || code === 47;
}
function isValidAttachmentBase64(value) {
if (value.length === 0 || value.length % 4 !== 0) return false;
let padding = 0;
let sawPadding = false;
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code === 61) {
padding += 1;
if (padding > 2) return false;
sawPadding = true;
continue;
}
if (sawPadding || !isBase64DataCharCode(code)) return false;
}
return true;
}
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 buildManagedInboundMediaRef(id) {
const candidate = `media://inbound/${id}`;
const parsed = parseInboundMediaUri(candidate);
if (!parsed || parsed.id !== id) throw new Error("Saved media ID failed canonical validation");
return parsed.normalizedSource;
}
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;
const path = value.path;
if (typeof path !== "string" || path.length === 0) throw new Error(`attachment ${label}: saveMediaBuffer returned no on-disk path`);
return {
id,
mediaRef: buildManagedInboundMediaRef(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 = expectDefined(dataUrlMatch[1], "data url match capture group 1");
}
return {
label,
mime,
base64
};
}
async function parseMessageWithAttachments(message, attachments, opts) {
const maxBytes = opts?.maxBytes ?? 20971520;
const log = opts?.log;
const supportsInlineImages = opts?.supportsInlineImages !== false;
const acceptNonImage = opts?.acceptNonImage !== false;
const supportsImagesOption = opts?.supportsImages;
let resolvedSupportsImages = typeof supportsImagesOption === "boolean" ? supportsImagesOption : void 0;
const resolveSupportsImages = async () => {
if (resolvedSupportsImages !== void 0) return resolvedSupportsImages;
resolvedSupportsImages = typeof supportsImagesOption === "function" ? await supportsImagesOption() : true;
return resolvedSupportsImages;
};
if (!attachments || attachments.length === 0) return {
message,
images: [],
imageOrder: [],
media: [],
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 (!isValidAttachmentBase64(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 = normalizeMimeType(mime);
const mimeHints = [providedMime, mimeTypeFromFilePath(label)];
const finalMime = await sniffMimeFromBase64(b64, { additionalMimeHints: [...mimeHints.filter((hint) => !isGenericContainerMime(hint)), ...mimeHints] }) ?? "application/octet-stream";
if (providedMime && !isGenericContainerMime(providedMime) && finalMime !== providedMime) log?.warn(`attachment ${label}: mime mismatch (${providedMime} -> ${finalMime})`);
const isImage = finalMime.startsWith("image/");
const shouldForceImageOffload = isImage && !await resolveSupportsImages();
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,
sourceIndex: idx
});
imageOrder.push("inline");
continue;
}
const buffer = Buffer.from(b64, "base64");
verifyDecodedSize(buffer, sizeBytes, label);
let savedMedia;
try {
const labelWithExt = ensureExtension(label, finalMime);
savedMedia = assertSavedMedia(await saveMediaBuffer(buffer, finalMime, "inbound", maxBytes, labelWithExt), 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 = savedMedia.mediaRef;
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,
kind: kindFromMime(finalMime) ?? "unknown",
mimeType: finalMime,
label,
sizeBytes,
sourceIndex: idx,
...typeof att.durationMs === "number" && Number.isFinite(att.durationMs) && att.durationMs >= 0 ? { durationMs: att.durationMs } : {},
...typeof att.width === "number" && Number.isFinite(att.width) && att.width >= 0 ? { width: att.width } : {},
...typeof att.height === "number" && Number.isFinite(att.height) && att.height >= 0 ? { height: att.height } : {}
});
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;
}
await enrichOffloadedMediaMetadata(offloadedRefs);
return {
message: updatedMessage !== message ? updatedMessage.trimEnd() : message,
images,
imageOrder,
media: offloadedRefs.map((ref) => ({
path: ref.path,
url: ref.mediaRef,
contentType: ref.mimeType,
kind: ref.kind,
fileName: ref.label,
sizeBytes: ref.sizeBytes,
...ref.durationMs ? { durationMs: ref.durationMs } : {},
...ref.width ? { width: ref.width } : {},
...ref.height ? { height: ref.height } : {}
})),
offloadedRefs
};
}
//#endregion
export { isValidAttachmentBase64 as a, persistInboundImagesForTranscript as c, discardPreparedInboundMedia as i, stripImageMediaMarkers as l, MediaOffloadError as n, logAttachmentFailure as o, UnsupportedAttachmentError as r, parseMessageWithAttachments as s, INLINE_IMAGE_DURABLE_OMISSION_MARKER as t };