fix: reply to Feishu trigger messages

This commit is contained in:
2026-07-09 20:04:13 +08:00
parent fc0df7b823
commit fc5a5365d8
8 changed files with 229 additions and 96 deletions
+8 -5
View File
@@ -20,7 +20,7 @@
* 6. fail(errorText) — flush + transition to error card * 6. fail(errorText) — flush + transition to error card
*/ */
import type { FeishuRuntime } from "../client.js"; import type { FeishuRuntime, SendMessageOptions } from "../client.js";
import { sendCard, patchCard, sendText } from "../client.js"; import { sendCard, patchCard, sendText } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import { import {
@@ -41,6 +41,7 @@ export interface StreamingCardOptions {
readonly runId: string; readonly runId: string;
readonly rt: FeishuRuntime; readonly rt: FeishuRuntime;
readonly chatId: string; readonly chatId: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly patchIntervalMs: number | undefined; readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined; readonly maxMessageLength: number | undefined;
} }
@@ -61,6 +62,7 @@ export class StreamingAgentCard {
private readonly runId: string; private readonly runId: string;
private readonly rt: FeishuRuntime; private readonly rt: FeishuRuntime;
private readonly chatId: string; private readonly chatId: string;
private readonly sendOptions: SendMessageOptions | undefined;
private readonly patchIntervalMs: number; private readonly patchIntervalMs: number;
private readonly maxMessageLength: number; private readonly maxMessageLength: number;
@@ -68,6 +70,7 @@ export class StreamingAgentCard {
this.runId = options.runId; this.runId = options.runId;
this.rt = options.rt; this.rt = options.rt;
this.chatId = options.chatId; this.chatId = options.chatId;
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;
startToolUseTraceRun(this.runId); startToolUseTraceRun(this.runId);
@@ -119,7 +122,7 @@ export class StreamingAgentCard {
updated = await this.flushCard("complete", fallbackText); updated = await this.flushCard("complete", fallbackText);
} }
if (!updated && this.interrupted) { if (!updated && this.interrupted) {
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002"); await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
} }
} finally { } finally {
clearToolUseTraceRun(this.runId); clearToolUseTraceRun(this.runId);
@@ -178,7 +181,7 @@ export class StreamingAgentCard {
}); });
if (this.currentMessageId === null) { if (this.currentMessageId === null) {
this.currentMessageId = await sendCard(this.rt, this.chatId, card); 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)
for (const chunk of chunks.slice(1)) { for (const chunk of chunks.slice(1)) {
@@ -192,7 +195,7 @@ export class StreamingAgentCard {
interrupted: this.interrupted, interrupted: this.interrupted,
runId: undefined, runId: undefined,
}); });
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard); const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null; updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId; this.currentMessageId = overflowMessageId;
} }
@@ -211,7 +214,7 @@ export class StreamingAgentCard {
interrupted: this.interrupted, interrupted: this.interrupted,
runId: undefined, runId: undefined,
}); });
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard); const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null; updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId; this.currentMessageId = overflowMessageId;
} }
+106 -56
View File
@@ -28,6 +28,10 @@ export interface OutboundPayload {
readonly content: string; readonly content: string;
} }
export interface SendMessageOptions {
readonly replyToMessageId?: string | undefined;
}
export async function withRetry<T>( export async function withRetry<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
options: { options: {
@@ -193,29 +197,24 @@ export function parsePostMessage(content: string): { text: string; imageKeys: st
} }
/** Send a text message using the best Feishu message type for the content. */ /** Send a text message using the best Feishu message type for the content. */
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> { export async function sendTextMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text); const payload = buildOutboundPayload(text);
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try { try {
const res = await client.im.v1.message.create({ return await sendMessagePayload(rt, chatId, payload, options);
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
});
return messageIdFromResponse(res);
} catch (e) { } catch (e) {
if (payload.msgType === "post" && isPostContentFormatError(e)) { if (payload.msgType === "post" && isPostContentFormatError(e)) {
try { try {
const res = await client.im.v1.message.create({ return await sendMessagePayload(
params: { receive_id_type: "chat_id" }, rt,
data: { chatId,
receive_id: chatId, { msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) },
msg_type: "text", options,
content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }), );
},
});
return messageIdFromResponse(res);
} catch { } catch {
return null; return null;
} }
@@ -225,10 +224,15 @@ export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: s
} }
/** Send long text as multiple Feishu messages. Returns the last successful message_id. */ /** Send long text as multiple Feishu messages. Returns the last successful message_id. */
export async function sendLongText(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> { export async function sendLongText(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
let lastMessageId: string | null = null; let lastMessageId: string | null = null;
for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) { for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) {
const messageId = await sendTextMessage(rt, chatId, chunk); const messageId = await sendTextMessage(rt, chatId, chunk, options);
if (messageId !== null) { if (messageId !== null) {
lastMessageId = messageId; lastMessageId = messageId;
} }
@@ -237,23 +241,31 @@ export async function sendLongText(rt: FeishuRuntime, chatId: string, text: stri
} }
/** Send a plain text message (fire-and-forget). */ /** Send a plain text message (fire-and-forget). */
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> { export async function sendText(
await sendTextMessage(rt, chatId, text); rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<void> {
await sendTextMessage(rt, chatId, text, options);
} }
/** Send an interactive card message (always patchable). Used for streaming. */ /** Send an interactive card message (always patchable). Used for streaming. */
export async function sendInteractiveCardMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> { export async function sendInteractiveCardMessage(
rt: FeishuRuntime,
chatId: string,
text: string,
options?: SendMessageOptions,
): Promise<string | null> {
const payload = buildOutboundPayload(text); const payload = buildOutboundPayload(text);
const card = buildInteractiveCardFromOutboundPayload(payload); const card = buildInteractiveCardFromOutboundPayload(payload);
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try { try {
const res = await client.im.v1.message.create({ return await sendMessagePayload(
params: { receive_id_type: "chat_id" }, rt,
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) }, chatId,
}); { msgType: "interactive", content: JSON.stringify(card) },
return messageIdFromResponse(res); options,
);
} catch { } catch {
return null; return null;
} }
@@ -278,16 +290,19 @@ export async function patchTextMessage(rt: FeishuRuntime, messageId: string, tex
} }
/** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */ /** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */
export async function sendCard(rt: FeishuRuntime, chatId: string, card: Record<string, unknown>): Promise<string | null> { export async function sendCard(
const client = rt.client as unknown as { rt: FeishuRuntime,
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } }; chatId: string,
}; card: Record<string, unknown>,
options?: SendMessageOptions,
): Promise<string | null> {
try { try {
const res = await client.im.v1.message.create({ return await sendMessagePayload(
params: { receive_id_type: "chat_id" }, rt,
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) }, chatId,
}); { msgType: "interactive", content: JSON.stringify(card) },
return messageIdFromResponse(res); options,
);
} catch { } catch {
return null; return null;
} }
@@ -318,6 +333,7 @@ export async function sendApprovalCard(
title: string, title: string,
body: string, body: string,
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>, buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
options?: SendMessageOptions,
): Promise<string | null> { ): Promise<string | null> {
const card = { const card = {
config: { wide_screen_mode: true }, config: { wide_screen_mode: true },
@@ -335,15 +351,13 @@ export async function sendApprovalCard(
}, },
], ],
}; };
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try { try {
const res = await client.im.v1.message.create({ return await sendMessagePayload(
params: { receive_id_type: "chat_id" }, rt,
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) }, chatId,
}); { msgType: "interactive", content: JSON.stringify(card) },
return messageIdFromResponse(res); options,
);
} catch (e) { } catch (e) {
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed"); rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
return null; return null;
@@ -491,11 +505,16 @@ export async function downloadMessageFile(
} }
/** Upload and send a file to a chat. */ /** Upload and send a file to a chat. */
export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: string, fileName: string): Promise<string | null> { export async function sendFile(
rt: FeishuRuntime,
chatId: string,
filePath: string,
fileName: string,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as { const client = rt.client as unknown as {
im: { v1: { im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> } file: { create: (p: unknown) => Promise<FileCreateResponse> }
message: { create: (p: unknown) => Promise<MessageCreateResponse> }
} }; } };
}; };
try { try {
@@ -510,13 +529,9 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key"); rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null; return null;
} }
const msgRes = await withRetry(async () => const messageId = await withRetry(async () =>
client.im.v1.message.create({ sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
}),
); );
const messageId = messageIdFromResponse(msgRes);
if (messageId === null) { if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id"); rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
} }
@@ -527,6 +542,41 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
} }
} }
async function sendMessagePayload(
rt: FeishuRuntime,
chatId: string,
payload: OutboundPayload,
options?: SendMessageOptions,
): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: { message: {
create: (p: unknown) => Promise<MessageCreateResponse>;
reply: (p: unknown) => Promise<MessageCreateResponse>;
} } };
};
const replyToMessageId = normalizeReplyMessageId(options?.replyToMessageId);
if (replyToMessageId !== null) {
const res = await client.im.v1.message.reply({
path: { message_id: replyToMessageId },
data: {
msg_type: payload.msgType,
content: payload.content,
},
});
return messageIdFromResponse(res);
}
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content },
});
return messageIdFromResponse(res);
}
function normalizeReplyMessageId(messageId: string | undefined): string | null {
return messageId === undefined || messageId === "" ? null : messageId;
}
type MessageCreateResponse = { type MessageCreateResponse = {
readonly message_id?: string | undefined; readonly message_id?: string | undefined;
readonly data?: { readonly message_id?: string | undefined } | undefined; readonly data?: { readonly message_id?: string | undefined } | undefined;
+4 -2
View File
@@ -1,6 +1,6 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod"; import { z } from "zod";
import { sendApprovalCard, sendFile, type FeishuRuntime } from "./client.js"; import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { resolveDeliverableFile } from "./fileDelivery.js"; import { resolveDeliverableFile } from "./fileDelivery.js";
import { readFeishuContext } from "./read.js"; import { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js"; import type { ApprovalManager } from "./approval.js";
@@ -11,6 +11,7 @@ export interface FileDeliveryToolOptions {
readonly projectId: string; readonly projectId: string;
readonly runId: string; readonly runId: string;
readonly workspaceDir: string; readonly workspaceDir: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager; readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void; readonly onDelivered?: (path: string) => void;
} }
@@ -43,7 +44,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
}; };
} }
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name); const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name, options.sendOptions);
if (messageId === null) { if (messageId === null) {
return { return {
isError: true, isError: true,
@@ -106,6 +107,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
value: option.value, value: option.value,
...(option.style === undefined ? {} : { style: option.style }), ...(option.style === undefined ? {} : { style: option.style }),
})), })),
options.sendOptions,
); );
if (messageId === null) { if (messageId === null) {
return { return {
+11 -10
View File
@@ -2,7 +2,7 @@ import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { ModelRegistry, RoleEntry } from "../agent/models.js"; import type { ModelRegistry, RoleEntry } from "../agent/models.js";
import type { RuntimeSettings } from "../settings/runtime.js"; import type { RuntimeSettings } from "../settings/runtime.js";
import { sendText, type FeishuRuntime } from "./client.js"; import { sendText, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import type { TriggerQueue } from "./triggerQueue.js"; import type { TriggerQueue } from "./triggerQueue.js";
export interface SlashInvocation { export interface SlashInvocation {
@@ -15,6 +15,7 @@ export interface SlashCommandRunContext {
readonly projectId: string; readonly projectId: string;
readonly chatId: string; readonly chatId: string;
readonly rt: FeishuRuntime; readonly rt: FeishuRuntime;
readonly sendOptions?: SendMessageOptions | undefined;
} }
export interface SlashCommandDefinition { export interface SlashCommandDefinition {
@@ -68,9 +69,9 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"不创建 agent run,也不改变当前会话。", "不创建 agent run,也不改变当前会话。",
"支持 /help new 和 /new help 两种写法。", "支持 /help new 和 /new help 两种写法。",
], ],
run: async ({ invocation, projectId, chatId, rt }) => { run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
const registry = await deps.settings.modelRegistry({ projectId }); const registry = await deps.settings.modelRegistry({ projectId });
await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands)); await sendText(rt, chatId, formatHelpCommandInvocation(invocation, registry, commands), sendOptions);
}, },
}); });
@@ -82,12 +83,12 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"归档当前未归档的 agent session。", "归档当前未归档的 agent session。",
"不会清空当前项目的等待队列。", "不会清空当前项目的等待队列。",
], ],
run: async ({ projectId, chatId, rt }) => { run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({ await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null }, where: { projectId, archivedAt: null },
data: { archivedAt: new Date() }, data: { archivedAt: new Date() },
}); });
await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。"); await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。", sendOptions);
}, },
}); });
@@ -99,21 +100,21 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"只恢复当前项目最近归档的 agent session。", "只恢复当前项目最近归档的 agent session。",
"没有可恢复会话时只回复提示,不会创建 agent run。", "没有可恢复会话时只回复提示,不会创建 agent run。",
], ],
run: async ({ projectId, chatId, rt }) => { run: async ({ projectId, chatId, rt, sendOptions }) => {
const latest = await deps.prisma.agentSession.findFirst({ const latest = await deps.prisma.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } }, where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" }, orderBy: { archivedAt: "desc" },
select: { id: true }, select: { id: true },
}); });
if (latest === null) { if (latest === null) {
await sendText(rt, chatId, "没有可恢复的会话。"); await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
return; return;
} }
await deps.prisma.agentSession.update({ await deps.prisma.agentSession.update({
where: { id: latest.id }, where: { id: latest.id },
data: { archivedAt: null }, data: { archivedAt: null },
}); });
await sendText(rt, chatId, "已恢复上一个会话。"); await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
}, },
}); });
@@ -125,7 +126,7 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"归档当前未归档的 agent session。", "归档当前未归档的 agent session。",
"清空当前项目已经排队、尚未开始的触发请求。", "清空当前项目已经排队、尚未开始的触发请求。",
], ],
run: async ({ projectId, chatId, rt }) => { run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({ await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null }, where: { projectId, archivedAt: null },
data: { archivedAt: new Date() }, data: { archivedAt: new Date() },
@@ -134,7 +135,7 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
if (cleared > 0) { if (cleared > 0) {
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset"); deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
} }
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。"); await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。", sendOptions);
}, },
}); });
+27 -8
View File
@@ -25,6 +25,7 @@ import {
type CardActionEvent, type CardActionEvent,
type FeishuRuntime, type FeishuRuntime,
type MessageReceiveEvent, type MessageReceiveEvent,
type SendMessageOptions,
} from "./client.js"; } from "./client.js";
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js"; import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
import type { RuntimeSettings } from "../settings/runtime.js"; import type { RuntimeSettings } from "../settings/runtime.js";
@@ -124,6 +125,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
options: { readonly queueIfLocked?: boolean } = {}, options: { readonly queueIfLocked?: boolean } = {},
): Promise<StartAgentRunOutcome> { ): Promise<StartAgentRunOutcome> {
const { msg, rt, chatId, projectId, senderOpenId, actor } = context; const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
const sendOptions = sendOptionsForTriggerMessage(msg);
const queueIfLocked = options.queueIfLocked ?? true; const queueIfLocked = options.queueIfLocked ?? true;
// ADR-0002: if locked by another run, enqueue the trigger for this project. // ADR-0002: if locked by another run, enqueue the trigger for this project.
@@ -169,7 +171,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sender: await senderAuditMetadata(rt, senderOpenId), sender: await senderAuditMetadata(rt, senderOpenId),
}, },
}); });
await sendText(rt, chatId, `无权限使用角色 ${roleId}`); await sendText(rt, chatId, `无权限使用角色 ${roleId}`, sendOptions);
return "skipped"; return "skipped";
} }
const role = models.role(roleId); const role = models.role(roleId);
@@ -279,7 +281,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// Streaming agent card: single interactive card through the full run // Streaming agent card: single interactive card through the full run
// lifecycle (thinking → tool calls → streaming text → complete). // lifecycle (thinking → tool calls → streaming text → complete).
// Shows tool-use trace panel + reasoning panel + answer text. // Shows tool-use trace panel + reasoning panel + answer text.
const card = new StreamingAgentCard({ runId: run.id, rt, chatId, patchIntervalMs: undefined, maxMessageLength: undefined }); const card = new StreamingAgentCard({
runId: run.id,
rt,
chatId,
sendOptions,
patchIntervalMs: undefined,
maxMessageLength: undefined,
});
const deliveredFiles: string[] = []; const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({ const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt, rt,
@@ -287,6 +296,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId, projectId,
runId: run.id, runId: run.id,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
sendOptions,
approvalManager, approvalManager,
onDelivered: (path) => { onDelivered: (path) => {
deliveredFiles.push(path); deliveredFiles.push(path);
@@ -422,11 +432,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
}); });
if (position === 0) { if (position === 0) {
await reactToMessage(context.rt, context.msg.message_id, "OnIt"); await reactToMessage(context.rt, context.msg.message_id, "OnIt");
await sendText(context.rt, context.chatId, "队列已满,请稍后再试"); await sendText(context.rt, context.chatId, "队列已满,请稍后再试", sendOptionsForTriggerMessage(context.msg));
return "rejected"; return "rejected";
} }
await sendText(context.rt, context.chatId, `已加入队列(第${position}位),当前处理完成后将自动开始`); await sendText(
context.rt,
context.chatId,
`已加入队列(第${position}位),当前处理完成后将自动开始`,
sendOptionsForTriggerMessage(context.msg),
);
return "queued"; return "queued";
} }
@@ -614,7 +629,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (senderOpenId === "") { if (senderOpenId === "") {
deps.logger.warn({ projectId, chatId }, "feishu trigger: sender has no open_id, denying"); deps.logger.warn({ projectId, chatId }, "feishu trigger: sender has no open_id, denying");
await writeAudit(deps.prisma, { projectId, action: "trigger.denied", metadata: { reason: "missing open_id" } }); await writeAudit(deps.prisma, { projectId, action: "trigger.denied", metadata: { reason: "missing open_id" } });
await sendText(rt, chatId, "无法识别发送者,拒绝触发。"); await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg));
return; return;
} }
const actor = { feishuOpenId: senderOpenId, chatId }; const actor = { feishuOpenId: senderOpenId, chatId };
@@ -634,7 +649,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sender: await senderAuditMetadata(rt, senderOpenId), sender: await senderAuditMetadata(rt, senderOpenId),
}, },
}); });
await sendText(rt, chatId, "无权限触发。"); await sendText(rt, chatId, "无权限触发。", sendOptionsForTriggerMessage(msg));
return; return;
} }
@@ -689,14 +704,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (helpSubcommandTarget !== null) { if (helpSubcommandTarget !== null) {
// Help is generated on demand because role/tool configuration is runtime data. // Help is generated on demand because role/tool configuration is runtime data.
const models = await deps.settings.modelRegistry({ projectId }); const models = await deps.settings.modelRegistry({ projectId });
await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands)); await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands), sendOptionsForTriggerMessage(msg));
return; return;
} }
if (invocation !== null) { if (invocation !== null) {
const slashCommand = slashCommands.get(invocation.name); const slashCommand = slashCommands.get(invocation.name);
if (slashCommand !== undefined) { if (slashCommand !== undefined) {
await slashCommand.run({ invocation, projectId, chatId, rt }); await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) });
return; return;
} }
} }
@@ -772,6 +787,10 @@ function nonEmpty(value: string | undefined): string | undefined {
return value === undefined || value === "" ? undefined : value; return value === undefined || value === "" ? undefined : value;
} }
function sendOptionsForTriggerMessage(msg: MessageReceiveEvent["message"]): SendMessageOptions {
return { replyToMessageId: msg.message_id };
}
function approvalActionFromValue(value: unknown): string | null { function approvalActionFromValue(value: unknown): string | null {
if (typeof value === "string") { if (typeof value === "string") {
try { try {
+44 -13
View File
@@ -62,6 +62,12 @@ export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[]; readonly sentTexts: string[];
readonly sentCards: unknown[]; readonly sentCards: unknown[];
readonly sentPatches: unknown[]; readonly sentPatches: unknown[];
readonly sentReplies: Array<{
readonly messageId: string;
readonly msgType: string;
readonly content: string;
readonly replyInThread: unknown;
}>;
readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>; readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
readonly readableMessages: Map<string, MockFeishuMessage>; readonly readableMessages: Map<string, MockFeishuMessage>;
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>; readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
@@ -84,6 +90,7 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = []; const sentTexts: string[] = [];
const sentCards: unknown[] = []; const sentCards: unknown[] = [];
const sentPatches: unknown[] = []; const sentPatches: unknown[] = [];
const sentReplies: MockFeishuRuntime["sentReplies"] = [];
const reactions: Array<{ messageId: string; emoji: string }> = []; const reactions: Array<{ messageId: string; emoji: string }> = [];
const readableMessages = new Map<string, MockFeishuMessage>(); const readableMessages = new Map<string, MockFeishuMessage>();
const threadMessages = new Map<string, readonly MockFeishuMessage[]>(); const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
@@ -108,19 +115,21 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
message: { message: {
create: async (p: unknown) => { create: async (p: unknown) => {
const payload = p as { data?: { msg_type?: string; content?: string } }; const payload = p as { data?: { msg_type?: string; content?: string } };
if (payload.data?.msg_type === "interactive") { recordSentMessage(payload.data, sentTexts, sentCards);
const card = payload.data.content ? JSON.parse(payload.data.content) : null; return { data: { message_id: "mock-msg-id" } };
sentCards.push(card); },
const text = textFromCard(card); reply: async (p: unknown) => {
if (text !== null) sentTexts.push(text); const payload = p as {
} else { path?: { message_id?: string };
try { data?: { msg_type?: string; content?: string; reply_in_thread?: unknown };
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string }; };
sentTexts.push(c.text ?? payload.data?.content ?? ""); sentReplies.push({
} catch { messageId: payload.path?.message_id ?? "",
sentTexts.push(payload.data?.content ?? ""); msgType: payload.data?.msg_type ?? "",
} content: payload.data?.content ?? "",
} replyInThread: payload.data?.reply_in_thread,
});
recordSentMessage(payload.data, sentTexts, sentCards);
return { data: { message_id: "mock-msg-id" } }; return { data: { message_id: "mock-msg-id" } };
}, },
patch: async (p: unknown) => { patch: async (p: unknown) => {
@@ -153,6 +162,7 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
sentTexts, sentTexts,
sentCards, sentCards,
sentPatches, sentPatches,
sentReplies,
reactions, reactions,
readableMessages, readableMessages,
threadMessages, threadMessages,
@@ -160,6 +170,27 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
return rt; return rt;
} }
function recordSentMessage(
data: { msg_type?: string; content?: string } | undefined,
sentTexts: string[],
sentCards: unknown[],
): void {
if (data?.msg_type === "interactive") {
const card = data.content ? JSON.parse(data.content) : null;
sentCards.push(card);
const text = textFromCard(card);
if (text !== null) sentTexts.push(text);
return;
}
try {
const c = JSON.parse(data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? data?.content ?? "");
} catch {
sentTexts.push(data?.content ?? "");
}
}
function textFromCard(card: unknown): string | null { function textFromCard(card: unknown): string | null {
if (typeof card !== "object" || card === null) return null; if (typeof card !== "object" || card === null) return null;
const elements = (card as { elements?: unknown }).elements; const elements = (card as { elements?: unknown }).elements;
+7 -1
View File
@@ -104,8 +104,9 @@ describe("trigger full lifecycle (integration)", () => {
it("creates a run, acquires + releases the lock, sends status card", async () => { it("creates a run, acquires + releases the lock, sends status card", async () => {
await seedProject("proj-1", "chat-1"); await seedProject("proj-1", "chat-1");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
const event = makeEvent("chat-1", "@_user_1 写教案");
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt); await trigger(event, rt);
// Wait for the async run to complete (fire-and-forget in trigger). // Wait for the async run to complete (fire-and-forget in trigger).
await vi.waitFor(async () => { await vi.waitFor(async () => {
@@ -122,6 +123,11 @@ describe("trigger full lifecycle (integration)", () => {
// A status card was sent. // A status card was sent.
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1); expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
expect(rt.sentReplies[0]).toMatchObject({
messageId: event.message.message_id,
msgType: "interactive",
replyInThread: undefined,
});
expect(rt.sentTexts).toContain("mock response"); expect(rt.sentTexts).toContain("mock response");
expect(runAgentCalls).toHaveLength(1); expect(runAgentCalls).toHaveLength(1);
expect(runAgentCalls[0]?.providerEnv).toMatchObject({ expect(runAgentCalls[0]?.providerEnv).toMatchObject({
+22 -1
View File
@@ -34,6 +34,23 @@ describe("Feishu client helpers", () => {
await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1"); await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1");
}); });
it("replies to a message without enabling Feishu topic mode", async () => {
const messageCreate = vi.fn();
const messageReply = vi.fn(async () => ({ data: { message_id: "reply-1" } }));
const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate, messageReply });
await expect(sendTextMessage(rt, "chat-1", "hello", { replyToMessageId: "m-trigger" })).resolves.toBe("reply-1");
expect(messageCreate).not.toHaveBeenCalled();
expect(messageReply).toHaveBeenCalledWith({
path: { message_id: "m-trigger" },
data: {
msg_type: "text",
content: JSON.stringify({ text: "hello" }),
},
});
});
it("sends long text in chunks and returns the last successful message id", async () => { it("sends long text in chunks and returns the last successful message id", async () => {
let messageNumber = 0; let messageNumber = 0;
const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } })); const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } }));
@@ -70,6 +87,7 @@ describe("Feishu client helpers", () => {
function mockRuntime(options: { function mockRuntime(options: {
readonly fileCreate: (payload: unknown) => Promise<unknown>; readonly fileCreate: (payload: unknown) => Promise<unknown>;
readonly messageCreate: (payload: unknown) => Promise<unknown>; readonly messageCreate: (payload: unknown) => Promise<unknown>;
readonly messageReply?: (payload: unknown) => Promise<unknown>;
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>; readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
readonly request?: (payload: unknown) => Promise<unknown>; readonly request?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime { }): FeishuRuntime {
@@ -86,7 +104,10 @@ function mockRuntime(options: {
im: { im: {
v1: { v1: {
file: { create: options.fileCreate }, file: { create: options.fileCreate },
message: { create: options.messageCreate }, message: {
create: options.messageCreate,
reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })),
},
}, },
}, },
} as unknown as FeishuRuntime["client"], } as unknown as FeishuRuntime["client"],