/** * Resolve markdown image references in agent answers into Feishu-hosted * image_keys so cards can embed them without remote URLs (which trip Feishu * content-security controls). */ import { isIP } from "node:net"; import type { FeishuRuntime } from "./client.js"; import { withRetry } from "./client.js"; import { WorkspaceFileBoundaryError, readWorkspaceFileNoFollow, } from "../security/workspaceFiles.js"; export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024; export const DEFAULT_MAX_OUTBOUND_IMAGES = 10; const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g; const FENCED_CODE_RE = /```[\s\S]*?```/g; export type CardContentSegment = | { readonly type: "markdown"; readonly content: string } | { readonly type: "image"; readonly imgKey: string; readonly alt: string }; export interface MarkdownImageRef { readonly fullMatch: string; readonly alt: string; readonly src: string; readonly index: number; readonly length: number; } export interface OutboundImageContext { readonly rt: FeishuRuntime; readonly workspaceRoot?: string | undefined; readonly workspaceDir?: string | undefined; readonly maxImageBytes?: number | undefined; readonly maxImages?: number | undefined; readonly fetchImpl?: typeof fetch | undefined; } type ImageCreateResponse = { image_key?: string; data?: { image_key?: string }; } | null; type MessageCreateResponse = { message_id?: string; data?: { message_id?: string }; } | null; /** Streaming-safe view: drop markdown image URLs so partial cards do not hit Feishu URL checks. */ export function maskMarkdownImagesForStreaming(text: string): string { return rewriteMarkdownImagesOutsideCode(text, (ref) => { const alt = ref.alt.trim(); return alt === "" ? "\u3010\u56fe\u7247\u3011" : alt; }); } export function findMarkdownImagesOutsideCode(text: string): MarkdownImageRef[] { const blocked = blockedRanges(text); const refs: MarkdownImageRef[] = []; IMAGE_MARKDOWN_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = IMAGE_MARKDOWN_RE.exec(text)) !== null) { const index = match.index; if (blocked.some((range) => index >= range.start && index < range.end)) continue; const fullMatch = match[0]; const alt = match[1] ?? ""; const rawSrc = (match[2] ?? "").trim(); const src = stripUrlTitle(rawSrc); if (src === "") continue; refs.push({ fullMatch, alt, src, index, length: fullMatch.length }); } return refs; } /** * Upload reachable markdown images and split the answer into card segments * (markdown + Feishu img elements). Unresolved images become visible alt text. */ export async function materializeAnswerSegments( text: string, ctx: OutboundImageContext, ): Promise<{ segments: CardContentSegment[]; unresolved: string[] }> { const refs = findMarkdownImagesOutsideCode(text); if (refs.length === 0) { return { segments: text === "" ? [] : [{ type: "markdown", content: text }], unresolved: [], }; } const maxImages = ctx.maxImages ?? DEFAULT_MAX_OUTBOUND_IMAGES; const maxBytes = ctx.maxImageBytes ?? FEISHU_MAX_IMAGE_BYTES; const keyBySrc = new Map(); const unresolved: string[] = []; const uniqueSrcs: string[] = []; for (const ref of refs) { if (!uniqueSrcs.includes(ref.src)) uniqueSrcs.push(ref.src); } for (const src of uniqueSrcs.slice(0, maxImages)) { try { const bytes = await resolveOutboundImageBytes(src, ctx, maxBytes); if (bytes === null) { unresolved.push(src); continue; } const imageKey = await uploadMessageImage(ctx.rt, bytes); keyBySrc.set(src, imageKey); } catch (error) { ctx.rt.logger.warn( { src, err: error instanceof Error ? error.message : String(error) }, "outbound image materialize failed", ); unresolved.push(src); } } for (const src of uniqueSrcs.slice(maxImages)) { unresolved.push(src); } const segments: CardContentSegment[] = []; let cursor = 0; for (const ref of refs) { if (ref.index > cursor) { pushMarkdown(segments, text.slice(cursor, ref.index)); } const imageKey = keyBySrc.get(ref.src); if (imageKey !== undefined) { segments.push({ type: "image", imgKey: imageKey, alt: ref.alt.trim() === "" ? "\u56fe\u7247" : ref.alt.trim(), }); } else { const alt = ref.alt.trim(); pushMarkdown(segments, alt === "" ? "\u3010\u56fe\u7247\u3011" : alt); } cursor = ref.index + ref.length; } if (cursor < text.length) { pushMarkdown(segments, text.slice(cursor)); } return { segments, unresolved }; } export async function uploadMessageImage(rt: FeishuRuntime, image: Buffer): Promise { if (image.byteLength === 0) { throw new Error("image is empty"); } if (image.byteLength > FEISHU_MAX_IMAGE_BYTES) { throw new Error(`image exceeds Feishu limit of ${FEISHU_MAX_IMAGE_BYTES} bytes`); } const client = rt.client as unknown as { im: { v1: { image: { create: (p: unknown) => Promise } } }; }; const res = await withRetry(async () => client.im.v1.image.create({ data: { image_type: "message", image }, }), ); const imageKey = res?.image_key ?? res?.data?.image_key; if (imageKey === undefined || imageKey === "") { throw new Error("Feishu image upload response is missing image_key"); } return imageKey; } /** Send a standalone image message (fallback if card embed is unavailable). */ export async function sendImageMessage( rt: FeishuRuntime, chatId: string, imageKey: string, options?: { readonly replyToMessageId?: string | undefined }, ): Promise { const client = rt.client as unknown as { im: { v1: { message: { create: (p: unknown) => Promise; reply: (p: unknown) => Promise; }; }; }; }; const replyTo = options?.replyToMessageId; if (replyTo !== undefined && replyTo !== "") { const res = await client.im.v1.message.reply({ path: { message_id: replyTo }, data: { msg_type: "image", content: JSON.stringify({ image_key: imageKey }) }, }); return res?.data?.message_id ?? res?.message_id ?? null; } const res = await client.im.v1.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, msg_type: "image", content: JSON.stringify({ image_key: imageKey }), }, }); return res?.data?.message_id ?? res?.message_id ?? null; } export async function resolveOutboundImageBytes( src: string, ctx: OutboundImageContext, maxBytes: number, ): Promise { if (isRemoteUrl(src)) { return fetchRemoteImage(src, ctx.fetchImpl ?? fetch, maxBytes); } const root = ctx.workspaceRoot?.trim(); const dir = ctx.workspaceDir?.trim(); if (root === undefined || root === "" || dir === undefined || dir === "") { return null; } try { const file = await readWorkspaceFileNoFollow(root, dir, src, maxBytes); return file.data; } catch (error) { if (error instanceof WorkspaceFileBoundaryError && error.reason === "not_found") { return null; } throw error; } } function rewriteMarkdownImagesOutsideCode( text: string, replace: (ref: MarkdownImageRef) => string, ): string { const refs = findMarkdownImagesOutsideCode(text); if (refs.length === 0) return text; let out = ""; let cursor = 0; for (const ref of refs) { out += text.slice(cursor, ref.index); out += replace(ref); cursor = ref.index + ref.length; } out += text.slice(cursor); return out; } function pushMarkdown(segments: CardContentSegment[], content: string): void { if (content === "") return; const last = segments[segments.length - 1]; if (last !== undefined && last.type === "markdown") { segments[segments.length - 1] = { type: "markdown", content: last.content + content }; return; } segments.push({ type: "markdown", content }); } function blockedRanges(text: string): Array<{ start: number; end: number }> { const ranges: Array<{ start: number; end: number }> = []; FENCED_CODE_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = FENCED_CODE_RE.exec(text)) !== null) { ranges.push({ start: match.index, end: match.index + match[0].length }); } return ranges; } function stripUrlTitle(raw: string): string { const trimmed = raw.trim(); // Markdown optional title: url "title" or url 'title' const titled = /^(\S+)\s+(".*"|'.*')$/.exec(trimmed); return (titled?.[1] ?? trimmed).trim(); } function isRemoteUrl(src: string): boolean { try { const url = new URL(src); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } async function fetchRemoteImage( src: string, fetchImpl: typeof fetch, maxBytes: number, ): Promise { let url: URL; try { url = new URL(src); } catch { return null; } if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (!isPublicHttpHost(url.hostname)) return null; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 15_000); try { const response = await fetchImpl(url, { method: "GET", redirect: "manual", signal: controller.signal, headers: { accept: "image/*,*/*;q=0.8" }, }); // One safe redirect hop to another public http(s) host. if (response.status >= 300 && response.status < 400) { const location = response.headers.get("location"); if (location === null || location === "") return null; let redirected: URL; try { redirected = new URL(location, url); } catch { return null; } if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null; if (!isPublicHttpHost(redirected.hostname)) return null; const second = await fetchImpl(redirected, { method: "GET", redirect: "manual", signal: controller.signal, headers: { accept: "image/*,*/*;q=0.8" }, }); return readImageBody(second, maxBytes); } return readImageBody(response, maxBytes); } finally { clearTimeout(timer); } } async function readImageBody(response: Response, maxBytes: number): Promise { if (!response.ok) return null; const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); if ( contentType !== "" && !contentType.startsWith("image/") && !contentType.includes("octet-stream") && (contentType.startsWith("text/") || contentType.includes("json") || contentType.includes("html")) ) { return null; } const contentLength = Number(response.headers.get("content-length") ?? "NaN"); if (Number.isFinite(contentLength) && contentLength > maxBytes) return null; const buf = Buffer.from(await response.arrayBuffer()); if (buf.byteLength === 0 || buf.byteLength > maxBytes) return null; return buf; } function isPublicHttpHost(hostname: string): boolean { const host = hostname.trim().toLowerCase().replace(/\.$/, ""); if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) { return false; } if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "::1" || host === "[::1]") { return false; } const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; const ipVersion = isIP(unbracketed); if (ipVersion === 4) return !isPrivateIPv4(unbracketed); if (ipVersion === 6) return !isPrivateIPv6(unbracketed); return true; } function isPrivateIPv4(ip: string): boolean { const parts = ip.split(".").map((part) => Number(part)); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { return true; } const a = parts[0]!; const b = parts[1]!; if (a === 10 || a === 127 || a === 0) return true; if (a === 169 && b === 254) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT if (a >= 224) return true; // multicast / reserved return false; } function isPrivateIPv6(ip: string): boolean { const normalized = ip.toLowerCase(); if (normalized === "::1") return true; if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local if (normalized.startsWith("fe80:")) return true; // link-local const mapped = /^:ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized); if (mapped?.[1] !== undefined) return isPrivateIPv4(mapped[1]); return false; }