openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
454 lines (453 loc) • 18.7 kB
JavaScript
import "./src-CdRlGRnW.mjs";
import { t as expectDefined } from "./expect-lbe3Hgrh.mjs";
import { b as parseLooseIpAddress, c as isIpv4Address, i as isCanonicalDottedDecimalIPv4, n as isBlockedSpecialUseIpv4Address, r as isBlockedSpecialUseIpv6Address, t as extractEmbeddedIpv4FromIpv6, u as isLegacyIpv4Literal, v as parseCanonicalIpAddress } from "./ip-jvtZlu_y.mjs";
import { t as hasHttpUrlPrefix } from "./url-protocol-OU3K-ySz.mjs";
import { r as findCodeRegions } from "./code-regions-CvUeKYli.mjs";
import { i as parseInlineDirectives } from "./directive-tags-CHfxUaYi.mjs";
import { r as parseInboundMediaUri } from "./inbound-media-uri-Bc4RWtcM.mjs";
//#region src/media/parse-output.ts
const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/i;
const RENDERABLE_ASSISTANT_MEDIA_PREFIX_RE = /^(?:https?:\/\/|data:(?:image|audio|video)\/|file:|~|\/|[a-z]:[\\/])/iu;
function isRelativeAssistantMediaReference(url) {
const trimmed = url.trim();
return Boolean(trimmed) && !RENDERABLE_ASSISTANT_MEDIA_PREFIX_RE.test(trimmed);
}
const FILE_URL_PREFIX_RE = /^file:(?:\/\/)?/i;
function normalizeMediaSource(src) {
return src.replace(FILE_URL_PREFIX_RE, "");
}
const TRAILING_SERIALIZED_JSON_AFTER_EXT_RE = /^(.*\.\w{1,10})\\?"(?=[\]},:]|$).*/s;
function cleanCandidate(raw) {
const stripped = raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
return TRAILING_SERIALIZED_JSON_AFTER_EXT_RE.exec(stripped)?.[1] ?? stripped;
}
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
const MEDIA_SOURCE_ROOT_RE = /^(?:[a-z]:[\\/]|[/~]|\.{1,2}[\\/]|\\\\)/i;
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
const HAS_FILE_EXT = /\.\w{1,10}$/;
const TRAVERSAL_SEGMENT_RE = /(?:^|[/\\])\.\.(?:[/\\]|$)/;
function isSupportedHomeRelativePath(candidate) {
return candidate.startsWith("~/") || candidate.startsWith("~\\");
}
function hasTraversalOrUnsupportedHomeDirPrefix(candidate) {
return candidate.startsWith("../") || candidate === ".." || candidate.startsWith("~") && !isSupportedHomeRelativePath(candidate) || TRAVERSAL_SEGMENT_RE.test(candidate);
}
function looksLikeLocalFilePath(candidate) {
return candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.startsWith("~") || WINDOWS_DRIVE_RE.test(candidate) || candidate.startsWith("\\\\") || !SCHEME_RE.test(candidate) && (candidate.includes("/") || candidate.includes("\\"));
}
function normalizeRemoteMediaHostname(value) {
const normalized = value.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
if (normalized.split(".").some((label) => label.length === 0)) return "";
return normalized;
}
function isBlockedRemoteMediaHostname(hostname) {
const normalized = normalizeRemoteMediaHostname(hostname);
if (!normalized) return true;
if (!normalized.includes(".")) return true;
if (normalized === "localhost" || normalized === "localhost.localdomain" || normalized === "metadata.google.internal" || normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal")) return true;
const strictIp = parseCanonicalIpAddress(normalized);
if (strictIp) {
if (isIpv4Address(strictIp)) return isBlockedSpecialUseIpv4Address(strictIp);
if (isBlockedSpecialUseIpv6Address(strictIp)) return true;
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp);
return embeddedIpv4 ? isBlockedSpecialUseIpv4Address(embeddedIpv4) : false;
}
if (normalized.includes(":") && !parseLooseIpAddress(normalized)) return true;
return !isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized);
}
function isAllowedRemoteMediaUrl(candidate) {
try {
const parsed = new URL(candidate);
return parsed.protocol === "https:" && !parsed.username && !parsed.password && !isBlockedRemoteMediaHostname(parsed.hostname);
} catch {
return false;
}
}
function isValidMedia(source, opts) {
const candidate = normalizeMediaSource(source);
if (!candidate) return false;
if (candidate.length > 4096) return false;
if (!opts?.allowSpaces && /\s/.test(candidate)) return false;
if (hasHttpUrlPrefix(candidate)) return isAllowedRemoteMediaUrl(candidate);
if (/^media:\/\//i.test(candidate)) try {
return parseInboundMediaUri(candidate) !== null;
} catch {
return false;
}
if (hasTraversalOrUnsupportedHomeDirPrefix(candidate)) return false;
if (looksLikeLocalFilePath(candidate)) return true;
if (opts?.allowBareFilename && !SCHEME_RE.test(candidate) && HAS_FILE_EXT.test(candidate)) return true;
return false;
}
function beginsIndependentMediaSource(raw) {
const candidate = normalizeMediaSource(cleanCandidate(raw));
return MEDIA_SOURCE_ROOT_RE.test(candidate) || SCHEME_RE.test(candidate);
}
const QUOTE_CHARS = /* @__PURE__ */ new Set([
"\"",
"'",
"`"
]);
const MEDIA_DIRECTIVE_SPACE_RE = /\s/;
function isQuotedMediaReferenceBoundary(payload, afterQuote) {
if (afterQuote >= payload.length) return true;
const char = payload.charAt(afterQuote);
if (MEDIA_DIRECTIVE_SPACE_RE.test(char)) return true;
if (char !== ",") return false;
let index = afterQuote + 1;
while (index < payload.length && MEDIA_DIRECTIVE_SPACE_RE.test(payload.charAt(index))) index += 1;
return index < payload.length && QUOTE_CHARS.has(payload.charAt(index));
}
function findQuotedMediaReferenceEnd(payload, start, quote) {
for (let index = start + 1; index < payload.length; index += 1) if (payload.charAt(index) === quote && isQuotedMediaReferenceBoundary(payload, index + 1)) return index;
return -1;
}
function stripSerializedJsonArrayWrapper(payload) {
const trimmed = payload.trim();
if (trimmed.length < 2 || trimmed.charAt(0) !== "[" || trimmed.charAt(trimmed.length - 1) !== "]") return payload;
return trimmed.slice(1, -1);
}
function readQuotedMediaReferenceList(rawPayload) {
const payload = stripSerializedJsonArrayWrapper(rawPayload);
const tokens = [];
let index = 0;
while (index < payload.length) {
const char = payload.charAt(index);
if (MEDIA_DIRECTIVE_SPACE_RE.test(char) || char === ",") {
index += 1;
continue;
}
if (QUOTE_CHARS.has(char)) {
const end = findQuotedMediaReferenceEnd(payload, index, char);
if (end !== -1) {
tokens.push(payload.slice(index, end + 1));
index = end + 1;
continue;
}
}
return null;
}
return tokens.length >= 2 ? tokens : null;
}
function splitMediaDirectiveParts(payload) {
const parts = [];
let previousEnd = 0;
for (const match of payload.matchAll(/\S+/g)) {
const candidate = normalizeMediaSource(cleanCandidate(match[0]));
const previous = parts.at(-1);
const previousCandidate = previous ? normalizeMediaSource(cleanCandidate(previous)) : "";
if (MEDIA_SOURCE_ROOT_RE.test(previousCandidate) && !beginsIndependentMediaSource(candidate) && (!HAS_FILE_EXT.test(previousCandidate) || !isValidMedia(candidate))) parts[parts.length - 1] = `${previous}${payload.slice(previousEnd, match.index)}${match[0]}`;
else parts.push(match[0]);
previousEnd = match.index + match[0].length;
}
return parts;
}
function unwrapQuoted(value) {
const trimmed = value.trim();
if (trimmed.length < 2) return;
const first = trimmed[0];
if (first !== trimmed[trimmed.length - 1]) return;
if (first !== `"` && first !== "'" && first !== "`") return;
return trimmed.slice(1, -1).trim();
}
function cleanLineText(text) {
return text.replace(/[ \t]{2,}/g, " ").trim();
}
const MAX_MARKDOWN_IMAGE_LINE_LENGTH = 2e4;
const MAX_MARKDOWN_IMAGE_MATCHES_PER_LINE = 50;
function removeMarkdownImageSpans(line, matches) {
const pieces = [];
let cursor = 0;
for (let index = 0; index < matches.length; index += 1) {
const match = expectDefined(matches[index], "Markdown image span");
let end = match.end;
let next = matches[index + 1];
let internalGap = "";
while (next) {
const gap = line.slice(end, next.start);
if (!/^[ \t]*$/.test(gap)) break;
internalGap ||= gap;
end = next.end;
index += 1;
next = matches[index + 1];
}
let start = match.start;
let left = start;
while (left > cursor && /[ \t]/.test(line.charAt(left - 1))) left -= 1;
let right = end;
while (right < line.length && /[ \t]/.test(line.charAt(right))) right += 1;
const hasTextBefore = left > 0 && line.charAt(left - 1) !== "\r";
const hasTextAfter = right < line.length && line.charAt(right) !== "\r";
let separator = "";
if (!hasTextBefore) {
if (hasTextAfter) end = right;
} else {
start = left;
if (hasTextAfter) {
separator = line.slice(end, right) || line.slice(left, match.start) || internalGap;
end = right;
}
}
pieces.push(line.slice(cursor, start), separator);
cursor = end;
}
pieces.push(line.slice(cursor));
return pieces.join("");
}
function collectMarkdownImageSegments(params) {
const { matches } = params;
if (matches.length === 0) return {
lineSegments: [],
foundMedia: false
};
const segmentPieces = [];
const visiblePieces = [];
const extractedImages = [];
const lineSegments = [];
let cursor = 0;
let foundMedia = false;
for (const match of matches) {
const before = params.line.slice(cursor, match.start);
segmentPieces.push(before);
visiblePieces.push(before);
const target = normalizeMediaSource(match.destination.trim());
const selectedTarget = params.allowlist?.get(target);
if (selectedTarget || !params.allowlist && hasHttpUrlPrefix(target) && isValidMedia(target)) {
extractedImages.push(match);
const beforeText = params.preserveTrailingWhitespace ? segmentPieces.join("") : cleanLineText(segmentPieces.join(""));
if (beforeText.trim()) lineSegments.push({
type: "text",
text: beforeText
});
segmentPieces.length = 0;
const mediaTarget = selectedTarget ?? target;
params.media.push(mediaTarget);
lineSegments.push({
type: "media",
url: mediaTarget
});
foundMedia = true;
} else {
const original = params.line.slice(match.start, match.end);
segmentPieces.push(original);
visiblePieces.push(original);
}
cursor = match.end;
}
const after = params.line.slice(cursor);
segmentPieces.push(after);
visiblePieces.push(after);
const trailingText = params.preserveTrailingWhitespace ? segmentPieces.join("") : cleanLineText(segmentPieces.join(""));
if (trailingText.trim()) lineSegments.push({
type: "text",
text: trailingText
});
const cleanedLine = params.preserveTrailingWhitespace ? removeMarkdownImageSpans(params.line, extractedImages) : cleanLineText(visiblePieces.join(""));
return {
cleanedLine: params.preserveTrailingWhitespace ? cleanedLine : cleanedLine || void 0,
lineSegments,
foundMedia
};
}
/** Splits tool/stdout text into visible text, media attachments, voice tags, and ordered segments. */
function splitMediaOutput(raw, options = {}, imageExtraction) {
const trimmedRaw = options.preserveTrailingWhitespace ? raw : raw.trimEnd();
if (!trimmedRaw.trim()) return { text: options.preserveTrailingWhitespace ? trimmedRaw : "" };
const markdownImageAllowlist = imageExtraction?.allowlist === void 0 ? void 0 : new Map(imageExtraction.allowlist.map((source) => [normalizeMediaSource(source.trim()), source]));
const extractMarkdownImages = imageExtraction !== void 0;
const extractMediaDirectives = options.extractMediaDirectives !== false;
const mayContainMediaToken = extractMediaDirectives && /media:/i.test(trimmedRaw);
const mayContainMarkdownImage = extractMarkdownImages && trimmedRaw.includes("![");
const mayContainAudioTag = trimmedRaw.includes("[[");
if (!mayContainMediaToken && !mayContainMarkdownImage && !mayContainAudioTag) return { text: trimmedRaw };
const media = [];
let rejectedMediaCount = 0;
let foundMediaToken = false;
const segments = [];
let lastTextSegment;
let lineSeparator = "";
let lastTextSeparator = "";
const pushTextSegment = (text) => {
const last = segments[segments.length - 1];
if (last?.type === "text") {
last.text = `${last.text}${lastTextSeparator}${text.trim() ? text : ""}`;
lastTextSeparator = lineSeparator;
} else if (!text.trim()) {
if (last?.type === "media" && lastTextSegment && !/[\r\n]$/.test(lastTextSegment.text)) lastTextSegment.text += lastTextSeparator;
} else {
lastTextSegment = {
type: "text",
text
};
lastTextSeparator = lineSeparator;
segments.push(lastTextSegment);
}
};
const codeBlocks = findCodeRegions(trimmedRaw).filter((region) => region.block);
const lines = trimmedRaw.split(/(\r\n|\r|\n)/);
const keptLines = [];
let keptSeparator = "";
const keepLine = (text) => {
keptLines.push(keptSeparator, text);
keptSeparator = lineSeparator;
};
const markdownImages = mayContainMarkdownImage && lines.some((line) => line.length <= MAX_MARKDOWN_IMAGE_LINE_LENGTH && line.includes("![")) ? imageExtraction.scan(trimmedRaw) : [];
let markdownImageIndex = 0;
let lineOffset = 0;
let codeBlockIndex = 0;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 2) {
const line = expectDefined(lines[lineIndex], "media output line");
lineSeparator = lines[lineIndex + 1] ?? "";
const lineEnd = lineOffset + line.length;
const lineImages = [];
for (; markdownImageIndex < markdownImages.length; markdownImageIndex += 1) {
const match = expectDefined(markdownImages[markdownImageIndex], "Markdown image span");
if (match.start >= lineEnd) break;
if (line.length <= MAX_MARKDOWN_IMAGE_LINE_LENGTH && lineImages.length < MAX_MARKDOWN_IMAGE_MATCHES_PER_LINE && match.start >= lineOffset && match.end <= lineEnd) lineImages.push({
...match,
start: match.start - lineOffset,
end: match.end - lineOffset
});
}
let codeBlock = codeBlocks[codeBlockIndex];
while (codeBlock && lineOffset >= codeBlock.end) {
codeBlockIndex += 1;
codeBlock = codeBlocks[codeBlockIndex];
}
if (codeBlock && lineEnd > codeBlock.start) {
keepLine(line);
pushTextSegment(line);
lineOffset += line.length + lineSeparator.length;
continue;
}
const linePrefix = line.trimStart().slice(0, 6);
if (!extractMediaDirectives || !linePrefix.toUpperCase().startsWith("MEDIA:")) {
const markdownImageResult = extractMarkdownImages ? collectMarkdownImageSegments({
line,
matches: lineImages,
media,
allowlist: markdownImageAllowlist,
preserveTrailingWhitespace: options.preserveTrailingWhitespace
}) : {
lineSegments: [],
foundMedia: false
};
if (!markdownImageResult.foundMedia) {
keepLine(line);
pushTextSegment(line);
} else {
foundMediaToken = true;
if (markdownImageResult.cleanedLine !== void 0) keepLine(markdownImageResult.cleanedLine);
for (const segment of markdownImageResult.lineSegments) {
if (segment.type === "text") {
pushTextSegment(segment.text);
continue;
}
segments.push(segment);
}
}
lineOffset += line.length + lineSeparator.length;
continue;
}
const match = MEDIA_TOKEN_RE.exec(line);
if (!match) {
keepLine(line);
pushTextSegment(line);
lineOffset += line.length + lineSeparator.length;
continue;
}
const payload = expectDefined(match[1], "parse regex capture 1");
const quotedList = readQuotedMediaReferenceList(payload);
const stripped = unwrapQuoted(payload);
const unwrapped = quotedList ? void 0 : stripped;
const payloadValue = unwrapped ?? payload;
const parts = quotedList ?? (unwrapped ? [unwrapped] : splitMediaDirectiveParts(payload));
const mediaStartIndex = media.length;
const rejectedBefore = rejectedMediaCount;
const invalidParts = [];
for (const part of parts) {
const quotedPart = unwrapped === void 0 ? unwrapQuoted(part) : void 0;
const candidate = unwrapped ?? quotedPart ?? cleanCandidate(part);
if (isValidMedia(candidate, {
allowSpaces: true,
allowBareFilename: quotedList !== null
})) media.push(candidate);
else if (beginsIndependentMediaSource(candidate) || looksLikeLocalFilePath(candidate)) {
rejectedMediaCount += 1;
foundMediaToken = true;
} else if (!/\s/.test(part) || !hasTraversalOrUnsupportedHomeDirPrefix(candidate)) invalidParts.push(part);
}
const trimmedPayload = (stripped ?? payload).trim();
const looksLikeLocalPath = looksLikeLocalFilePath(trimmedPayload) || FILE_URL_PREFIX_RE.test(trimmedPayload);
if (quotedList === null && !unwrapped && media.length - mediaStartIndex === 1 && invalidParts.length > 0 && !parts.slice(1).some(beginsIndependentMediaSource) && /\s/.test(payloadValue) && looksLikeLocalPath) {
const fallback = cleanCandidate(payloadValue);
if (isValidMedia(fallback, { allowSpaces: true })) {
media.splice(mediaStartIndex, media.length - mediaStartIndex, fallback);
invalidParts.length = 0;
}
}
if (quotedList === null && media.length === mediaStartIndex) {
const fallback = unwrapped ?? cleanCandidate(payloadValue);
if (isValidMedia(fallback, {
allowSpaces: true,
allowBareFilename: true
})) {
media.push(fallback);
invalidParts.length = 0;
}
}
let cleanedLine;
if (media.length > mediaStartIndex) {
foundMediaToken = true;
for (const url of media.slice(mediaStartIndex)) segments.push({
type: "media",
url
});
cleanedLine = cleanLineText(invalidParts.join(" "));
} else if (looksLikeLocalPath || rejectedMediaCount > rejectedBefore) {
foundMediaToken = true;
cleanedLine = "";
} else cleanedLine = cleanLineText(line);
if (cleanedLine) {
keepLine(cleanedLine);
pushTextSegment(cleanedLine);
}
lineOffset += line.length + lineSeparator.length;
}
const visibleText = keptLines.join("").replace(/^(?:[ \t]*(?:\r\n|\r|\n))+/, "");
const audioTagResult = options.extractAudioDirectives === false ? {
text: visibleText,
audioAsVoice: false
} : parseInlineDirectives(visibleText, {
stripReplyTags: false,
preserveTrailingWhitespace: options.preserveTrailingWhitespace,
onAudioDirective: options.onAudioDirective
});
const cleanedText = options.preserveTrailingWhitespace ? audioTagResult.text : audioTagResult.text.trimEnd();
const hasAudioAsVoice = audioTagResult.audioAsVoice;
if (media.length === 0) {
const parsedText = foundMediaToken || hasAudioAsVoice ? cleanedText : trimmedRaw;
const result = {
text: parsedText,
segments: parsedText ? [{
type: "text",
text: parsedText
}] : [],
...rejectedMediaCount > 0 ? { rejectedMediaCount } : {}
};
if (hasAudioAsVoice) result.audioAsVoice = true;
return result;
}
return {
text: cleanedText,
mediaUrls: media,
...rejectedMediaCount > 0 ? { rejectedMediaCount } : {},
segments: segments.length > 0 ? segments : [{
type: "text",
text: cleanedText
}],
...hasAudioAsVoice ? { audioAsVoice: true } : {}
};
}
//#endregion
export { splitMediaOutput as n, isRelativeAssistantMediaReference as t };