From 8cc762daff13330810be0ad0f74dd50ccc8fed02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 6 May 2026 07:30:27 +0100 Subject: [PATCH] fix(feishu): keep topic sessions stable Fixes Feishu native topic starter routing by hydrating a missing topic thread ID before session resolution.\n\nCloses #78262. --- CHANGELOG.md | 1 + docs/channels/feishu.md | 7 ++-- extensions/feishu/src/bot.test.ts | 69 +++++++++++++++++++++++++++++++ extensions/feishu/src/bot.ts | 57 ++++++++++++++++++++++++- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 979c9cf7490..b538fe8782a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: hydrate missing native topic starter thread IDs before session routing so first turns and follow-ups stay in the same topic session. Fixes #78262. Thanks @joeyzenghuan. - Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`. - Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models. - Matrix/approvals: retry approval delivery up to 3 times with a short backoff so transient Matrix send failures do not strand pending approval prompts. (#78179) Thanks @Patrick-Erichsen. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 0b01918baf1..8620c8e673b 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -479,9 +479,10 @@ conversion fails, OpenClaw falls back to a file attachment and logs the reason. For `groupSessionScope: "group_topic"` and `"group_topic_sender"`, native Feishu/Lark topic groups use the event `thread_id` (`omt_*`) as the canonical -topic session key. Normal group replies that OpenClaw turns into threads keep -using the reply root message ID (`om_*`) so the first turn and follow-up turn -stay in the same session. +topic session key. If a native topic starter event omits `thread_id`, OpenClaw +hydrates it from Feishu before routing the turn. Normal group replies that +OpenClaw turns into threads keep using the reply root message ID (`om_*`) so the +first turn and follow-up turn stay in the same session. --- diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index e195bf0a51e..396c1e8dd3a 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -2514,6 +2514,75 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("hydrates missing native topic thread_id before routing starter events", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + mockGetMessageFeishu.mockResolvedValueOnce({ + messageId: "msg-native-topic-first", + chatId: "oc-group", + chatType: "topic_group", + content: "topic starter", + contentType: "text", + threadId: "omt_native_topic", + }); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_topic", + replyInThread: "enabled", + }, + }, + }, + }, + } as ClawdbotConfig; + + const firstTurn: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-init" } }, + message: { + message_id: "msg-native-topic-first", + chat_id: "oc-group", + chat_type: "topic_group", + message_type: "text", + content: JSON.stringify({ text: "create native topic" }), + }, + }; + const secondTurn: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-init" } }, + message: { + message_id: "msg-native-topic-second", + chat_id: "oc-group", + chat_type: "topic_group", + thread_id: "omt_native_topic", + message_type: "text", + content: JSON.stringify({ text: "follow up in same native topic" }), + }, + }; + + await dispatchMessage({ cfg, event: firstTurn }); + await dispatchMessage({ cfg, event: secondTurn }); + + expect(mockGetMessageFeishu).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-native-topic-first", + }), + ); + expect(mockResolveAgentRoute).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:omt_native_topic" }, + }), + ); + expect(mockResolveAgentRoute).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:omt_native_topic" }, + }), + ); + }); + it("replies to the topic root when handling a message inside an existing topic", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 06fe777ba57..1b1552907ac 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -78,6 +78,31 @@ const groupNameCache = new Map(); const GROUP_NAME_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap +type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; + +function resolveConfiguredFeishuGroupSessionScope(params: { + groupConfig?: { + groupSessionScope?: FeishuGroupSessionScope; + topicSessionMode?: "enabled" | "disabled"; + }; + feishuCfg?: { + groupSessionScope?: FeishuGroupSessionScope; + topicSessionMode?: "enabled" | "disabled"; + }; +}): FeishuGroupSessionScope { + const legacyTopicSessionMode = + params.groupConfig?.topicSessionMode ?? params.feishuCfg?.topicSessionMode ?? "disabled"; + return ( + params.groupConfig?.groupSessionScope ?? + params.feishuCfg?.groupSessionScope ?? + (legacyTopicSessionMode === "enabled" ? "group_topic" : "group") + ); +} + +function isFeishuTopicSessionScope(scope: FeishuGroupSessionScope): boolean { + return scope === "group_topic" || scope === "group_topic_sender"; +} + function evictGroupNameCache(): void { const now = Date.now(); for (const [key, val] of groupNameCache) { @@ -503,6 +528,36 @@ export async function handleFeishuMessage(params: { const groupConfig = isGroup ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }) : undefined; + const groupSessionScope = isGroup + ? resolveConfiguredFeishuGroupSessionScope({ groupConfig, feishuCfg }) + : null; + let effectiveThreadId = ctx.threadId; + if ( + isGroup && + ctx.chatType === "topic_group" && + !effectiveThreadId && + isFeishuTopicSessionScope(groupSessionScope ?? "group") + ) { + try { + const messageInfo = await getMessageFeishu({ + cfg, + accountId: account.accountId, + messageId: ctx.messageId, + }); + const hydratedThreadId = messageInfo?.threadId?.trim(); + if (hydratedThreadId) { + ctx = { ...ctx, threadId: hydratedThreadId }; + effectiveThreadId = hydratedThreadId; + log( + `feishu[${account.accountId}]: hydrated topic thread_id=${hydratedThreadId} for message=${ctx.messageId}`, + ); + } + } catch (err) { + log( + `feishu[${account.accountId}]: failed to hydrate topic thread_id for message=${ctx.messageId}: ${String(err)}`, + ); + } + } const effectiveGroupSenderAllowFrom = isGroup ? (groupConfig?.allowFrom?.length ?? 0) > 0 ? (groupConfig?.allowFrom ?? []) @@ -514,7 +569,7 @@ export async function handleFeishuMessage(params: { senderOpenId: ctx.senderOpenId, messageId: ctx.messageId, rootId: ctx.rootId, - threadId: ctx.threadId, + threadId: effectiveThreadId, chatType: ctx.chatType, groupConfig, feishuCfg,