From 77d04a39d87ab5d45d1b1e5e0e1d8457acac1045 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 00:56:43 +0100 Subject: [PATCH] fix(feishu): separate synthetic ids from reply targets --- CHANGELOG.md | 3 ++ extensions/feishu/src/bot.ts | 8 +++- extensions/feishu/src/card-action.ts | 5 ++ extensions/feishu/src/event-types.ts | 2 + extensions/feishu/src/monitor.account.ts | 3 ++ .../feishu/src/monitor.bot-menu-handler.ts | 1 + ...monitor.bot-menu.lifecycle.test-support.ts | 2 +- ...itor.card-action.lifecycle.test-support.ts | 46 +++++++++++++++++-- extensions/feishu/src/types.ts | 2 + 9 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d4f991ca4..9170e17cd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,9 @@ Docs: https://docs.openclaw.ai - Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068. +- Feishu: keep synthetic card-action and bot-menu ids out of platform reply + targets, using the real card callback message id when Feishu provides one and + plain-sending otherwise. Fixes #71673. Thanks @eddy1068. - QQ Bot: make `qqbot_remind` schedule, list, and remove Gateway cron jobs directly for owner-authorized senders instead of returning `cronParams` and relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937) diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index dc899d62f99..4d1fad5ee21 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -122,6 +122,8 @@ export function parseFeishuMessageEvent( const ctx: FeishuMessageContext = { chatId: event.message.chat_id, messageId: event.message.message_id, + replyTargetMessageId: event.message.reply_target_message_id?.trim() || undefined, + suppressReplyTarget: event.message.suppress_reply_target === true, senderId: senderUserId || senderOpenId || "", // Keep the historical field name, but fall back to user_id when open_id is unavailable // (common in some mobile app deliveries). @@ -1037,7 +1039,11 @@ export async function handleFeishuMessage(params: { isGroup && (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled"; const replyTargetMessageId = - isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId; + isTopicSession || configReplyInThread + ? (ctx.rootId ?? + ctx.replyTargetMessageId ?? + (ctx.suppressReplyTarget ? undefined : ctx.messageId)) + : (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId)); const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false; if (broadcastAgents) { diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index cff6b89b9f4..acc30412721 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -22,7 +22,9 @@ export type FeishuCardActionEvent = { value: Record; tag: string; }; + open_message_id?: string; context: { + open_message_id?: string; open_id?: string; user_id?: string; chat_id?: string; @@ -107,6 +109,7 @@ function buildSyntheticMessageEvent( content: string, chatType: "p2p" | "group", ): FeishuMessageEvent { + const replyTargetMessageId = event.context.open_message_id ?? event.open_message_id; return { sender: { sender_id: { @@ -117,6 +120,8 @@ function buildSyntheticMessageEvent( }, message: { message_id: `card-action-${event.token}`, + ...(replyTargetMessageId ? { reply_target_message_id: replyTargetMessageId } : {}), + ...(!replyTargetMessageId ? { suppress_reply_target: true } : {}), chat_id: event.context.chat_id || event.operator.open_id, chat_type: chatType, message_type: "text", diff --git a/extensions/feishu/src/event-types.ts b/extensions/feishu/src/event-types.ts index ec63f47daa6..c806335b315 100644 --- a/extensions/feishu/src/event-types.ts +++ b/extensions/feishu/src/event-types.ts @@ -10,6 +10,8 @@ export type FeishuMessageEvent = { }; message: { message_id: string; + reply_target_message_id?: string; + suppress_reply_target?: boolean; root_id?: string; parent_id?: string; thread_id?: string; diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index f08645f7a9d..80b3ff86db7 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -210,6 +210,7 @@ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEven const unionId = firstString(operator.union_id); const tag = readString(action.tag); const actionValue = action.value; + const openMessageId = firstString(value.open_message_id, context.open_message_id); const contextOpenId = firstString(context.open_id, openId); const contextUserId = firstString(context.user_id, userId); const chatId = firstString(context.chat_id, context.open_chat_id); @@ -227,7 +228,9 @@ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEven value: actionValue, tag, }, + ...(openMessageId ? { open_message_id: openMessageId } : {}), context: { + ...(openMessageId ? { open_message_id: openMessageId } : {}), ...(contextOpenId ? { open_id: contextOpenId } : {}), ...(contextUserId ? { user_id: contextUserId } : {}), ...(chatId ? { chat_id: chatId } : {}), diff --git a/extensions/feishu/src/monitor.bot-menu-handler.ts b/extensions/feishu/src/monitor.bot-menu-handler.ts index ce536beb230..ac45f0fc52b 100644 --- a/extensions/feishu/src/monitor.bot-menu-handler.ts +++ b/extensions/feishu/src/monitor.bot-menu-handler.ts @@ -93,6 +93,7 @@ export function createFeishuBotMenuHandler(params: { }, message: { message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`, + suppress_reply_target: true, chat_id: `p2p:${operatorOpenId}`, chat_type: "p2p", message_type: "text", diff --git a/extensions/feishu/src/monitor.bot-menu.lifecycle.test-support.ts b/extensions/feishu/src/monitor.bot-menu.lifecycle.test-support.ts index 22b03534496..c1f7aadb585 100644 --- a/extensions/feishu/src/monitor.bot-menu.lifecycle.test-support.ts +++ b/extensions/feishu/src/monitor.bot-menu.lifecycle.test-support.ts @@ -179,7 +179,7 @@ describe("Feishu bot-menu lifecycle", () => { expect.objectContaining({ accountId: "acct-menu", chatId: "p2p:ou_user1", - replyToMessageId: "bot-menu:quick-actions:1700000000001", + replyToMessageId: undefined, }), ); expect(finalizeInboundContextMock).toHaveBeenCalledWith( diff --git a/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts b/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts index 4d14f81bd45..f3b34d4378d 100644 --- a/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts +++ b/extensions/feishu/src/monitor.card-action.lifecycle.test-support.ts @@ -181,7 +181,7 @@ describe("Feishu card-action lifecycle", () => { expect.objectContaining({ accountId: "acct-card", chatId: "p2p:ou_user1", - replyToMessageId: "card-action-tok-card-once", + replyToMessageId: undefined, }), ); expect(finalizeInboundContextMock).toHaveBeenCalledWith( @@ -233,7 +233,12 @@ describe("Feishu card-action lifecycle", () => { expect.objectContaining({ accountId: "acct-card", chatId, - replyToMessageId: "card-action-tok-card-v2-context", + replyToMessageId: "om_card_v2", + }), + ); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + MessageSid: "card-action-tok-card-v2-context", }), ); }); @@ -261,7 +266,42 @@ describe("Feishu card-action lifecycle", () => { expect.objectContaining({ accountId: "acct-card", chatId: "ou_user1", - replyToMessageId: "card-action-tok-card-sdk-flat", + replyToMessageId: "om_sdk_card", + }), + ); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + MessageSid: "card-action-tok-card-sdk-flat", + }), + ); + }); + + it("plain-sends card action replies when Feishu provides no real message id", async () => { + const onCardAction = await setupLifecycleMonitor(); + + await onCardAction({ + open_id: "ou_user1", + token: "tok-card-no-reply-target", + action: { + tag: "button", + value: { + command: "/help", + }, + }, + }); + + expect(lastRuntime?.error).not.toHaveBeenCalled(); + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-card", + chatId: "ou_user1", + replyToMessageId: undefined, + }), + ); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + MessageSid: "card-action-tok-card-no-reply-target", }), ); }); diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index 3ffbcb9c102..595c7380486 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -40,6 +40,8 @@ export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id"; export type FeishuMessageContext = { chatId: string; messageId: string; + replyTargetMessageId?: string; + suppressReplyTarget?: boolean; senderId: string; senderOpenId: string; senderName?: string;