diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a11d98208a..6b2c01ef11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Feishu: back off streaming-card creation after HTTP 400 startup failures, so unsupported card setups fall back without delaying every message. Fixes #56981. Thanks @JinnanDuan. +- Feishu/topic groups: key native Feishu/Lark topic-group sessions by `thread_id` so starter messages and replies with different `root_id` formats stay in the same `group_topic` conversation. Fixes #71438. Thanks @1335848090. - Feishu: suppress duplicate final card delivery when idle closes a streaming card before the final payload arrives. (#68491) Thanks @MoerAI. - Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury. - Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar. diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index ce87a8056a8..50571935ae4 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -430,6 +430,12 @@ Full configuration: [Gateway configuration](/gateway/configuration) - ✅ Thread replies - ✅ Media replies stay thread-aware when replying to a thread message +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. + --- ## Related diff --git a/extensions/feishu/src/bot-content.ts b/extensions/feishu/src/bot-content.ts index ce166c99fc6..90e470e2726 100644 --- a/extensions/feishu/src/bot-content.ts +++ b/extensions/feishu/src/bot-content.ts @@ -4,7 +4,7 @@ import { normalizeFeishuExternalKey } from "./external-keys.js"; import { downloadMessageResourceFeishu } from "./media.js"; import { parsePostContent } from "./post.js"; import { getFeishuRuntime } from "./runtime.js"; -import type { FeishuMediaInfo } from "./types.js"; +import type { FeishuChatType, FeishuMediaInfo } from "./types.js"; export type FeishuMention = { key: string; @@ -54,6 +54,7 @@ export function resolveFeishuGroupSession(params: { messageId: string; rootId?: string; threadId?: string; + chatType?: FeishuChatType; groupConfig?: { groupSessionScope?: GroupSessionScope; topicSessionMode?: "enabled" | "disabled"; @@ -65,7 +66,8 @@ export function resolveFeishuGroupSession(params: { replyInThread?: "enabled" | "disabled"; }; }): ResolvedFeishuGroupSession { - const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params; + const { chatId, senderOpenId, messageId, rootId, threadId, chatType, groupConfig, feishuCfg } = + params; const normalizedThreadId = threadId?.trim(); const normalizedRootId = rootId?.trim(); const threadReply = Boolean(normalizedThreadId || normalizedRootId); @@ -78,9 +80,14 @@ export function resolveFeishuGroupSession(params: { groupConfig?.groupSessionScope ?? feishuCfg?.groupSessionScope ?? (legacyTopicSessionMode === "enabled" ? "group_topic" : "group"); + const normalizedTopicGroupThreadId = + chatType === "topic_group" ? (normalizedThreadId ?? normalizedRootId) : undefined; const topicScope = groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender" - ? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null)) + ? (normalizedTopicGroupThreadId ?? + normalizedRootId ?? + normalizedThreadId ?? + (replyInThread ? messageId : null)) : null; let peerId = chatId; diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 34816ca9d52..07f7c334971 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -2031,6 +2031,65 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("uses thread_id as the canonical topic key in Feishu topic groups", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_topic", + }, + }, + }, + }, + } as ClawdbotConfig; + + const topicStarter: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-user" } }, + message: { + message_id: "om_topic_starter_message", + chat_id: "oc-group", + chat_type: "topic_group", + root_id: "omt_topic_1", + message_type: "text", + content: JSON.stringify({ text: "topic starter" }), + }, + }; + const topicReply: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-topic-user" } }, + message: { + message_id: "om_topic_reply_message", + chat_id: "oc-group", + chat_type: "topic_group", + root_id: "om_topic_starter_message", + thread_id: "omt_topic_1", + message_type: "text", + content: JSON.stringify({ text: "topic reply" }), + }, + }; + + await dispatchMessage({ cfg, event: topicStarter }); + await dispatchMessage({ cfg, event: topicReply }); + + expect(mockResolveAgentRoute).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:omt_topic_1" }, + parentPeer: { kind: "group", id: "oc-group" }, + }), + ); + expect(mockResolveAgentRoute).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:omt_topic_1" }, + parentPeer: { kind: "group", id: "oc-group" }, + }), + ); + }); + it("uses thread_id as topic key when root_id is missing", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index e03a82a4231..dc899d62f99 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -54,7 +54,11 @@ import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js"; export type { FeishuBotAddedEvent, FeishuMessageEvent } from "./event-types.js"; import type { FeishuMessageEvent } from "./event-types.js"; -import type { FeishuMessageContext, FeishuMessageInfo } from "./types.js"; +import { + isFeishuGroupChatType, + type FeishuMessageContext, + type FeishuMessageInfo, +} from "./types.js"; import type { DynamicAgentCreationConfig } from "./types.js"; export { toMessageResourceType } from "./bot-content.js"; @@ -300,7 +304,7 @@ export async function handleFeishuMessage(params: { } let ctx = parseFeishuMessageEvent(event, botOpenId, botName); - const isGroup = ctx.chatType === "group"; + const isGroup = isFeishuGroupChatType(ctx.chatType); const isDirect = !isGroup; const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id); @@ -391,6 +395,7 @@ export async function handleFeishuMessage(params: { messageId: ctx.messageId, rootId: ctx.rootId, threadId: ctx.threadId, + chatType: ctx.chatType, groupConfig, feishuCfg, }) diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 1efd59e12a8..c75fd98fbfc 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -122,8 +122,9 @@ const GroupSessionScopeSchema = z * - "disabled" (default): All messages in a group share one session * - "enabled": Messages in different topics get separate sessions * - * Topic routing uses `root_id` when present to keep session continuity and - * falls back to `thread_id` when `root_id` is unavailable. + * Topic routing uses Feishu topic-group `thread_id` when the event identifies a + * native topic group, and keeps `root_id` precedence for normal groups so + * reply-created threads stay on the initiating message session. */ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional(); const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional(); diff --git a/extensions/feishu/src/event-types.ts b/extensions/feishu/src/event-types.ts index 37af2f7d601..ec63f47daa6 100644 --- a/extensions/feishu/src/event-types.ts +++ b/extensions/feishu/src/event-types.ts @@ -14,7 +14,7 @@ export type FeishuMessageEvent = { parent_id?: string; thread_id?: string; chat_id: string; - chat_type: "p2p" | "group" | "private"; + chat_type: "p2p" | "group" | "topic_group" | "private"; message_type: string; content: string; create_time?: string; diff --git a/extensions/feishu/src/mention.ts b/extensions/feishu/src/mention.ts index 313b0751037..f320cb0e868 100644 --- a/extensions/feishu/src/mention.ts +++ b/extensions/feishu/src/mention.ts @@ -1,6 +1,7 @@ import type { FeishuMessageEvent } from "./event-types.js"; export type { MentionTarget } from "./mention-target.types.js"; import type { MentionTarget } from "./mention-target.types.js"; +import { isFeishuGroupChatType } from "./types.js"; /** * Escape regex metacharacters so user-controlled mention fields are treated literally. @@ -46,7 +47,7 @@ export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: s return false; } - const isDirectMessage = event.message.chat_type !== "group"; + const isDirectMessage = !isFeishuGroupChatType(event.message.chat_type); const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId); if (isDirectMessage) { diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 9f60900e8c7..10254a2511f 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -156,7 +156,9 @@ export async function resolveReactionSyntheticEvent( } function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined { - return value === "group" || value === "private" || value === "p2p" ? value : undefined; + return value === "group" || value === "topic_group" || value === "private" || value === "p2p" + ? value + : undefined; } type RegisterEventHandlersContext = { diff --git a/extensions/feishu/src/monitor.message-handler.ts b/extensions/feishu/src/monitor.message-handler.ts index e4d44d76945..363c42eea18 100644 --- a/extensions/feishu/src/monitor.message-handler.ts +++ b/extensions/feishu/src/monitor.message-handler.ts @@ -59,7 +59,9 @@ type FeishuMessageReceiveHandlerContext = { }; function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined { - return value === "group" || value === "private" || value === "p2p" ? value : undefined; + return value === "group" || value === "topic_group" || value === "private" || value === "p2p" + ? value + : undefined; } function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null { diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index df7388d917b..338565167d0 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -268,7 +268,10 @@ function parseFeishuMessageItem( messageId: item.message_id ?? fallbackMessageId ?? "", chatId: item.chat_id ?? "", chatType: - item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p" + item.chat_type === "group" || + item.chat_type === "topic_group" || + item.chat_type === "private" || + item.chat_type === "p2p" ? item.chat_type : undefined, senderId: item.sender?.id, diff --git a/extensions/feishu/src/types.ts b/extensions/feishu/src/types.ts index a790cf7296e..3ffbcb9c102 100644 --- a/extensions/feishu/src/types.ts +++ b/extensions/feishu/src/types.ts @@ -43,7 +43,7 @@ export type FeishuMessageContext = { senderId: string; senderOpenId: string; senderName?: string; - chatType: "p2p" | "group" | "private"; + chatType: FeishuChatType; mentionedBot: boolean; hasAnyMention?: boolean; rootId?: string; @@ -60,7 +60,11 @@ export type FeishuSendResult = { chatId: string; }; -export type FeishuChatType = "p2p" | "group" | "private"; +export type FeishuChatType = "p2p" | "group" | "topic_group" | "private"; + +export function isFeishuGroupChatType(chatType: FeishuChatType | undefined): boolean { + return chatType === "group" || chatType === "topic_group"; +} export type FeishuMessageInfo = { messageId: string;