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
*/
import type { FeishuRuntime } from "../client.js";
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
import { sendCard, patchCard, sendText } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import {
@@ -41,6 +41,7 @@ export interface StreamingCardOptions {
readonly runId: string;
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined;
}
@@ -61,6 +62,7 @@ export class StreamingAgentCard {
private readonly runId: string;
private readonly rt: FeishuRuntime;
private readonly chatId: string;
private readonly sendOptions: SendMessageOptions | undefined;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
@@ -68,6 +70,7 @@ export class StreamingAgentCard {
this.runId = options.runId;
this.rt = options.rt;
this.chatId = options.chatId;
this.sendOptions = options.sendOptions;
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
startToolUseTraceRun(this.runId);
@@ -119,7 +122,7 @@ export class StreamingAgentCard {
updated = await this.flushCard("complete", fallbackText);
}
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 {
clearToolUseTraceRun(this.runId);
@@ -178,7 +181,7 @@ export class StreamingAgentCard {
});
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;
// Send overflow chunks as new messages (rare for agent output)
for (const chunk of chunks.slice(1)) {
@@ -192,7 +195,7 @@ export class StreamingAgentCard {
interrupted: this.interrupted,
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;
this.currentMessageId = overflowMessageId;
}
@@ -211,7 +214,7 @@ export class StreamingAgentCard {
interrupted: this.interrupted,
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;
this.currentMessageId = overflowMessageId;
}
+106 -56
View File
@@ -28,6 +28,10 @@ export interface OutboundPayload {
readonly content: string;
}
export interface SendMessageOptions {
readonly replyToMessageId?: string | undefined;
}
export async function withRetry<T>(
fn: () => Promise<T>,
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. */
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 client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
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);
return await sendMessagePayload(rt, chatId, payload, options);
} catch (e) {
if (payload.msgType === "post" && isPostContentFormatError(e)) {
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }),
},
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) },
options,
);
} catch {
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. */
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;
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) {
lastMessageId = messageId;
}
@@ -237,23 +241,31 @@ export async function sendLongText(rt: FeishuRuntime, chatId: string, text: stri
}
/** Send a plain text message (fire-and-forget). */
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
await sendTextMessage(rt, chatId, text);
export async function sendText(
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. */
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 card = buildInteractiveCardFromOutboundPayload(payload);
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
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. */
export async function sendCard(rt: FeishuRuntime, chatId: string, card: Record<string, unknown>): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
export async function sendCard(
rt: FeishuRuntime,
chatId: string,
card: Record<string, unknown>,
options?: SendMessageOptions,
): Promise<string | null> {
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch {
return null;
}
@@ -318,6 +333,7 @@ export async function sendApprovalCard(
title: string,
body: string,
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
options?: SendMessageOptions,
): Promise<string | null> {
const card = {
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 {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return messageIdFromResponse(res);
return await sendMessagePayload(
rt,
chatId,
{ msgType: "interactive", content: JSON.stringify(card) },
options,
);
} catch (e) {
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
return null;
@@ -491,11 +505,16 @@ export async function downloadMessageFile(
}
/** 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 {
im: { v1: {
file: { create: (p: unknown) => Promise<FileCreateResponse> }
message: { create: (p: unknown) => Promise<MessageCreateResponse> }
} };
};
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");
return null;
}
const msgRes = await withRetry(async () =>
client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
}),
const messageId = await withRetry(async () =>
sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options),
);
const messageId = messageIdFromResponse(msgRes);
if (messageId === null) {
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 = {
readonly message_id?: string | 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 { 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 { readFeishuContext } from "./read.js";
import type { ApprovalManager } from "./approval.js";
@@ -11,6 +11,7 @@ export interface FileDeliveryToolOptions {
readonly projectId: string;
readonly runId: string;
readonly workspaceDir: string;
readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager;
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) {
return {
isError: true,
@@ -106,6 +107,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
value: option.value,
...(option.style === undefined ? {} : { style: option.style }),
})),
options.sendOptions,
);
if (messageId === null) {
return {
+11 -10
View File
@@ -2,7 +2,7 @@ import type { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type { ModelRegistry, RoleEntry } from "../agent/models.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";
export interface SlashInvocation {
@@ -15,6 +15,7 @@ export interface SlashCommandRunContext {
readonly projectId: string;
readonly chatId: string;
readonly rt: FeishuRuntime;
readonly sendOptions?: SendMessageOptions | undefined;
}
export interface SlashCommandDefinition {
@@ -68,9 +69,9 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"不创建 agent run,也不改变当前会话。",
"支持 /help new 和 /new help 两种写法。",
],
run: async ({ invocation, projectId, chatId, rt }) => {
run: async ({ invocation, projectId, chatId, rt, sendOptions }) => {
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。",
"不会清空当前项目的等待队列。",
],
run: async ({ projectId, chatId, rt }) => {
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
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 run。",
],
run: async ({ projectId, chatId, rt }) => {
run: async ({ projectId, chatId, rt, sendOptions }) => {
const latest = await deps.prisma.agentSession.findFirst({
where: { projectId, archivedAt: { not: null } },
orderBy: { archivedAt: "desc" },
select: { id: true },
});
if (latest === null) {
await sendText(rt, chatId, "没有可恢复的会话。");
await sendText(rt, chatId, "没有可恢复的会话。", sendOptions);
return;
}
await deps.prisma.agentSession.update({
where: { id: latest.id },
data: { archivedAt: null },
});
await sendText(rt, chatId, "已恢复上一个会话。");
await sendText(rt, chatId, "已恢复上一个会话。", sendOptions);
},
});
@@ -125,7 +126,7 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
"归档当前未归档的 agent session。",
"清空当前项目已经排队、尚未开始的触发请求。",
],
run: async ({ projectId, chatId, rt }) => {
run: async ({ projectId, chatId, rt, sendOptions }) => {
await deps.prisma.agentSession.updateMany({
where: { projectId, archivedAt: null },
data: { archivedAt: new Date() },
@@ -134,7 +135,7 @@ export function createSlashCommandRegistry(deps: SlashCommandRegistryDeps): Read
if (cleared > 0) {
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 FeishuRuntime,
type MessageReceiveEvent,
type SendMessageOptions,
} from "./client.js";
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
import type { RuntimeSettings } from "../settings/runtime.js";
@@ -124,6 +125,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
options: { readonly queueIfLocked?: boolean } = {},
): Promise<StartAgentRunOutcome> {
const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
const sendOptions = sendOptionsForTriggerMessage(msg);
const queueIfLocked = options.queueIfLocked ?? true;
// 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),
},
});
await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
await sendText(rt, chatId, `无权限使用角色 ${roleId}`, sendOptions);
return "skipped";
}
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
// lifecycle (thinking → tool calls → streaming text → complete).
// 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 fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt,
@@ -287,6 +296,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
projectId,
runId: run.id,
workspaceDir: project.workspaceDir,
sendOptions,
approvalManager,
onDelivered: (path) => {
deliveredFiles.push(path);
@@ -422,11 +432,16 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
});
if (position === 0) {
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";
}
await sendText(context.rt, context.chatId, `已加入队列(第${position}位),当前处理完成后将自动开始`);
await sendText(
context.rt,
context.chatId,
`已加入队列(第${position}位),当前处理完成后将自动开始`,
sendOptionsForTriggerMessage(context.msg),
);
return "queued";
}
@@ -614,7 +629,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (senderOpenId === "") {
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 sendText(rt, chatId, "无法识别发送者,拒绝触发。");
await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg));
return;
}
const actor = { feishuOpenId: senderOpenId, chatId };
@@ -634,7 +649,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sender: await senderAuditMetadata(rt, senderOpenId),
},
});
await sendText(rt, chatId, "无权限触发。");
await sendText(rt, chatId, "无权限触发。", sendOptionsForTriggerMessage(msg));
return;
}
@@ -689,14 +704,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
if (helpSubcommandTarget !== null) {
// Help is generated on demand because role/tool configuration is runtime data.
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;
}
if (invocation !== null) {
const slashCommand = slashCommands.get(invocation.name);
if (slashCommand !== undefined) {
await slashCommand.run({ invocation, projectId, chatId, rt });
await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) });
return;
}
}
@@ -772,6 +787,10 @@ function nonEmpty(value: string | undefined): string | undefined {
return value === undefined || value === "" ? undefined : value;
}
function sendOptionsForTriggerMessage(msg: MessageReceiveEvent["message"]): SendMessageOptions {
return { replyToMessageId: msg.message_id };
}
function approvalActionFromValue(value: unknown): string | null {
if (typeof value === "string") {
try {
+44 -13
View File
@@ -62,6 +62,12 @@ export interface MockFeishuRuntime extends FeishuRuntime {
readonly sentTexts: string[];
readonly sentCards: 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 readableMessages: Map<string, MockFeishuMessage>;
readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
@@ -84,6 +90,7 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
const sentTexts: string[] = [];
const sentCards: unknown[] = [];
const sentPatches: unknown[] = [];
const sentReplies: MockFeishuRuntime["sentReplies"] = [];
const reactions: Array<{ messageId: string; emoji: string }> = [];
const readableMessages = new Map<string, MockFeishuMessage>();
const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
@@ -108,19 +115,21 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
message: {
create: async (p: unknown) => {
const payload = p as { data?: { msg_type?: string; content?: string } };
if (payload.data?.msg_type === "interactive") {
const card = payload.data.content ? JSON.parse(payload.data.content) : null;
sentCards.push(card);
const text = textFromCard(card);
if (text !== null) sentTexts.push(text);
} else {
try {
const c = JSON.parse(payload.data?.content ?? "{}") as { text?: string };
sentTexts.push(c.text ?? payload.data?.content ?? "");
} catch {
sentTexts.push(payload.data?.content ?? "");
}
}
recordSentMessage(payload.data, sentTexts, sentCards);
return { data: { message_id: "mock-msg-id" } };
},
reply: async (p: unknown) => {
const payload = p as {
path?: { message_id?: string };
data?: { msg_type?: string; content?: string; reply_in_thread?: unknown };
};
sentReplies.push({
messageId: payload.path?.message_id ?? "",
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" } };
},
patch: async (p: unknown) => {
@@ -153,6 +162,7 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
sentTexts,
sentCards,
sentPatches,
sentReplies,
reactions,
readableMessages,
threadMessages,
@@ -160,6 +170,27 @@ export function mockFeishuRuntime(): MockFeishuRuntime {
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 {
if (typeof card !== "object" || card === null) return null;
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 () => {
await seedProject("proj-1", "chat-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).
await vi.waitFor(async () => {
@@ -122,6 +123,11 @@ describe("trigger full lifecycle (integration)", () => {
// A status card was sent.
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(runAgentCalls).toHaveLength(1);
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");
});
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 () => {
let messageNumber = 0;
const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } }));
@@ -70,6 +87,7 @@ describe("Feishu client helpers", () => {
function mockRuntime(options: {
readonly fileCreate: (payload: unknown) => Promise<unknown>;
readonly messageCreate: (payload: unknown) => Promise<unknown>;
readonly messageReply?: (payload: unknown) => Promise<unknown>;
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
readonly request?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
@@ -86,7 +104,10 @@ function mockRuntime(options: {
im: {
v1: {
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"],