From 36d69d05e212980821263b3b7fc370554d8d4959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=8B=90=E7=88=B7=26=26=E8=80=81=E6=8B=90=E7=98=A6?= Date: Sat, 28 Feb 2026 09:26:36 +0800 Subject: [PATCH] feat(feishu): support sender/topic-scoped group session routing (openclaw#17798) thanks @yfge Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: yfge <1186273+yfge@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/bot.test.ts | 133 +++++++++++++++++++++++-- extensions/feishu/src/bot.ts | 48 ++++++--- extensions/feishu/src/channel.ts | 4 + extensions/feishu/src/config-schema.ts | 21 +++- 5 files changed, 185 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d718c6feb..ec565f10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - 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/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 ca0792f2e..2f2a92a8d 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -10,6 +10,7 @@ const { mockGetMessageFeishu, mockDownloadMessageResourceFeishu, mockCreateFeishuClient, + mockResolveAgentRoute, } = vi.hoisted(() => ({ mockCreateFeishuReplyDispatcher: vi.fn(() => ({ dispatcher: vi.fn(), @@ -24,6 +25,12 @@ const { fileName: "clip.mp4", }), mockCreateFeishuClient: vi.fn(), + mockResolveAgentRoute: vi.fn(() => ({ + agentId: "main", + accountId: "default", + sessionKey: "agent:main:feishu:dm:ou-attacker", + matchedBy: "default", + })), })); vi.mock("./reply-dispatcher.js", () => ({ @@ -120,6 +127,12 @@ describe("handleFeishuMessage command authorization", () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveAgentRoute.mockReturnValue({ + agentId: "main", + accountId: "default", + sessionKey: "agent:main:feishu:dm:ou-attacker", + matchedBy: "default", + }); mockCreateFeishuClient.mockReturnValue({ contact: { user: { @@ -133,12 +146,7 @@ describe("handleFeishuMessage command authorization", () => { }, channel: { routing: { - resolveAgentRoute: vi.fn(() => ({ - agentId: "main", - accountId: "default", - sessionKey: "agent:main:feishu:dm:ou-attacker", - matchedBy: "default", - })), + resolveAgentRoute: mockResolveAgentRoute, }, reply: { resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })), @@ -540,4 +548,117 @@ describe("handleFeishuMessage command authorization", () => { }), ); }); + + it("routes group sessions by sender when groupSessionScope=group_sender", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_sender", + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-scope-user" } }, + message: { + message_id: "msg-scope-group-sender", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "group sender scope" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockResolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:sender:ou-scope-user" }, + parentPeer: null, + }), + ); + }); + + it("routes topic sessions and parentPeer when groupSessionScope=group_topic_sender", 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-user" } }, + message: { + message_id: "msg-scope-topic-sender", + chat_id: "oc-group", + chat_type: "group", + root_id: "om_root_topic", + message_type: "text", + content: JSON.stringify({ text: "topic sender scope" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockResolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" }, + parentPeer: { kind: "group", id: "oc-group" }, + }), + ); + }); + + it("maps legacy topicSessionMode=enabled to group_topic routing", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + topicSessionMode: "enabled", + groups: { + "oc-group": { + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-legacy" } }, + message: { + message_id: "msg-legacy-topic-mode", + chat_id: "oc-group", + chat_type: "group", + root_id: "om_root_legacy", + message_type: "text", + content: JSON.stringify({ text: "legacy topic mode" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockResolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ + peer: { kind: "group", id: "oc-group:topic:om_root_legacy" }, + parentPeer: { kind: "group", id: "oc-group" }, + }), + ); + }); }); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 61c659737..943ed964b 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -755,19 +755,39 @@ export async function handleFeishuMessage(params: { const feishuFrom = `feishu:${ctx.senderOpenId}`; const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`; - // Resolve peer ID for session routing - // When topicSessionMode is enabled, messages within a topic (identified by root_id) - // get a separate session from the main group chat. + // Resolve peer ID for session routing. + // Default is one session per group chat; this can be customized with groupSessionScope. let peerId = isGroup ? ctx.chatId : ctx.senderOpenId; - let topicSessionMode: "enabled" | "disabled" = "disabled"; - if (isGroup && ctx.rootId) { - const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId }); - topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; - if (topicSessionMode === "enabled") { - // Use chatId:topic:rootId as peer ID for topic-scoped sessions - peerId = `${ctx.chatId}:topic:${ctx.rootId}`; - log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`); + let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" = + "group"; + + if (isGroup) { + const legacyTopicSessionMode = + groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled"; + groupSessionScope = + groupConfig?.groupSessionScope ?? + feishuCfg?.groupSessionScope ?? + (legacyTopicSessionMode === "enabled" ? "group_topic" : "group"); + + 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; + break; + case "group_topic_sender": + peerId = ctx.rootId + ? `${ctx.chatId}:topic:${ctx.rootId}:sender:${ctx.senderOpenId}` + : `${ctx.chatId}:sender:${ctx.senderOpenId}`; + break; + case "group": + default: + peerId = ctx.chatId; + break; } + + log(`feishu[${account.accountId}]: group session scope=${groupSessionScope}, peer=${peerId}`); } let route = core.channel.routing.resolveAgentRoute({ @@ -778,9 +798,11 @@ export async function handleFeishuMessage(params: { kind: isGroup ? "group" : "direct", id: peerId, }, - // Add parentPeer for binding inheritance in topic mode + // Add parentPeer for binding inheritance in topic-scoped modes. parentPeer: - isGroup && ctx.rootId && topicSessionMode === "enabled" + isGroup && + ctx.rootId && + (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") ? { kind: "group", id: ctx.chatId, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index f22292417..b9a599a36 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -101,6 +101,10 @@ export const feishuPlugin: ChannelPlugin = { items: { oneOf: [{ type: "string" }, { type: "number" }] }, }, requireMention: { type: "boolean" }, + groupSessionScope: { + type: "string", + enum: ["group", "group_sender", "group_topic", "group_topic_sender"], + }, topicSessionMode: { type: "string", enum: ["disabled", "enabled"] }, historyLimit: { type: "integer", minimum: 0 }, dmHistoryLimit: { type: "integer", minimum: 0 }, diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index f5b08e13e..04d65551f 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -91,12 +91,23 @@ const FeishuToolsConfigSchema = z .optional(); /** + * Group session scope for routing Feishu group messages. + * - "group" (default): one session per group chat + * - "group_sender": one session per (group + sender) + * - "group_topic": one session per group topic thread (falls back to group if no topic) + * - "group_topic_sender": one session per (group + topic thread + sender), + * falls back to (group + sender) if no topic + */ +const GroupSessionScopeSchema = z + .enum(["group", "group_sender", "group_topic", "group_topic_sender"]) + .optional(); + +/** + * @deprecated Use groupSessionScope instead. + * * Topic session isolation mode for group chats. * - "disabled" (default): All messages in a group share one session * - "enabled": Messages in different topics get separate sessions - * - * When enabled, the session key becomes `chat:{chatId}:topic:{rootId}` - * for messages within a topic thread, allowing isolated conversations. */ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional(); @@ -108,6 +119,7 @@ export const FeishuGroupSchema = z enabled: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), systemPrompt: z.string().optional(), + groupSessionScope: GroupSessionScopeSchema, topicSessionMode: TopicSessionModeSchema, }) .strict(); @@ -153,6 +165,8 @@ export const FeishuAccountConfigSchema = z connectionMode: FeishuConnectionModeSchema.optional(), webhookPath: z.string().optional(), ...FeishuSharedConfigShape, + groupSessionScope: GroupSessionScopeSchema, + topicSessionMode: TopicSessionModeSchema, }) .strict(); @@ -171,6 +185,7 @@ export const FeishuConfigSchema = z dmPolicy: DmPolicySchema.optional().default("pairing"), groupPolicy: GroupPolicySchema.optional().default("allowlist"), requireMention: z.boolean().optional().default(true), + groupSessionScope: GroupSessionScopeSchema, topicSessionMode: TopicSessionModeSchema, // Dynamic agent creation for DM users dynamicAgentCreation: DynamicAgentCreationSchema,