forked from bai/curriculum-project-hub
e21096c642
Materialize markdown image refs on agent finish: fetch/read bytes, upload im.v1.image, and render native card img elements so remote image URLs no longer trip Feishu content-security. Stream masks image URLs mid-run; card failure falls back to plain text plus standalone image messages. Docs: clarify im:resource covers outbound Agent image send.
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
/**
|
|
* 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<string, string>();
|
|
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<string> {
|
|
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<ImageCreateResponse> } } };
|
|
};
|
|
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<string | null> {
|
|
const client = rt.client as unknown as {
|
|
im: {
|
|
v1: {
|
|
message: {
|
|
create: (p: unknown) => Promise<MessageCreateResponse>;
|
|
reply: (p: unknown) => Promise<MessageCreateResponse>;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
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<Buffer | null> {
|
|
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<Buffer | null> {
|
|
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<Buffer | null> {
|
|
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;
|
|
}
|