forked from EduCraft/curriculum-project-hub
feat(hub): embed agent images via Feishu upload + release v0.0.36
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.
This commit is contained in:
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
|
|||||||
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
|
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
|
||||||
| 以应用身份发送消息 | `im:message:send_as_bot` |
|
| 以应用身份发送消息 | `im:message:send_as_bot` |
|
||||||
| 读取触发消息和线程上下文 | `im:message:readonly` |
|
| 读取触发消息和线程上下文 | `im:message:readonly` |
|
||||||
| 获取与上传图片或文件 | `im:resource` |
|
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
|
||||||
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
|
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
|
||||||
| 获取用户基本信息 | `contact:user.base:readonly` |
|
| 获取用户基本信息 | `contact:user.base:readonly` |
|
||||||
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
|
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
|
||||||
@@ -66,6 +66,9 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
|
|||||||
|
|
||||||
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
|
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
|
||||||
|
|
||||||
|
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
|
||||||
|
|
||||||
|
|
||||||
## 4. 配置事件与卡片回调
|
## 4. 配置事件与卡片回调
|
||||||
|
|
||||||
进入“事件与回调”。
|
进入“事件与回调”。
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.35",
|
"version": "0.0.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.35",
|
"version": "0.0.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alicloud/credentials": "^2.4.5",
|
"@alicloud/credentials": "^2.4.5",
|
||||||
"@alicloud/docmind-api20220711": "^1.4.15",
|
"@alicloud/docmind-api20220711": "^1.4.15",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.35",
|
"version": "0.0.36",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
import type { ToolUseTraceStep } from "./trace-store.js";
|
||||||
|
import type { CardContentSegment } from "../outboundImages.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -58,6 +59,7 @@ function toolIcon(toolName: string): string {
|
|||||||
export function buildAgentCard(params: {
|
export function buildAgentCard(params: {
|
||||||
phase: CardPhase;
|
phase: CardPhase;
|
||||||
text: string;
|
text: string;
|
||||||
|
contentSegments?: readonly CardContentSegment[] | undefined;
|
||||||
reasoningText: string | undefined;
|
reasoningText: string | undefined;
|
||||||
toolUseSteps: ToolUseTraceStep[];
|
toolUseSteps: ToolUseTraceStep[];
|
||||||
toolUseElapsedMs: number | undefined;
|
toolUseElapsedMs: number | undefined;
|
||||||
@@ -65,19 +67,19 @@ export function buildAgentCard(params: {
|
|||||||
interrupted: boolean | undefined;
|
interrupted: boolean | undefined;
|
||||||
runId: string | undefined;
|
runId: string | undefined;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||||
const elements: unknown[] = [];
|
const elements: unknown[] = [];
|
||||||
|
|
||||||
// Tool-use panel (always present if there are steps)
|
// Tool-use panel (always present if there are steps)
|
||||||
if (toolUseSteps.length > 0) {
|
if (toolUseSteps.length > 0) {
|
||||||
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
||||||
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
|
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
||||||
elements.push(buildPendingToolUsePanel());
|
elements.push(buildPendingToolUsePanel());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reasoning panel
|
// Reasoning panel
|
||||||
if (reasoningText !== undefined && reasoningText !== "") {
|
if (reasoningText !== undefined && reasoningText !== "") {
|
||||||
if (phase === "streaming" && text === "") {
|
if (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0)) {
|
||||||
// Still thinking: show reasoning inline
|
// Still thinking: show reasoning inline
|
||||||
elements.push({
|
elements.push({
|
||||||
tag: "markdown",
|
tag: "markdown",
|
||||||
@@ -90,12 +92,11 @@ export function buildAgentCard(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main text content
|
// Main answer: either materialized segments (markdown + Feishu-hosted images)
|
||||||
if (text !== "") {
|
// or a single markdown block.
|
||||||
elements.push({
|
const answerElements = buildAnswerElements(text, contentSegments);
|
||||||
tag: "markdown",
|
if (answerElements.length > 0) {
|
||||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
elements.push(...answerElements);
|
||||||
});
|
|
||||||
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
|
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
|
||||||
elements.push({
|
elements.push({
|
||||||
tag: "markdown",
|
tag: "markdown",
|
||||||
@@ -401,6 +402,43 @@ function escapeMarkdown(value: string): string {
|
|||||||
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
|
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAnswerElements(
|
||||||
|
text: string,
|
||||||
|
contentSegments: readonly CardContentSegment[] | undefined,
|
||||||
|
): unknown[] {
|
||||||
|
if (contentSegments !== undefined && contentSegments.length > 0) {
|
||||||
|
const elements: unknown[] = [];
|
||||||
|
let remaining = MAX_TEXT_LENGTH;
|
||||||
|
for (const segment of contentSegments) {
|
||||||
|
if (segment.type === "image") {
|
||||||
|
elements.push({
|
||||||
|
tag: "img",
|
||||||
|
img_key: segment.imgKey,
|
||||||
|
alt: { tag: "plain_text", content: segment.alt },
|
||||||
|
mode: "fit_horizontal",
|
||||||
|
preview: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segment.content === "" || remaining <= 0) continue;
|
||||||
|
const slice = segment.content.length <= remaining
|
||||||
|
? segment.content
|
||||||
|
: truncateText(segment.content, remaining);
|
||||||
|
remaining -= slice.length;
|
||||||
|
elements.push({
|
||||||
|
tag: "markdown",
|
||||||
|
content: slice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
if (text === "") return [];
|
||||||
|
return [{
|
||||||
|
tag: "markdown",
|
||||||
|
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
function truncateText(value: string, maxLength: number): string {
|
function truncateText(value: string, maxLength: number): string {
|
||||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,13 @@
|
|||||||
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
|
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
|
||||||
* 5. finish(finalText) — flush + transition to complete card
|
* 5. finish(finalText) — flush + transition to complete card
|
||||||
* 6. fail(errorText) — flush + transition to error card
|
* 6. fail(errorText) — flush + transition to error card
|
||||||
|
*
|
||||||
|
* On finish, markdown image references (``) are downloaded /
|
||||||
|
* read, uploaded to Feishu as message images, and embedded as native card
|
||||||
|
* `img` elements so external URLs never hit Feishu content-security checks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
|
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
|
||||||
import { sendCard, patchCard, sendText } from "../client.js";
|
import { sendCard, patchCard, sendText, sendLongText } from "../client.js";
|
||||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
||||||
import {
|
import {
|
||||||
startToolUseTraceRun,
|
startToolUseTraceRun,
|
||||||
@@ -31,6 +34,12 @@ import {
|
|||||||
getToolUseTraceSteps,
|
getToolUseTraceSteps,
|
||||||
} from "./trace-store.js";
|
} from "./trace-store.js";
|
||||||
import { buildAgentCard, type CardPhase } from "./builder.js";
|
import { buildAgentCard, type CardPhase } from "./builder.js";
|
||||||
|
import {
|
||||||
|
type CardContentSegment,
|
||||||
|
maskMarkdownImagesForStreaming,
|
||||||
|
materializeAnswerSegments,
|
||||||
|
sendImageMessage,
|
||||||
|
} from "../outboundImages.js";
|
||||||
|
|
||||||
export interface StreamingCardSink {
|
export interface StreamingCardSink {
|
||||||
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
||||||
@@ -44,6 +53,11 @@ export interface StreamingCardOptions {
|
|||||||
readonly sendOptions?: SendMessageOptions | undefined;
|
readonly sendOptions?: SendMessageOptions | undefined;
|
||||||
readonly patchIntervalMs: number | undefined;
|
readonly patchIntervalMs: number | undefined;
|
||||||
readonly maxMessageLength: number | undefined;
|
readonly maxMessageLength: number | undefined;
|
||||||
|
/** Project workspace root; required to resolve local image paths. */
|
||||||
|
readonly workspaceRoot?: string | undefined;
|
||||||
|
/** Project workspace directory; required to resolve local image paths. */
|
||||||
|
readonly workspaceDir?: string | undefined;
|
||||||
|
readonly maxImageBytes?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
||||||
@@ -65,6 +79,9 @@ export class StreamingAgentCard {
|
|||||||
private readonly sendOptions: SendMessageOptions | undefined;
|
private readonly sendOptions: SendMessageOptions | undefined;
|
||||||
private readonly patchIntervalMs: number;
|
private readonly patchIntervalMs: number;
|
||||||
private readonly maxMessageLength: number;
|
private readonly maxMessageLength: number;
|
||||||
|
private readonly workspaceRoot: string | undefined;
|
||||||
|
private readonly workspaceDir: string | undefined;
|
||||||
|
private readonly maxImageBytes: number | undefined;
|
||||||
|
|
||||||
constructor(options: StreamingCardOptions) {
|
constructor(options: StreamingCardOptions) {
|
||||||
this.runId = options.runId;
|
this.runId = options.runId;
|
||||||
@@ -73,6 +90,9 @@ export class StreamingAgentCard {
|
|||||||
this.sendOptions = options.sendOptions;
|
this.sendOptions = options.sendOptions;
|
||||||
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
||||||
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
||||||
|
this.workspaceRoot = options.workspaceRoot;
|
||||||
|
this.workspaceDir = options.workspaceDir;
|
||||||
|
this.maxImageBytes = options.maxImageBytes;
|
||||||
startToolUseTraceRun(this.runId);
|
startToolUseTraceRun(this.runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,23 +126,43 @@ export class StreamingAgentCard {
|
|||||||
recordToolUseEnd({ runId: this.runId, ...params });
|
recordToolUseEnd({ runId: this.runId, ...params });
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
}
|
}
|
||||||
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
|
|
||||||
|
async finish(
|
||||||
|
fallbackText: string,
|
||||||
|
options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {},
|
||||||
|
): Promise<void> {
|
||||||
await this.flushChain;
|
await this.flushChain;
|
||||||
this.interrupted = options.interrupted === true;
|
this.interrupted = options.interrupted === true;
|
||||||
const footerText = options.footerText ?? "";
|
const footerText = options.footerText ?? "";
|
||||||
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
||||||
try {
|
try {
|
||||||
|
let answerText =
|
||||||
|
this.text.length > 0 ? appendFooter(this.text, footerText) : fallbackWithFooter;
|
||||||
|
this.text = answerText;
|
||||||
|
|
||||||
|
const { segments, unresolved } = await materializeAnswerSegments(answerText, {
|
||||||
|
rt: this.rt,
|
||||||
|
workspaceRoot: this.workspaceRoot,
|
||||||
|
workspaceDir: this.workspaceDir,
|
||||||
|
maxImageBytes: this.maxImageBytes,
|
||||||
|
});
|
||||||
|
if (unresolved.length > 0) {
|
||||||
|
this.rt.logger.warn(
|
||||||
|
{ runId: this.runId, unresolvedCount: unresolved.length, unresolved: unresolved.slice(0, 5) },
|
||||||
|
"some answer images could not be uploaded to Feishu",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let updated = true;
|
let updated = true;
|
||||||
if (this.text.length > 0) {
|
if (answerText.length > 0 || segments.length > 0) {
|
||||||
this.text = appendFooter(this.text, footerText);
|
updated = await this.flushCard("complete", answerText, false, segments);
|
||||||
updated = await this.flushCard("complete", this.text);
|
|
||||||
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
|
|
||||||
// No streaming text was sent. If we never created a card, send one now.
|
|
||||||
this.text = fallbackWithFooter;
|
|
||||||
updated = await this.flushCard("complete", this.text);
|
|
||||||
} else if (this.currentMessageId !== null) {
|
} else if (this.currentMessageId !== null) {
|
||||||
// Patch the existing card with the final text.
|
updated = await this.flushCard("complete", "", false, []);
|
||||||
updated = await this.flushCard("complete", fallbackWithFooter);
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
|
||||||
|
updated = await this.deliverPlainFallback(segments, answerText);
|
||||||
}
|
}
|
||||||
if (!updated && this.interrupted) {
|
if (!updated && this.interrupted) {
|
||||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||||
@@ -166,15 +206,30 @@ export class StreamingAgentCard {
|
|||||||
return this.flushCard(this.currentPhase(), this.text);
|
return this.flushCard(this.currentPhase(), this.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
|
private async flushCard(
|
||||||
const chunks = splitAtBoundary(text, this.maxMessageLength);
|
phase: CardPhase,
|
||||||
const firstChunk = chunks[0];
|
text: string,
|
||||||
if (firstChunk === undefined) return true;
|
isError = false,
|
||||||
|
contentSegments?: readonly CardContentSegment[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
// During live streaming, strip image URLs so Feishu never fetches remote
|
||||||
|
// ranks mid-run. Materialized segments are only used on the complete pass.
|
||||||
|
const displayText =
|
||||||
|
phase === "complete" && contentSegments !== undefined
|
||||||
|
? text
|
||||||
|
: maskMarkdownImagesForStreaming(text);
|
||||||
|
|
||||||
|
const chunks = splitAtBoundary(displayText, this.maxMessageLength);
|
||||||
|
const firstChunk = chunks[0] ?? "";
|
||||||
|
// When we have segments (complete+images), keep first-card complete content
|
||||||
|
// on segments only; overflow text (rare) falls back to plain chunked cards.
|
||||||
const toolUseSteps = getToolUseTraceSteps(this.runId);
|
const toolUseSteps = getToolUseTraceSteps(this.runId);
|
||||||
const card = buildAgentCard({
|
const card = buildAgentCard({
|
||||||
phase,
|
phase,
|
||||||
text: firstChunk,
|
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk,
|
||||||
|
contentSegments: contentSegments !== undefined && contentSegments.length > 0
|
||||||
|
? contentSegments
|
||||||
|
: undefined,
|
||||||
reasoningText: this.reasoningText || undefined,
|
reasoningText: this.reasoningText || undefined,
|
||||||
toolUseSteps,
|
toolUseSteps,
|
||||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||||
@@ -186,43 +241,89 @@ export class StreamingAgentCard {
|
|||||||
if (this.currentMessageId === null) {
|
if (this.currentMessageId === null) {
|
||||||
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
|
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
|
||||||
let updated = this.currentMessageId !== null;
|
let updated = this.currentMessageId !== null;
|
||||||
// Send overflow chunks as new messages (rare for agent output)
|
// Send overflow chunks as new messages (rare for agent output). Segments
|
||||||
for (const chunk of chunks.slice(1)) {
|
// already include the whole answer; only plain text overflows.
|
||||||
const overflowCard = buildAgentCard({
|
if (contentSegments === undefined || contentSegments.length === 0) {
|
||||||
phase,
|
for (const chunk of chunks.slice(1)) {
|
||||||
text: chunk,
|
const overflowCard = buildAgentCard({
|
||||||
reasoningText: undefined,
|
phase,
|
||||||
toolUseSteps: [],
|
text: chunk,
|
||||||
toolUseElapsedMs: undefined,
|
reasoningText: undefined,
|
||||||
isError,
|
toolUseSteps: [],
|
||||||
interrupted: this.interrupted,
|
toolUseElapsedMs: undefined,
|
||||||
runId: undefined,
|
isError,
|
||||||
});
|
interrupted: this.interrupted,
|
||||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
runId: undefined,
|
||||||
updated = updated && overflowMessageId !== null;
|
});
|
||||||
this.currentMessageId = overflowMessageId;
|
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||||
}
|
updated = updated && overflowMessageId !== null;
|
||||||
return updated;
|
this.currentMessageId = overflowMessageId;
|
||||||
} else {
|
}
|
||||||
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
|
||||||
// For overflow, create new messages
|
|
||||||
for (const chunk of chunks.slice(1)) {
|
|
||||||
const overflowCard = buildAgentCard({
|
|
||||||
phase,
|
|
||||||
text: chunk,
|
|
||||||
reasoningText: undefined,
|
|
||||||
toolUseSteps: [],
|
|
||||||
toolUseElapsedMs: undefined,
|
|
||||||
isError,
|
|
||||||
interrupted: this.interrupted,
|
|
||||||
runId: undefined,
|
|
||||||
});
|
|
||||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
|
||||||
updated = updated && overflowMessageId !== null;
|
|
||||||
this.currentMessageId = overflowMessageId;
|
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
||||||
|
if (contentSegments === undefined || contentSegments.length === 0) {
|
||||||
|
for (const chunk of chunks.slice(1)) {
|
||||||
|
const overflowCard = buildAgentCard({
|
||||||
|
phase,
|
||||||
|
text: chunk,
|
||||||
|
reasoningText: undefined,
|
||||||
|
toolUseSteps: [],
|
||||||
|
toolUseElapsedMs: undefined,
|
||||||
|
isError,
|
||||||
|
interrupted: this.interrupted,
|
||||||
|
runId: undefined,
|
||||||
|
});
|
||||||
|
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||||
|
updated = updated && overflowMessageId !== null;
|
||||||
|
this.currentMessageId = overflowMessageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverPlainFallback(
|
||||||
|
segments: readonly CardContentSegment[],
|
||||||
|
answerText: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const textParts: string[] = [];
|
||||||
|
const imageKeys: string[] = [];
|
||||||
|
if (segments.length > 0) {
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.type === "markdown") {
|
||||||
|
if (segment.content.trim() !== "") textParts.push(segment.content);
|
||||||
|
} else {
|
||||||
|
imageKeys.push(segment.imgKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (answerText.trim() !== "") {
|
||||||
|
textParts.push(maskMarkdownImagesForStreaming(answerText));
|
||||||
|
}
|
||||||
|
|
||||||
|
let any = false;
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
const messageId = await sendLongText(
|
||||||
|
this.rt,
|
||||||
|
this.chatId,
|
||||||
|
textParts.join("\n\n"),
|
||||||
|
this.sendOptions,
|
||||||
|
);
|
||||||
|
any = messageId !== null;
|
||||||
|
}
|
||||||
|
for (const imageKey of imageKeys) {
|
||||||
|
try {
|
||||||
|
const messageId = await sendImageMessage(this.rt, this.chatId, imageKey, this.sendOptions);
|
||||||
|
any = any || messageId !== null;
|
||||||
|
} catch (error) {
|
||||||
|
this.rt.logger.warn(
|
||||||
|
{ runId: this.runId, err: error instanceof Error ? error.message : String(error) },
|
||||||
|
"standalone image fallback failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any;
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentPhase(): CardPhase {
|
private currentPhase(): CardPhase {
|
||||||
@@ -235,5 +336,5 @@ export class StreamingAgentCard {
|
|||||||
function appendFooter(text: string, footerText: string): string {
|
function appendFooter(text: string, footerText: string): string {
|
||||||
if (footerText === "") return text;
|
if (footerText === "") return text;
|
||||||
if (text === "") return footerText;
|
if (text === "") return footerText;
|
||||||
return `${text.trimEnd()}\n\n${footerText}`;
|
return `${text}\n\n${footerText}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,7 +316,8 @@ export async function sendCard(
|
|||||||
{ msgType: "interactive", content: JSON.stringify(card) },
|
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
rt.logger.warn({ chatId, err: errorText(e) }, "sendCard failed");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +335,7 @@ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Reco
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
|
rt.logger.warn({ messageId, err: errorText(e) }, "patchCard failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -496,6 +496,9 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
sendOptions,
|
sendOptions,
|
||||||
patchIntervalMs: undefined,
|
patchIntervalMs: undefined,
|
||||||
maxMessageLength: undefined,
|
maxMessageLength: undefined,
|
||||||
|
workspaceRoot: projectWorkspaceRoot,
|
||||||
|
workspaceDir: project.workspaceDir,
|
||||||
|
maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
|
||||||
});
|
});
|
||||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||||
rt,
|
rt,
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||||
|
import {
|
||||||
|
findMarkdownImagesOutsideCode,
|
||||||
|
maskMarkdownImagesForStreaming,
|
||||||
|
materializeAnswerSegments,
|
||||||
|
uploadMessageImage,
|
||||||
|
} from "../../src/feishu/outboundImages.js";
|
||||||
|
import { buildAgentCard } from "../../src/feishu/card/builder.js";
|
||||||
|
|
||||||
|
const temps: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outbound markdown image parsing", () => {
|
||||||
|
it("finds image refs outside fenced code blocks", () => {
|
||||||
|
const text = [
|
||||||
|
"See diagram:",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"```md",
|
||||||
|
"",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const refs = findMarkdownImagesOutsideCode(text);
|
||||||
|
expect(refs.map((ref) => ref.src)).toEqual([
|
||||||
|
"https://cdn.example.com/a.png",
|
||||||
|
"assets/x.png",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks image urls for streaming cards", () => {
|
||||||
|
expect(maskMarkdownImagesForStreaming("before  after")).toBe(
|
||||||
|
"before alt text after",
|
||||||
|
);
|
||||||
|
expect(maskMarkdownImagesForStreaming("")).toBe("【图片】");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("materializeAnswerSegments", () => {
|
||||||
|
it("uploads remote and workspace images and builds card segments", async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "cph-img-root-"));
|
||||||
|
temps.push(workspaceRoot);
|
||||||
|
const workspaceDir = join(workspaceRoot, "project");
|
||||||
|
await mkdir(join(workspaceDir, "assets"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(workspaceDir, "assets", "local.png"),
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageCreate = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ image_key: "img_remote_1" })
|
||||||
|
.mockResolvedValueOnce({ image_key: "img_local_1" });
|
||||||
|
const rt = mockRuntime({ imageCreate });
|
||||||
|
|
||||||
|
const fetchImpl = vi.fn(async () =>
|
||||||
|
new Response(Buffer.from("remote-bytes"), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = "Intro\n\n\n\nAnd local \nDone.";
|
||||||
|
const { segments, unresolved } = await materializeAnswerSegments(text, {
|
||||||
|
rt,
|
||||||
|
workspaceRoot,
|
||||||
|
workspaceDir,
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(unresolved).toEqual([]);
|
||||||
|
expect(imageCreate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(segments).toEqual([
|
||||||
|
{ type: "markdown", content: "Intro\n\n" },
|
||||||
|
{ type: "image", imgKey: "img_remote_1", alt: "remote" },
|
||||||
|
{ type: "markdown", content: "\n\nAnd local " },
|
||||||
|
{ type: "image", imgKey: "img_local_1", alt: "local" },
|
||||||
|
{ type: "markdown", content: "\nDone." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const card = buildAgentCard({
|
||||||
|
phase: "complete",
|
||||||
|
text: "",
|
||||||
|
contentSegments: segments,
|
||||||
|
reasoningText: undefined,
|
||||||
|
toolUseSteps: [],
|
||||||
|
toolUseElapsedMs: undefined,
|
||||||
|
isError: false,
|
||||||
|
interrupted: false,
|
||||||
|
runId: undefined,
|
||||||
|
});
|
||||||
|
expect(card.elements).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ tag: "img", img_key: "img_remote_1" }),
|
||||||
|
expect.objectContaining({ tag: "img", img_key: "img_local_1" }),
|
||||||
|
expect.objectContaining({ tag: "markdown", content: "Intro\n\n" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects private remote hosts", async () => {
|
||||||
|
const imageCreate = vi.fn();
|
||||||
|
const rt = mockRuntime({ imageCreate });
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const { segments, unresolved } = await materializeAnswerSegments(
|
||||||
|
"",
|
||||||
|
{ rt, fetchImpl: fetchImpl as unknown as typeof fetch },
|
||||||
|
);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
expect(imageCreate).not.toHaveBeenCalled();
|
||||||
|
expect(unresolved).toEqual(["http://127.0.0.1/secret.png"]);
|
||||||
|
expect(segments).toEqual([{ type: "markdown", content: "x" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadMessageImage", () => {
|
||||||
|
it("returns image_key from Feishu upload", async () => {
|
||||||
|
const imageCreate = vi.fn(async () => ({ data: { image_key: "img_nested" } }));
|
||||||
|
const rt = mockRuntime({ imageCreate });
|
||||||
|
await expect(uploadMessageImage(rt, Buffer.from("png"))).resolves.toBe("img_nested");
|
||||||
|
expect(imageCreate).toHaveBeenCalledWith({
|
||||||
|
data: { image_type: "message", image: Buffer.from("png") },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockRuntime(options: {
|
||||||
|
readonly imageCreate?: (payload: unknown) => Promise<unknown>;
|
||||||
|
}): FeishuRuntime {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
im: {
|
||||||
|
v1: {
|
||||||
|
image: {
|
||||||
|
create: options.imageCreate ?? vi.fn(),
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
create: vi.fn(),
|
||||||
|
reply: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as FeishuRuntime["client"],
|
||||||
|
logger: {
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
child: vi.fn(),
|
||||||
|
fatal: vi.fn(),
|
||||||
|
trace: vi.fn(),
|
||||||
|
silent: vi.fn(),
|
||||||
|
level: "info",
|
||||||
|
} as unknown as FeishuRuntime["logger"],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user