From 89669a33bd675993ac9d19af05d5361bd6a644a4 Mon Sep 17 00:00:00 2001 From: kcinzgg Date: Sat, 28 Feb 2026 09:53:02 +0800 Subject: [PATCH] feat(feishu): add replyInThread configuration for message replies (openclaw#27325) thanks @kcinzgg Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: kcinzgg <13964709+kcinzgg@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/bot.test.ts | 38 +++++ extensions/feishu/src/bot.ts | 19 ++- extensions/feishu/src/channel.ts | 1 + extensions/feishu/src/config-schema.test.ts | 33 +++- extensions/feishu/src/config-schema.ts | 12 ++ extensions/feishu/src/media.test.ts | 32 ++++ extensions/feishu/src/media.ts | 25 ++- .../feishu/src/reply-dispatcher.test.ts | 94 +++++++++++ extensions/feishu/src/reply-dispatcher.ts | 29 +++- extensions/feishu/src/send.ts | 17 +- extensions/feishu/src/streaming-card.ts | 147 ++++++++++++------ 12 files changed, 385 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e48cb5497de..ba729309cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) - Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) +- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) - Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) - Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg. - Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 2f2a92a8d70..9e87ee3b251 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -661,4 +661,42 @@ describe("handleFeishuMessage command authorization", () => { }), ); }); + + it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_topic", + replyInThread: "enabled", + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-init" } }, + message: { + message_id: "msg-new-topic-root", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "create topic" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockResolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:msg-new-topic-root" }, + parentPeer: { kind: "group", id: "oc-group" }, + }), + ); + }); }); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 943ed964b51..d2f7cce6451 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -760,6 +760,10 @@ export async function handleFeishuMessage(params: { let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" = "group"; + let topicRootForSession: string | null = null; + const replyInThread = + isGroup && + (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled"; if (isGroup) { const legacyTopicSessionMode = @@ -769,16 +773,22 @@ export async function handleFeishuMessage(params: { feishuCfg?.groupSessionScope ?? (legacyTopicSessionMode === "enabled" ? "group_topic" : "group"); + // When topic-scoped sessions are enabled and replyInThread is on, the first + // bot reply creates the thread rooted at the current message ID. + if (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") { + topicRootForSession = ctx.rootId ?? (replyInThread ? ctx.messageId : null); + } + switch (groupSessionScope) { case "group_sender": peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`; break; case "group_topic": - peerId = ctx.rootId ? `${ctx.chatId}:topic:${ctx.rootId}` : ctx.chatId; + peerId = topicRootForSession ? `${ctx.chatId}:topic:${topicRootForSession}` : ctx.chatId; break; case "group_topic_sender": - peerId = ctx.rootId - ? `${ctx.chatId}:topic:${ctx.rootId}:sender:${ctx.senderOpenId}` + peerId = topicRootForSession + ? `${ctx.chatId}:topic:${topicRootForSession}:sender:${ctx.senderOpenId}` : `${ctx.chatId}:sender:${ctx.senderOpenId}`; break; case "group": @@ -801,7 +811,7 @@ export async function handleFeishuMessage(params: { // Add parentPeer for binding inheritance in topic-scoped modes. parentPeer: isGroup && - ctx.rootId && + topicRootForSession && (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") ? { kind: "group", @@ -965,6 +975,7 @@ export async function handleFeishuMessage(params: { runtime: runtime as RuntimeEnv, chatId: ctx.chatId, replyToMessageId: ctx.messageId, + replyInThread, mentionTargets: ctx.mentionTargets, accountId: account.accountId, }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index b9a599a369c..43fd8c9ca42 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -106,6 +106,7 @@ export const feishuPlugin: ChannelPlugin = { enum: ["group", "group_sender", "group_topic", "group_topic_sender"], }, topicSessionMode: { type: "string", enum: ["disabled", "enabled"] }, + replyInThread: { type: "string", enum: ["disabled", "enabled"] }, historyLimit: { type: "integer", minimum: 0 }, dmHistoryLimit: { type: "integer", minimum: 0 }, textChunkLimit: { type: "integer", minimum: 1 }, diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 64a278c4afe..ab8f09612e4 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { FeishuConfigSchema } from "./config-schema.js"; +import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js"; describe("FeishuConfigSchema webhook validation", () => { it("applies top-level defaults", () => { @@ -86,3 +86,34 @@ describe("FeishuConfigSchema webhook validation", () => { expect(result.success).toBe(true); }); }); + +describe("FeishuConfigSchema replyInThread", () => { + it("accepts replyInThread at top level", () => { + const result = FeishuConfigSchema.parse({ replyInThread: "enabled" }); + expect(result.replyInThread).toBe("enabled"); + }); + + it("defaults replyInThread to undefined when not set", () => { + const result = FeishuConfigSchema.parse({}); + expect(result.replyInThread).toBeUndefined(); + }); + + it("rejects invalid replyInThread value", () => { + const result = FeishuConfigSchema.safeParse({ replyInThread: "always" }); + expect(result.success).toBe(false); + }); + + it("accepts replyInThread in group config", () => { + const result = FeishuGroupSchema.parse({ replyInThread: "enabled" }); + expect(result.replyInThread).toBe("enabled"); + }); + + it("accepts replyInThread in account config", () => { + const result = FeishuConfigSchema.parse({ + accounts: { + main: { replyInThread: "enabled" }, + }, + }); + expect(result.accounts?.main?.replyInThread).toBe("enabled"); + }); +}); diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 04d65551f3e..1d3b80e0944 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -111,6 +111,16 @@ const GroupSessionScopeSchema = z */ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional(); +/** + * Reply-in-thread mode for group chats. + * - "disabled" (default): Bot replies are normal inline replies + * - "enabled": Bot replies create or continue a Feishu topic thread + * + * When enabled, the Feishu reply API is called with `reply_in_thread: true`, + * causing the reply to appear as a topic (话题) under the original message. + */ +const ReplyInThreadSchema = z.enum(["disabled", "enabled"]).optional(); + export const FeishuGroupSchema = z .object({ requireMention: z.boolean().optional(), @@ -121,6 +131,7 @@ export const FeishuGroupSchema = z systemPrompt: z.string().optional(), groupSessionScope: GroupSessionScopeSchema, topicSessionMode: TopicSessionModeSchema, + replyInThread: ReplyInThreadSchema, }) .strict(); @@ -147,6 +158,7 @@ const FeishuSharedConfigShape = { renderMode: RenderModeSchema, streaming: StreamingModeSchema, tools: FeishuToolsConfigSchema, + replyInThread: ReplyInThreadSchema, }; /** diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 7ee765c37a7..8d1c61d3b57 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -190,6 +190,38 @@ describe("sendMediaFeishu msg_type routing", () => { expect(messageCreateMock).not.toHaveBeenCalled(); }); + it("passes reply_in_thread when replyInThread is true", async () => { + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaBuffer: Buffer.from("video"), + fileName: "reply.mp4", + replyToMessageId: "om_parent", + replyInThread: true, + }); + + expect(messageReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: { message_id: "om_parent" }, + data: expect.objectContaining({ msg_type: "media", reply_in_thread: true }), + }), + ); + }); + + it("omits reply_in_thread when replyInThread is false", async () => { + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaBuffer: Buffer.from("video"), + fileName: "reply.mp4", + replyToMessageId: "om_parent", + replyInThread: false, + }); + + const callData = messageReplyMock.mock.calls[0][0].data; + expect(callData).not.toHaveProperty("reply_in_thread"); + }); + it("passes mediaLocalRoots as localRoots to loadWebMedia for local paths (#27884)", async () => { loadWebMediaMock.mockResolvedValue({ buffer: Buffer.from("local-file"), diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 62dfd9551c8..d44604d8a9c 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -265,9 +265,10 @@ export async function sendImageFeishu(params: { to: string; imageKey: string; replyToMessageId?: string; + replyInThread?: boolean; accountId?: string; }): Promise { - const { cfg, to, imageKey, replyToMessageId, accountId } = params; + const { cfg, to, imageKey, replyToMessageId, replyInThread, accountId } = params; const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, @@ -281,6 +282,7 @@ export async function sendImageFeishu(params: { data: { content, msg_type: "image", + ...(replyInThread ? { reply_in_thread: true } : {}), }, }); assertFeishuMessageApiSuccess(response, "Feishu image reply failed"); @@ -309,9 +311,10 @@ export async function sendFileFeishu(params: { /** Use "media" for video, "audio" for audio, "file" for documents */ msgType?: "file" | "media" | "audio"; replyToMessageId?: string; + replyInThread?: boolean; accountId?: string; }): Promise { - const { cfg, to, fileKey, replyToMessageId, accountId } = params; + const { cfg, to, fileKey, replyToMessageId, replyInThread, accountId } = params; const msgType = params.msgType ?? "file"; const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, @@ -326,6 +329,7 @@ export async function sendFileFeishu(params: { data: { content, msg_type: msgType, + ...(replyInThread ? { reply_in_thread: true } : {}), }, }); assertFeishuMessageApiSuccess(response, "Feishu file reply failed"); @@ -387,12 +391,22 @@ export async function sendMediaFeishu(params: { mediaBuffer?: Buffer; fileName?: string; replyToMessageId?: string; + replyInThread?: boolean; accountId?: string; /** Allowed roots for local path reads; required for local filePath to work. */ mediaLocalRoots?: readonly string[]; }): Promise { - const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId, mediaLocalRoots } = - params; + const { + cfg, + to, + mediaUrl, + mediaBuffer, + fileName, + replyToMessageId, + replyInThread, + accountId, + mediaLocalRoots, + } = params; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); @@ -423,7 +437,7 @@ export async function sendMediaFeishu(params: { if (isImage) { const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId }); - return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId }); + return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId }); } else { const fileType = detectFileType(name); const { fileKey } = await uploadFileFeishu({ @@ -441,6 +455,7 @@ export async function sendMediaFeishu(params: { fileKey, msgType, replyToMessageId, + replyInThread, accountId, }); } diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 43cbdc23333..55834a8ab0d 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -186,4 +186,98 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }), ); }); + + it("passes replyInThread to sendMessageFeishu for plain text", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + replyToMessageId: "om_msg", + replyInThread: true, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ text: "plain text" }, { kind: "final" }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_msg", + replyInThread: true, + }), + ); + }); + + it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => { + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "card", + streaming: false, + }, + }); + + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + replyToMessageId: "om_msg", + replyInThread: true, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ text: "card text" }, { kind: "final" }); + + expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_msg", + replyInThread: true, + }), + ); + }); + + it("passes replyToMessageId and replyInThread to streaming.start()", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: vi.fn() } as never, + chatId: "oc_chat", + replyToMessageId: "om_msg", + replyInThread: true, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", { + replyToMessageId: "om_msg", + replyInThread: true, + }); + }); + + it("passes replyInThread to media attachments", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + replyToMessageId: "om_msg", + replyInThread: true, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); + + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_msg", + replyInThread: true, + }), + ); + }); }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 9991779e939..9cf836be267 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -28,13 +28,15 @@ export type CreateFeishuReplyDispatcherParams = { runtime: RuntimeEnv; chatId: string; replyToMessageId?: string; + replyInThread?: boolean; mentionTargets?: MentionTarget[]; accountId?: string; }; export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) { const core = getFeishuRuntime(); - const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params; + const { cfg, agentId, chatId, replyToMessageId, replyInThread, mentionTargets, accountId } = + params; const account = resolveFeishuAccount({ cfg, accountId }); const prefixContext = createReplyPrefixContext({ cfg, agentId }); @@ -100,7 +102,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP params.runtime.log?.(`feishu[${account.accountId}] ${message}`), ); try { - await streaming.start(chatId, resolveReceiveIdType(chatId)); + await streaming.start(chatId, resolveReceiveIdType(chatId), { + replyToMessageId, + replyInThread, + }); } catch (error) { params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`); streaming = null; @@ -170,7 +175,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP // Send media even when streaming handled the text if (hasMedia) { for (const mediaUrl of mediaList) { - await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId }); + await sendMediaFeishu({ + cfg, + to: chatId, + mediaUrl, + replyToMessageId, + replyInThread, + accountId, + }); } } return; @@ -188,6 +200,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP to: chatId, text: chunk, replyToMessageId, + replyInThread, mentions: first ? mentionTargets : undefined, accountId, }); @@ -205,6 +218,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP to: chatId, text: chunk, replyToMessageId, + replyInThread, mentions: first ? mentionTargets : undefined, accountId, }); @@ -215,7 +229,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (hasMedia) { for (const mediaUrl of mediaList) { - await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId }); + await sendMediaFeishu({ + cfg, + to: chatId, + mediaUrl, + replyToMessageId, + replyInThread, + accountId, + }); } } }, diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 341ff3ed64d..fefc698c916 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -96,6 +96,8 @@ export type SendFeishuMessageParams = { to: string; text: string; replyToMessageId?: string; + /** When true, reply creates a Feishu topic thread instead of an inline reply */ + replyInThread?: boolean; /** Mention target users */ mentions?: MentionTarget[]; /** Account ID (optional, uses default if not specified) */ @@ -127,7 +129,7 @@ function buildFeishuPostMessagePayload(params: { messageText: string }): { export async function sendMessageFeishu( params: SendFeishuMessageParams, ): Promise { - const { cfg, to, text, replyToMessageId, mentions, accountId } = params; + const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params; const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({ cfg, @@ -149,6 +151,7 @@ export async function sendMessageFeishu( data: { content, msg_type: msgType, + ...(replyInThread ? { reply_in_thread: true } : {}), }, }); assertFeishuMessageApiSuccess(response, "Feishu reply failed"); @@ -172,11 +175,13 @@ export type SendFeishuCardParams = { to: string; card: Record; replyToMessageId?: string; + /** When true, reply creates a Feishu topic thread instead of an inline reply */ + replyInThread?: boolean; accountId?: string; }; export async function sendCardFeishu(params: SendFeishuCardParams): Promise { - const { cfg, to, card, replyToMessageId, accountId } = params; + const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params; const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId }); const content = JSON.stringify(card); @@ -186,6 +191,7 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise { - const { cfg, to, text, replyToMessageId, mentions, accountId } = params; - // Build message content (with @mention support) + const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params; let cardText = text; if (mentions && mentions.length > 0) { cardText = buildMentionedCardContent(mentions, text); } const card = buildMarkdownCard(cardText); - return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId }); + return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId }); } /** diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 56f1fc36557..6eb72135c12 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -3,6 +3,7 @@ */ import type { Client } from "@larksuiteoapi/node-sdk"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { FeishuDomain } from "./types.js"; type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain }; @@ -21,6 +22,20 @@ function resolveApiBase(domain?: FeishuDomain): string { return "https://open.feishu.cn/open-apis"; } +function resolveAllowedHostnames(domain?: FeishuDomain): string[] { + if (domain === "lark") { + return ["open.larksuite.com"]; + } + if (domain && domain !== "feishu" && domain.startsWith("http")) { + try { + return [new URL(domain).hostname]; + } catch { + return []; + } + } + return ["open.feishu.cn"]; +} + async function getToken(creds: Credentials): Promise { const key = `${creds.domain ?? "feishu"}|${creds.appId}`; const cached = tokenCache.get(key); @@ -28,17 +43,23 @@ async function getToken(creds: Credentials): Promise { return cached.token; } - const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }), + const { response, release } = await fetchWithSsrFGuard({ + url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }), + }, + policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) }, + auditContext: "feishu.streaming-card.token", }); - const data = (await res.json()) as { + const data = (await response.json()) as { code: number; msg: string; tenant_access_token?: string; expire?: number; }; + await release(); if (data.code !== 0 || !data.tenant_access_token) { throw new Error(`Token error: ${data.msg}`); } @@ -78,6 +99,7 @@ export class FeishuStreamingSession { async start( receiveId: string, receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", + options?: { replyToMessageId?: string; replyInThread?: boolean }, ): Promise { if (this.state) { return; @@ -97,33 +119,52 @@ export class FeishuStreamingSession { }; // Create card entity - const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, { - method: "POST", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", + const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({ + url: `${apiBase}/cardkit/v1/cards`, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${await getToken(this.creds)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }), }, - body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }), + policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + auditContext: "feishu.streaming-card.create", }); const createData = (await createRes.json()) as { code: number; msg: string; data?: { card_id: string }; }; + await releaseCreate(); if (createData.code !== 0 || !createData.data?.card_id) { throw new Error(`Create card failed: ${createData.msg}`); } const cardId = createData.data.card_id; + const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } }); - // Send card message - const sendRes = await this.client.im.message.create({ - params: { receive_id_type: receiveIdType }, - data: { - receive_id: receiveId, - msg_type: "interactive", - content: JSON.stringify({ type: "card", data: { card_id: cardId } }), - }, - }); + // Send card message — reply into thread when configured + let sendRes; + if (options?.replyToMessageId) { + sendRes = await this.client.im.message.reply({ + path: { message_id: options.replyToMessageId }, + data: { + msg_type: "interactive", + content: cardContent, + ...(options.replyInThread ? { reply_in_thread: true } : {}), + }, + }); + } else { + sendRes = await this.client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + msg_type: "interactive", + content: cardContent, + }, + }); + } if (sendRes.code !== 0 || !sendRes.data?.message_id) { throw new Error(`Send card failed: ${sendRes.msg}`); } @@ -138,18 +179,27 @@ export class FeishuStreamingSession { } const apiBase = resolveApiBase(this.creds.domain); this.state.sequence += 1; - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { - method: "PUT", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json", + await fetchWithSsrFGuard({ + url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, + init: { + method: "PUT", + headers: { + Authorization: `Bearer ${await getToken(this.creds)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: text, + sequence: this.state.sequence, + uuid: `s_${this.state.cardId}_${this.state.sequence}`, + }), }, - body: JSON.stringify({ - content: text, - sequence: this.state.sequence, - uuid: `s_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch((error) => onError?.(error)); + policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + auditContext: "feishu.streaming-card.update", + }) + .then(async ({ release }) => { + await release(); + }) + .catch((error) => onError?.(error)); } async update(text: string): Promise { @@ -194,20 +244,29 @@ export class FeishuStreamingSession { // Close streaming mode this.state.sequence += 1; - await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${await getToken(this.creds)}`, - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - settings: JSON.stringify({ - config: { streaming_mode: false, summary: { content: truncateSummary(text) } }, + await fetchWithSsrFGuard({ + url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, + init: { + method: "PATCH", + headers: { + Authorization: `Bearer ${await getToken(this.creds)}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + settings: JSON.stringify({ + config: { streaming_mode: false, summary: { content: truncateSummary(text) } }, + }), + sequence: this.state.sequence, + uuid: `c_${this.state.cardId}_${this.state.sequence}`, }), - sequence: this.state.sequence, - uuid: `c_${this.state.cardId}_${this.state.sequence}`, - }), - }).catch((e) => this.log?.(`Close failed: ${String(e)}`)); + }, + policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, + auditContext: "feishu.streaming-card.close", + }) + .then(async ({ release }) => { + await release(); + }) + .catch((e) => this.log?.(`Close failed: ${String(e)}`)); this.log?.(`Closed streaming: cardId=${this.state.cardId}`); }