UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

426 lines (425 loc) • 17.4 kB
import "./src-SskUVPJG.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-ja-e24jU.mjs"; import { t as hasHttpUrlPrefix } from "./url-protocol-OU3K-ySz.mjs"; import { r as findCodeRegions } from "./code-regions-CjBR1D_6.mjs"; import { i as parseInlineDirectives } from "./directive-tags-BDwXb2d0.mjs"; import { r as parseInboundMediaUri } from "./inbound-media-uri-Bc4RWtcM.mjs"; //#region src/media/parse-output.ts /** Captures legacy MEDIA: attachment directives from model/tool output. */ const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi; 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 isLikelyLocalPath(candidate) { if (hasTraversalOrUnsupportedHomeDirPrefix(candidate)) return false; return candidate.startsWith("/") || candidate.startsWith("./") || isSupportedHomeRelativePath(candidate) || 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 (isLikelyLocalPath(candidate)) return true; if (hasTraversalOrUnsupportedHomeDirPrefix(candidate)) return false; 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); } function splitUnquotedMediaDirectiveParts(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 normalizeMarkdownImageDestination(destination) { return normalizeMediaSource(destination.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 isRemoteMarkdownImageMedia(candidate) { return hasHttpUrlPrefix(candidate) && isValidMedia(candidate); } 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 = normalizeMarkdownImageDestination(match.destination); const selectedTarget = params.allowlist?.get(target); if (selectedTarget || !params.allowlist && isRemoteMarkdownImageMedia(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) => [normalizeMarkdownImageDestination(source), 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 foundMediaToken = false; const segments = []; let lastTextSegment; const pushTextSegment = (text) => { const last = segments[segments.length - 1]; if (last?.type === "text") last.text = `${last.text}\n${text.trim() ? text : ""}`; else if (!text.trim()) { if (last?.type === "media" && lastTextSegment && !lastTextSegment.text.endsWith("\n")) lastTextSegment.text += "\n"; } else { lastTextSegment = { type: "text", text }; segments.push(lastTextSegment); } }; const codeBlocks = findCodeRegions(trimmedRaw).filter((region) => region.block); const lines = trimmedRaw.split("\n"); const keptLines = []; 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 (const line of lines) { 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) { keptLines.push(line); pushTextSegment(line); lineOffset += line.length + 1; 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) { keptLines.push(line); pushTextSegment(line); } else { foundMediaToken = true; if (markdownImageResult.cleanedLine !== void 0) keptLines.push(markdownImageResult.cleanedLine); for (const segment of markdownImageResult.lineSegments) { if (segment.type === "text") { pushTextSegment(segment.text); continue; } segments.push(segment); } } lineOffset += line.length + 1; continue; } const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE)); if (matches.length === 0) { keptLines.push(line); pushTextSegment(line); lineOffset += line.length + 1; continue; } const pieces = []; const lineSegments = []; let cursor = 0; for (const match of matches) { const start = match.index ?? 0; pieces.push(line.slice(cursor, start)); const payload = expectDefined(match[1], "parse regex capture 1"); const unwrapped = unwrapQuoted(payload); const payloadValue = unwrapped ?? payload; const parts = unwrapped ? [unwrapped] : splitUnquotedMediaDirectiveParts(payload); const mediaStartIndex = media.length; let validCount = 0; const invalidParts = []; let hasValidMedia = false; for (const part of parts) { const candidate = unwrapped === void 0 ? cleanCandidate(part) : part; if (isValidMedia(candidate, { allowSpaces: Boolean(unwrapped) || /\s/.test(candidate) })) { media.push(candidate); hasValidMedia = true; foundMediaToken = true; validCount += 1; } else if (!/\s/.test(part) || !hasTraversalOrUnsupportedHomeDirPrefix(candidate)) invalidParts.push(part); } const trimmedPayload = payloadValue.trim(); const looksLikeLocalPath = looksLikeLocalFilePath(trimmedPayload) || FILE_URL_PREFIX_RE.test(trimmedPayload); if (!unwrapped && validCount === 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); hasValidMedia = true; foundMediaToken = true; invalidParts.length = 0; } } if (!hasValidMedia) { const fallback = unwrapped ?? cleanCandidate(payloadValue); if (isValidMedia(fallback, { allowSpaces: true, allowBareFilename: true })) { media.push(fallback); hasValidMedia = true; foundMediaToken = true; invalidParts.length = 0; } } if (hasValidMedia) { const beforeText = cleanLineText(pieces.join("")); if (beforeText) lineSegments.push({ type: "text", text: beforeText }); pieces.length = 0; for (const url of media.slice(mediaStartIndex)) lineSegments.push({ type: "media", url }); if (invalidParts.length > 0) pieces.push(invalidParts.join(" ")); } else if (looksLikeLocalPath) foundMediaToken = true; else pieces.push(match[0]); cursor = start + match[0].length; } pieces.push(line.slice(cursor)); const cleanedLine = cleanLineText(pieces.join("")); if (cleanedLine) { keptLines.push(cleanedLine); lineSegments.push({ type: "text", text: cleanedLine }); } for (const segment of lineSegments) { if (segment.type === "text") { pushTextSegment(segment.text); continue; } segments.push(segment); } lineOffset += line.length + 1; } const visibleText = keptLines.join("\n").replace(/^(?:[ \t]*\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 }] : [] }; if (hasAudioAsVoice) result.audioAsVoice = true; return result; } return { text: cleanedText, mediaUrls: media, segments: segments.length > 0 ? segments : [{ type: "text", text: cleanedText }], ...hasAudioAsVoice ? { audioAsVoice: true } : {} }; } //#endregion export { splitMediaOutput as n, isRelativeAssistantMediaReference as t };