diff --git a/hub/src/feishu/card/streaming-card.ts b/hub/src/feishu/card/streaming-card.ts index e3d5b62..907b375 100644 --- a/hub/src/feishu/card/streaming-card.ts +++ b/hub/src/feishu/card/streaming-card.ts @@ -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; } diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 9ec5e21..e355d4d 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -28,6 +28,10 @@ export interface OutboundPayload { readonly content: string; } +export interface SendMessageOptions { + readonly replyToMessageId?: string | undefined; +} + export async function withRetry( fn: () => Promise, 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 { +export async function sendTextMessage( + rt: FeishuRuntime, + chatId: string, + text: string, + options?: SendMessageOptions, +): Promise { const payload = buildOutboundPayload(text); - const client = rt.client as unknown as { - im: { v1: { message: { create: (p: unknown) => Promise } } }; - }; 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 { +export async function sendLongText( + rt: FeishuRuntime, + chatId: string, + text: string, + options?: SendMessageOptions, +): Promise { 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 { - await sendTextMessage(rt, chatId, text); +export async function sendText( + rt: FeishuRuntime, + chatId: string, + text: string, + options?: SendMessageOptions, +): Promise { + 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 { +export async function sendInteractiveCardMessage( + rt: FeishuRuntime, + chatId: string, + text: string, + options?: SendMessageOptions, +): Promise { const payload = buildOutboundPayload(text); const card = buildInteractiveCardFromOutboundPayload(payload); - const client = rt.client as unknown as { - im: { v1: { message: { create: (p: unknown) => Promise } } }; - }; 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): Promise { - const client = rt.client as unknown as { - im: { v1: { message: { create: (p: unknown) => Promise } } }; - }; +export async function sendCard( + rt: FeishuRuntime, + chatId: string, + card: Record, + options?: SendMessageOptions, +): Promise { 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 { 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 } } }; - }; 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 { +export async function sendFile( + rt: FeishuRuntime, + chatId: string, + filePath: string, + fileName: string, + options?: SendMessageOptions, +): Promise { const client = rt.client as unknown as { im: { v1: { file: { create: (p: unknown) => Promise } - message: { create: (p: unknown) => Promise } } }; }; 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 { + const client = rt.client as unknown as { + im: { v1: { message: { + create: (p: unknown) => Promise; + reply: (p: unknown) => Promise; + } } }; + }; + 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; diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index 5a0ea4b..76ae653 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -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 { diff --git a/hub/src/feishu/slashCommands.ts b/hub/src/feishu/slashCommands.ts index 4a9ca72..f8b5c8b 100644 --- a/hub/src/feishu/slashCommands.ts +++ b/hub/src/feishu/slashCommands.ts @@ -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); }, }); diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 0ca7cf5..44c8f26 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -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 { 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 { diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 73df524..c1a18e6 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -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; readonly threadMessages: Map; @@ -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(); const threadMessages = new Map(); @@ -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; diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index df6bd1b..3757f34 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -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({ diff --git a/hub/test/unit/feishu-client.test.ts b/hub/test/unit/feishu-client.test.ts index e5d672b..fa075ce 100644 --- a/hub/test/unit/feishu-client.test.ts +++ b/hub/test/unit/feishu-client.test.ts @@ -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; readonly messageCreate: (payload: unknown) => Promise; + readonly messageReply?: (payload: unknown) => Promise; readonly contactBasicBatch?: (payload: unknown) => Promise; readonly request?: (payload: unknown) => Promise; }): 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"],