From 63ce7c74bdc08f264e636d13923c7cfc16c3110b Mon Sep 17 00:00:00 2001 From: Madoka Date: Thu, 5 Mar 2026 10:32:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(feishu):=20comprehensive=20reply=20mechanis?= =?UTF-8?q?m=20=E2=80=94=20outbound=20replyToId=20forwarding=20+=20topic-a?= =?UTF-8?q?ware=20reply=20targeting=20(#33789)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(feishu): comprehensive reply mechanism fix — outbound replyToId forwarding + topic-aware reply targeting - Forward replyToId from ChannelOutboundContext through sendText/sendMedia to sendMessageFeishu/sendMarkdownCardFeishu/sendMediaFeishu, enabling reply-to-message via the message tool. - Fix group reply targeting: use ctx.messageId (triggering message) in normal groups to prevent silent topic thread creation (#32980). Preserve ctx.rootId targeting for topic-mode groups (group_topic/group_topic_sender) and groups with explicit replyInThread config. - Add regression tests for both fixes. Fixes #32980 Fixes #32958 Related #19784 * fix: normalize Feishu delivery.to before comparing with messaging tool targets - Add normalizeDeliveryTarget helper to strip user:/chat: prefixes for Feishu - Apply normalization in matchesMessagingToolDeliveryTarget before comparison - This ensures cron duplicate suppression works when session uses prefixed targets (user:ou_xxx) but messaging tool extract uses normalized bare IDs (ou_xxx) Fixes review comment on PR #32755 (cherry picked from commit fc20106f16ccc88a5f02e58922bb7b7999fe9dcd) * fix(feishu): catch thrown SDK errors for withdrawn reply targets The Feishu Lark SDK can throw exceptions (SDK errors with .code or AxiosErrors with .response.data.code) for withdrawn/deleted reply targets, in addition to returning error codes in the response object. Wrap reply calls in sendMessageFeishu and sendCardFeishu with try-catch to handle thrown withdrawn/not-found errors (230011, 231003) and fall back to client.im.message.create, matching the existing response-level fallback behavior. Also extract sendFallbackDirect helper to deduplicate the direct-send fallback block across both functions. Closes #33496 (cherry picked from commit ad0901aec103a2c52f186686cfaf5f8ba54b4a48) * feishu: forward outbound reply target context (cherry picked from commit c129a691fcf552a1cebe1e8a22ea8611ffc3b377) * feishu extension: tighten reply target fallback semantics (cherry picked from commit f85ec610f267020b66713c09e648ec004b2e26f1) * fix(feishu): align synthesized fallback typing and changelog attribution * test(feishu): cover group_topic_sender reply targeting --------- Co-authored-by: Xu Zimo Co-authored-by: Munem Hashmi Co-authored-by: bmendonca3 Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- .../fragments/pr-feishu-reply-mechanism.md | 1 + extensions/feishu/src/bot.test.ts | 114 +++++++++++ extensions/feishu/src/bot.ts | 18 +- extensions/feishu/src/outbound.test.ts | 178 ++++++++++++++++++ extensions/feishu/src/outbound.ts | 43 ++++- .../feishu/src/send.reply-fallback.test.ts | 74 ++++++++ extensions/feishu/src/send.ts | 135 ++++++++----- src/cron/isolated-agent/delivery-dispatch.ts | 25 ++- 8 files changed, 529 insertions(+), 59 deletions(-) create mode 100644 changelog/fragments/pr-feishu-reply-mechanism.md diff --git a/changelog/fragments/pr-feishu-reply-mechanism.md b/changelog/fragments/pr-feishu-reply-mechanism.md new file mode 100644 index 00000000000..f19716c4c7d --- /dev/null +++ b/changelog/fragments/pr-feishu-reply-mechanism.md @@ -0,0 +1 @@ +- Feishu reply routing now uses one canonical reply-target path across inbound and outbound flows: normal groups reply to the triggering message while topic-mode groups stay on topic roots, outbound sends preserve `replyToId`/`threadId`, withdrawn reply targets fall back to direct sends, and cron duplicate suppression normalizes Feishu/Lark target IDs consistently (#32980, #32958, #33572, #33526; #33789, #33575, #33515, #33161). Thanks @guoqunabc, @bmendonca3, @MunemHashmi, and @Jimmy-xuzimo. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 9b36e922526..2dfbb6ffae3 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1517,6 +1517,120 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("replies to triggering message in normal group even when root_id is present (#32980)", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group", + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-normal-user" } }, + message: { + message_id: "om_quote_reply", + root_id: "om_original_msg", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello in normal group" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_quote_reply", + rootId: "om_original_msg", + }), + ); + }); + + it("replies to topic root in topic-mode group with root_id", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_topic", + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-user" } }, + message: { + message_id: "om_topic_reply", + root_id: "om_topic_root", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello in topic group" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_topic_root", + rootId: "om_topic_root", + }), + ); + }); + + it("replies to topic root in topic-sender group with root_id", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_topic_sender", + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-sender-user" } }, + message: { + message_id: "om_topic_sender_reply", + root_id: "om_topic_sender_root", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello in topic sender group" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_topic_sender_root", + rootId: "om_topic_sender_root", + }), + ); + }); + it("forces thread replies when inbound message contains thread_id", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index d97fcd4cf6b..447c951963a 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1337,7 +1337,23 @@ export async function handleFeishuMessage(params: { const messageCreateTimeMs = event.message.create_time ? parseInt(event.message.create_time, 10) : undefined; - const replyTargetMessageId = ctx.rootId ?? ctx.messageId; + // Determine reply target based on group session mode: + // - Topic-mode groups (group_topic / group_topic_sender): reply to the topic + // root so the bot stays in the same thread. + // - Groups with explicit replyInThread config: reply to the root so the bot + // stays in the thread the user expects. + // - Normal groups (auto-detected threadReply from root_id): reply to the + // triggering message itself. Using rootId here would silently push the + // reply into a topic thread invisible in the main chat view (#32980). + const isTopicSession = + isGroup && + (groupSession?.groupSessionScope === "group_topic" || + groupSession?.groupSessionScope === "group_topic_sender"); + const configReplyInThread = + isGroup && + (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled"; + const replyTargetMessageId = + isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId; const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false; if (broadcastAgents) { diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 69377215603..bed44df77a6 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -136,6 +136,156 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "card_msg" })); }); + + it("forwards replyToId as replyToMessageId on sendText", async () => { + await sendText({ + cfg: {} as any, + to: "chat_1", + text: "hello", + replyToId: "om_reply_1", + accountId: "main", + } as any); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: "hello", + replyToMessageId: "om_reply_1", + accountId: "main", + }), + ); + }); + + it("falls back to threadId when replyToId is empty on sendText", async () => { + await sendText({ + cfg: {} as any, + to: "chat_1", + text: "hello", + replyToId: " ", + threadId: "om_thread_2", + accountId: "main", + } as any); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: "hello", + replyToMessageId: "om_thread_2", + accountId: "main", + }), + ); + }); +}); + +describe("feishuOutbound.sendText replyToId forwarding", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); + sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); + sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + }); + + it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => { + await sendText({ + cfg: {} as any, + to: "chat_1", + text: "hello", + replyToId: "om_reply_target", + accountId: "main", + }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: "hello", + replyToMessageId: "om_reply_target", + accountId: "main", + }), + ); + }); + + it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => { + await sendText({ + cfg: { + channels: { + feishu: { + renderMode: "card", + }, + }, + } as any, + to: "chat_1", + text: "```code```", + replyToId: "om_reply_target", + accountId: "main", + }); + + expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_reply_target", + }), + ); + }); + + it("does not pass replyToMessageId when replyToId is absent", async () => { + await sendText({ + cfg: {} as any, + to: "chat_1", + text: "hello", + accountId: "main", + }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: "hello", + accountId: "main", + }), + ); + expect(sendMessageFeishuMock.mock.calls[0][0].replyToMessageId).toBeUndefined(); + }); +}); + +describe("feishuOutbound.sendMedia replyToId forwarding", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); + sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); + sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + }); + + it("forwards replyToId to sendMediaFeishu", async () => { + await feishuOutbound.sendMedia?.({ + cfg: {} as any, + to: "chat_1", + text: "", + mediaUrl: "https://example.com/image.png", + replyToId: "om_reply_target", + accountId: "main", + }); + + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_reply_target", + }), + ); + }); + + it("forwards replyToId to text caption send", async () => { + await feishuOutbound.sendMedia?.({ + cfg: {} as any, + to: "chat_1", + text: "caption text", + mediaUrl: "https://example.com/image.png", + replyToId: "om_reply_target", + accountId: "main", + }); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_reply_target", + }), + ); + }); }); describe("feishuOutbound.sendMedia renderMode", () => { @@ -178,4 +328,32 @@ describe("feishuOutbound.sendMedia renderMode", () => { expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "media_msg" })); }); + + it("uses threadId fallback as replyToMessageId on sendMedia", async () => { + await feishuOutbound.sendMedia?.({ + cfg: {} as any, + to: "chat_1", + text: "caption", + mediaUrl: "https://example.com/image.png", + threadId: "om_thread_1", + accountId: "main", + } as any); + + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + mediaUrl: "https://example.com/image.png", + replyToMessageId: "om_thread_1", + accountId: "main", + }), + ); + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: "caption", + replyToMessageId: "om_thread_1", + accountId: "main", + }), + ); + }); }); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index ab4037fcae0..955777676ef 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -43,21 +43,37 @@ function shouldUseCard(text: string): boolean { return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text); } +function resolveReplyToMessageId(params: { + replyToId?: string | null; + threadId?: string | number | null; +}): string | undefined { + const replyToId = params.replyToId?.trim(); + if (replyToId) { + return replyToId; + } + if (params.threadId == null) { + return undefined; + } + const trimmed = String(params.threadId).trim(); + return trimmed || undefined; +} + async function sendOutboundText(params: { cfg: Parameters[0]["cfg"]; to: string; text: string; + replyToMessageId?: string; accountId?: string; }) { - const { cfg, to, text, accountId } = params; + const { cfg, to, text, accountId, replyToMessageId } = params; const account = resolveFeishuAccount({ cfg, accountId }); const renderMode = account.config?.renderMode ?? "auto"; if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) { - return sendMarkdownCardFeishu({ cfg, to, text, accountId }); + return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId }); } - return sendMessageFeishu({ cfg, to, text, accountId }); + return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId }); } export const feishuOutbound: ChannelOutboundAdapter = { @@ -65,7 +81,8 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId }) => { + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); // Scheme A compatibility shim: // when upstream accidentally returns a local image path as plain text, // auto-upload and send as Feishu image message instead of leaking path text. @@ -77,6 +94,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { to, mediaUrl: localImagePath, accountId: accountId ?? undefined, + replyToMessageId, }); return { channel: "feishu", ...result }; } catch (err) { @@ -90,10 +108,21 @@ export const feishuOutbound: ChannelOutboundAdapter = { to, text, accountId: accountId ?? undefined, + replyToMessageId, }); return { channel: "feishu", ...result }; }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => { + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + accountId, + mediaLocalRoots, + replyToId, + threadId, + }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); // Send text first if provided if (text?.trim()) { await sendOutboundText({ @@ -101,6 +130,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { to, text, accountId: accountId ?? undefined, + replyToMessageId, }); } @@ -113,6 +143,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { mediaUrl, accountId: accountId ?? undefined, mediaLocalRoots, + replyToMessageId, }); return { channel: "feishu", ...result }; } catch (err) { @@ -125,6 +156,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { to, text: fallbackText, accountId: accountId ?? undefined, + replyToMessageId, }); return { channel: "feishu", ...result }; } @@ -136,6 +168,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { to, text: text ?? "", accountId: accountId ?? undefined, + replyToMessageId, }); return { channel: "feishu", ...result }; }, diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts index 182cb3c4be9..75dda353bbe 100644 --- a/extensions/feishu/src/send.reply-fallback.test.ts +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -102,4 +102,78 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { expect(createMock).not.toHaveBeenCalled(); }); + + it("falls back to create when reply throws a withdrawn SDK error", async () => { + const sdkError = Object.assign(new Error("request failed"), { code: 230011 }); + replyMock.mockRejectedValue(sdkError); + createMock.mockResolvedValue({ + code: 0, + data: { message_id: "om_thrown_fallback" }, + }); + + const result = await sendMessageFeishu({ + cfg: {} as never, + to: "user:ou_target", + text: "hello", + replyToMessageId: "om_parent", + }); + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(createMock).toHaveBeenCalledTimes(1); + expect(result.messageId).toBe("om_thrown_fallback"); + }); + + it("falls back to create when card reply throws a not-found AxiosError", async () => { + const axiosError = Object.assign(new Error("Request failed"), { + response: { status: 200, data: { code: 231003, msg: "The message is not found" } }, + }); + replyMock.mockRejectedValue(axiosError); + createMock.mockResolvedValue({ + code: 0, + data: { message_id: "om_axios_fallback" }, + }); + + const result = await sendCardFeishu({ + cfg: {} as never, + to: "user:ou_target", + card: { schema: "2.0" }, + replyToMessageId: "om_parent", + }); + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(createMock).toHaveBeenCalledTimes(1); + expect(result.messageId).toBe("om_axios_fallback"); + }); + + it("re-throws non-withdrawn thrown errors for text messages", async () => { + const sdkError = Object.assign(new Error("rate limited"), { code: 99991400 }); + replyMock.mockRejectedValue(sdkError); + + await expect( + sendMessageFeishu({ + cfg: {} as never, + to: "user:ou_target", + text: "hello", + replyToMessageId: "om_parent", + }), + ).rejects.toThrow("rate limited"); + + expect(createMock).not.toHaveBeenCalled(); + }); + + it("re-throws non-withdrawn thrown errors for card messages", async () => { + const sdkError = Object.assign(new Error("permission denied"), { code: 99991401 }); + replyMock.mockRejectedValue(sdkError); + + await expect( + sendCardFeishu({ + cfg: {} as never, + to: "user:ou_target", + card: { schema: "2.0" }, + replyToMessageId: "om_parent", + }), + ).rejects.toThrow("permission denied"); + + expect(createMock).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index e637cf13810..928ef07f949 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -19,6 +19,61 @@ function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string } return msg.includes("withdrawn") || msg.includes("not found"); } +/** Check whether a thrown error indicates a withdrawn/not-found reply target. */ +function isWithdrawnReplyError(err: unknown): boolean { + if (typeof err !== "object" || err === null) { + return false; + } + // SDK error shape: err.code + const code = (err as { code?: number }).code; + if (typeof code === "number" && WITHDRAWN_REPLY_ERROR_CODES.has(code)) { + return true; + } + // AxiosError shape: err.response.data.code + const response = (err as { response?: { data?: { code?: number; msg?: string } } }).response; + if ( + typeof response?.data?.code === "number" && + WITHDRAWN_REPLY_ERROR_CODES.has(response.data.code) + ) { + return true; + } + return false; +} + +type FeishuCreateMessageClient = { + im: { + message: { + create: (opts: { + params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" }; + data: { receive_id: string; content: string; msg_type: string }; + }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>; + }; + }; +}; + +/** Send a direct message as a fallback when a reply target is unavailable. */ +async function sendFallbackDirect( + client: FeishuCreateMessageClient, + params: { + receiveId: string; + receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id"; + content: string; + msgType: string; + }, + errorPrefix: string, +): Promise { + const response = await client.im.message.create({ + params: { receive_id_type: params.receiveIdType }, + data: { + receive_id: params.receiveId, + content: params.content, + msg_type: params.msgType, + }, + }); + assertFeishuMessageApiSuccess(response, errorPrefix); + return toFeishuSendResult(response, params.receiveId); +} + export type FeishuMessageInfo = { messageId: string; chatId: string; @@ -239,41 +294,33 @@ export async function sendMessageFeishu( const { content, msgType } = buildFeishuPostMessagePayload({ messageText }); + const directParams = { receiveId, receiveIdType, content, msgType }; + if (replyToMessageId) { - const response = await client.im.message.reply({ - path: { message_id: replyToMessageId }, - data: { - content, - msg_type: msgType, - ...(replyInThread ? { reply_in_thread: true } : {}), - }, - }); - if (shouldFallbackFromReplyTarget(response)) { - const fallback = await client.im.message.create({ - params: { receive_id_type: receiveIdType }, + let response: { code?: number; msg?: string; data?: { message_id?: string } }; + try { + response = await client.im.message.reply({ + path: { message_id: replyToMessageId }, data: { - receive_id: receiveId, content, msg_type: msgType, + ...(replyInThread ? { reply_in_thread: true } : {}), }, }); - assertFeishuMessageApiSuccess(fallback, "Feishu send failed"); - return toFeishuSendResult(fallback, receiveId); + } catch (err) { + if (!isWithdrawnReplyError(err)) { + throw err; + } + return sendFallbackDirect(client, directParams, "Feishu send failed"); + } + if (shouldFallbackFromReplyTarget(response)) { + return sendFallbackDirect(client, directParams, "Feishu send failed"); } assertFeishuMessageApiSuccess(response, "Feishu reply failed"); return toFeishuSendResult(response, receiveId); } - const response = await client.im.message.create({ - params: { receive_id_type: receiveIdType }, - data: { - receive_id: receiveId, - content, - msg_type: msgType, - }, - }); - assertFeishuMessageApiSuccess(response, "Feishu send failed"); - return toFeishuSendResult(response, receiveId); + return sendFallbackDirect(client, directParams, "Feishu send failed"); } export type SendFeishuCardParams = { @@ -291,41 +338,33 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise