openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
309 lines (308 loc) • 13.7 kB
JavaScript
import { r as formatUncaughtError, t as formatErrorMessage } from "./errors-O33D01XW.mjs";
import { n as resolveRuntimeProcessEntrypointUrl } from "./runtime-process-url-D3H3_asn.mjs";
import { r as WorkerTaskError } from "./src-WCJCjrey.mjs";
import { t as WorkerTaskPool } from "./worker-task-pool-iI3pfrsK.mjs";
import { r as MAX_IMAGE_BYTES } from "./constants-GzjKtFvA.mjs";
import { d as normalizeMimeType, l as kindFromMime, r as extensionForMime } from "./mime-CGDYrRzL.mjs";
import { t as probeMediaFilesWithinBudget } from "./media-probe-COHtJCIk.mjs";
import { n as isGenericContainerMime, r as prepareAttachment } from "./attachment-processor.runtime.js";
import { r as parseInboundMediaUri } from "./inbound-media-uri-Bc4RWtcM.mjs";
import { f as saveMediaBuffer, i as deleteMediaBuffer } from "./store-CP-GWHOW.mjs";
import { n as registerMediaCleanupDrain } from "./server-media-cleanup-lifecycle-KlRv-aNy.mjs";
import { t as SessionMutationAuthorizationChangedError } from "./session-mutation-authorization-error-CCz8QGWh.mjs";
import "./chat-attachment-policy-BBDKPSK_.mjs";
import { t as formatForLog } from "./ws-log-Lc1qgHtV.mjs";
//#region src/media/attachment-processor.ts
const pool = new WorkerTaskPool({
workerUrl: resolveRuntimeProcessEntrypointUrl("attachmentProcessor"),
maxWorkers: 2,
sharedCompute: true
});
async function prepareMediaAttachment(input, maxBytes, signal) {
signal?.throwIfAborted();
const size = Math.floor(input.base64.length * 3 / 4) - (input.base64.endsWith("==") ? 2 : input.base64.endsWith("=") ? 1 : 0);
if (size > maxBytes) throw new Error(`attachment ${input.label}: exceeds size limit (${size} > ${maxBytes} bytes)`);
const result = input.base64.length < 262144 ? await prepareAttachment(input) : await pool.run(input, {
inputBytes: input.base64.length * 2,
signal,
timeoutMs: 18e4
});
signal?.throwIfAborted();
return result;
}
//#endregion
//#region src/gateway/chat-attachments.ts
/** Deletes prepared inbound files that never reached a durable owner. */
async function discardPreparedInboundMedia(refs, log) {
const deletion = Promise.allSettled(refs.map((ref) => deleteMediaBuffer(ref.id, "inbound")));
registerMediaCleanupDrain(deletion.then(() => void 0));
const results = await deletion;
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 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";
try {
params.assertCurrent?.();
for (const image of params.images) try {
params.assertCurrent?.();
const saved = await saveMediaBuffer(Buffer.from(image.data, "base64"), image.mimeType, "inbound", void 0, image.fileName, void 0, { assertCommitAllowed: params.assertCurrent });
entries.push({
id: saved.id,
path: saved.path,
sourceIndex: image.sourceIndex,
imageKind: "inline",
fact: {
url: buildManagedInboundMediaRef(saved.id),
contentType: saved.contentType ?? image.mimeType,
kind: "image",
...image.fileName ? { fileName: image.fileName } : {},
sizeBytes: saved.size
}
});
} catch (err) {
if (err instanceof SessionMutationAuthorizationChangedError) throw err;
params.assertCurrent?.();
omission = "inline-image-save-failed";
params.log.warn(`${params.logContext}: failed to persist inbound image (${image.mimeType}): ${formatErrorMessage(err)}`);
}
params.assertCurrent?.();
} catch (error) {
await discardPreparedInboundMedia(entries, params.log);
throw error;
}
for (const ref of params.offloadedRefs) {
const fact = {
url: buildManagedInboundMediaRef(ref.id),
contentType: ref.mimeType,
kind: ref.kind,
fileName: ref.label,
...ref.origin ? { origin: ref.origin } : {},
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 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 normalizeAttachment(att, idx) {
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`);
let base64 = content.trim();
const commaIndex = base64.startsWith("data:") ? base64.indexOf(",") : -1;
if (commaIndex >= 0 && /^data:[^;,]+;base64$/.test(base64.slice(0, commaIndex))) base64 = base64.slice(commaIndex + 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 {
opts?.assertCurrent?.();
for (const [idx, att] of attachments.entries()) {
if (!att) continue;
const { base64: b64, label, mime } = normalizeAttachment(att, idx);
if (b64.length === 0) throw new UnsupportedAttachmentError("empty-payload", `attachment ${label}: empty payload`);
const prepared = await prepareMediaAttachment({
base64: b64,
label,
mime,
imageStorage: opts?.imageStorage
}, maxBytes, opts?.signal);
const { sizeBytes, mime: finalMime } = prepared;
const providedMime = normalizeMimeType(mime);
if (providedMime && !isGenericContainerMime(providedMime) && finalMime !== providedMime) log?.warn(`attachment ${label}: mime mismatch (${providedMime} -> ${finalMime})`);
const isImage = finalMime.startsWith("image/");
const shouldForceImageOffload = isImage && !await resolveSupportsImages();
opts?.signal?.throwIfAborted();
opts?.assertCurrent?.();
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 && 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 || opts?.imageStorage !== "inline" && sizeBytes > 2e6)) {
images.push({
type: "image",
data: b64,
mimeType: finalMime,
...att.fileName ? { fileName: att.fileName } : {},
sourceIndex: idx
});
imageOrder.push("inline");
continue;
}
opts?.assertCurrent?.();
const bytes = prepared.buffer;
const buffer = bytes ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Buffer.from(b64, "base64");
let savedMedia;
let mediaRef;
try {
const labelWithExt = ensureExtension(label, finalMime);
savedMedia = await saveMediaBuffer(buffer, finalMime, "inbound", maxBytes, labelWithExt, void 0, { assertCommitAllowed: opts?.assertCurrent });
mediaRef = buildManagedInboundMediaRef(savedMedia.id);
} catch (err) {
if (err instanceof SessionMutationAuthorizationChangedError) throw err;
opts?.assertCurrent?.();
throw new MediaOffloadError(`[Gateway Error] Failed to save intercepted media to disk: ${formatErrorMessage(err)}`, { cause: err });
}
savedMediaIds.push(savedMedia.id);
updatedMessage += `\n[media attached: ${mediaRef}]`;
log?.info?.(shouldForceImageOffload ? `[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,
...att.origin === "paste" || att.origin === "file" ? { origin: att.origin } : {},
...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++;
}
}
await enrichOffloadedMediaMetadata(offloadedRefs);
opts?.assertCurrent?.();
} catch (err) {
if (savedMediaIds.length > 0) await Promise.allSettled(savedMediaIds.map((id) => deleteMediaBuffer(id, "inbound")));
if (err instanceof WorkerTaskError && err !== opts?.signal?.reason) {
if (err.code === "failed") throw new Error(err.message, { cause: err });
throw new MediaOffloadError(`[Gateway Error] Failed to prepare attachments: ${formatErrorMessage(err)}`, { cause: err });
}
throw err;
}
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,
...ref.origin ? { origin: ref.origin } : {},
sizeBytes: ref.sizeBytes,
...ref.durationMs ? { durationMs: ref.durationMs } : {},
...ref.width ? { width: ref.width } : {},
...ref.height ? { height: ref.height } : {}
})),
offloadedRefs
};
}
//#endregion
export { logAttachmentFailure as a, stripImageMediaMarkers as c, discardPreparedInboundMedia as i, MediaOffloadError as n, parseMessageWithAttachments as o, UnsupportedAttachmentError as r, persistInboundImagesForTranscript as s, INLINE_IMAGE_DURABLE_OMISSION_MARKER as t };