UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

462 lines (461 loc) 18.6 kB
import "./src-vebZIeLe.js"; import { t as expectDefined } from "./expect-CyE8FADM.js"; import { _ as parseCanonicalIpAddress, c as isIpv4Address, i as isCanonicalDottedDecimalIPv4, n as isBlockedSpecialUseIpv4Address, r as isBlockedSpecialUseIpv6Address, t as extractEmbeddedIpv4FromIpv6, u as isLegacyIpv4Literal, y as parseLooseIpAddress } from "./ip-BkT2Is0E.js"; import { t as hasHttpUrlPrefix } from "./url-protocol-OU3K-ySz.js"; import { t as parseInlineDirectives } from "./directive-tags-Wp1S72ap.js"; import { r as parseFenceSpans } from "./fences-DtD_oijY.js"; import { t as MarkdownItCallable } from "./markdown-it-BjEtoM4V.js"; //#region packages/markdown-core/src/image-spans.ts /** Finds inline image destinations without treating code or escaped syntax as media. */ function findMarkdownImageSpans(markdown) { const md = new MarkdownItCallable("commonmark"); md.inline.ruler.enableOnly(["image"]); const parseImage = expectDefined(md.inline.ruler.getRules("")[0], "Markdown image rule"); md.configure("commonmark"); md.normalizeLink = (url) => url; md.validateLink = () => true; const environment = {}; const blocks = md.parse(markdown, environment); const lineOffsets = [0]; for (const newline of markdown.matchAll(/\r\n?|\n/g)) lineOffsets.push(newline.index + newline[0].length); const sourceLines = markdown.replace(/\0/g, "�").split(/\r\n?|\n/); const images = []; let source = ""; let inlineLines = []; let inlineLine = 0; const sourceOffset = (position) => { for (let next = inlineLines[inlineLine + 1]; next && next.start <= position; next = inlineLines[inlineLine + 1]) inlineLine += 1; return position + expectDefined(inlineLines[inlineLine], "Markdown inline line").offset; }; md.inline.ruler.at("image", (state, silent) => { const start = state.pos; const matched = parseImage(state, silent); if (matched && !silent && state.src === source) { const token = expectDefined(state.tokens.at(-1), "Parsed Markdown image"); if (token.meta?.label) return matched; images.push({ start: sourceOffset(start), end: sourceOffset(state.pos), destination: String(expectDefined(token.attrGet("src"), "Markdown image destination")) }); } return matched; }); for (const block of blocks) { if (block.type !== "inline" || !block.map || !block.children?.some((token) => token.type === "image")) continue; source = block.content; const firstLine = block.map[0]; let start = 0; inlineLines = source.split("\n").map((content, index) => { const original = expectDefined(sourceLines[firstLine + index], "Markdown source line"); const text = content.trimStart(); const offset = expectDefined(lineOffsets[firstLine + index], "Markdown source offset") + original.indexOf(text) - (content.length - text.length) - start; const line = { start, offset }; start += content.length + 1; return line; }); inlineLine = 0; md.inline.parse(source, md, environment, []); } return images; } //#endregion //#region src/media/audio-tags.ts /** * Extract audio mode tag from text. * Supports [[audio_as_voice]] to send audio as voice message instead of file. * Default is file (preserves backward compatibility). */ function parseAudioTag(text) { const result = parseInlineDirectives(text, { stripReplyTags: false }); return { text: result.text, audioAsVoice: result.audioAsVoice, hadTag: result.hasAudioTag }; } //#endregion //#region src/media/parse.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 (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 mayContainFenceMarkers(input) { return input.includes("```") || input.includes("~~~"); } 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 collectMarkdownImageSegments(params) { const { matches } = params; if (matches.length === 0) return { lineSegments: [], foundMedia: false }; const segmentPieces = []; const visiblePieces = []; 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)) { const beforeText = cleanLineText(segmentPieces.join("")); if (beforeText) 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 = cleanLineText(segmentPieces.join("")); if (trailingText) lineSegments.push({ type: "text", text: trailingText }); return { cleanedLine: cleanLineText(visiblePieces.join("")) || void 0, lineSegments, foundMedia }; } /** Splits tool/stdout text into visible text, media attachments, voice tags, and ordered segments. */ function splitMediaFromOutput(raw, options = {}) { const trimmedRaw = raw.trimEnd(); if (!trimmedRaw.trim()) return { text: "" }; const markdownImageAllowlist = options.markdownImageAllowlist === void 0 ? void 0 : new Map(options.markdownImageAllowlist.map((source) => [normalizeMarkdownImageDestination(source), source])); const extractMarkdownImages = markdownImageAllowlist !== void 0 || options.extractMarkdownImages === true; 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 fenceSpans = mayContainFenceMarkers(trimmedRaw) ? parseFenceSpans(trimmedRaw) : []; const lines = trimmedRaw.split("\n"); const keptLines = []; const markdownImages = mayContainMarkdownImage && lines.some((line) => line.length <= MAX_MARKDOWN_IMAGE_LINE_LENGTH && line.includes("![")) ? findMarkdownImageSpans(trimmedRaw) : []; let markdownImageIndex = 0; let lineOffset = 0; let fenceIndex = 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 fence = fenceSpans[fenceIndex]; while (fence && lineOffset >= fence.end) { fenceIndex += 1; fence = fenceSpans[fenceIndex]; } if (fence && lineOffset >= fence.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 }) : { lineSegments: [], foundMedia: false }; if (!markdownImageResult.foundMedia) { keptLines.push(line); pushTextSegment(line); } else { foundMediaToken = true; if (markdownImageResult.cleanedLine) 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 = cleanCandidate(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 && !unwrapped && /\s/.test(payloadValue)) { const spacedFallback = cleanCandidate(payloadValue); if (isValidMedia(spacedFallback, { allowSpaces: true, allowBareFilename: true })) { media.splice(mediaStartIndex, media.length - mediaStartIndex, spacedFallback); hasValidMedia = true; foundMediaToken = true; invalidParts.length = 0; } } if (!hasValidMedia) { const fallback = 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 } : parseAudioTag(visibleText); const cleanedText = 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 { splitMediaFromOutput as n, isRelativeAssistantMediaReference as t };