Skip to content

Commit 36d69d0

Browse files
yfgeTakhoffman
andauthored
feat(feishu): support sender/topic-scoped group session routing (#17798) thanks @yfge
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: yfge <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent ed51796 commit 36d69d0

File tree

5 files changed

+185
-22
lines changed

5 files changed

+185
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
1616
### Fixes
1717

1818
- 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)
19+
- 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)
1920
- 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)
2021
- 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.
2122
- 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.

extensions/feishu/src/bot.test.ts

Lines changed: 127 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
mockGetMessageFeishu,
1111
mockDownloadMessageResourceFeishu,
1212
mockCreateFeishuClient,
13+
mockResolveAgentRoute,
1314
} = vi.hoisted(() => ({
1415
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
1516
dispatcher: vi.fn(),
@@ -24,6 +25,12 @@ const {
2425
fileName: "clip.mp4",
2526
}),
2627
mockCreateFeishuClient: vi.fn(),
28+
mockResolveAgentRoute: vi.fn(() => ({
29+
agentId: "main",
30+
accountId: "default",
31+
sessionKey: "agent:main:feishu:dm:ou-attacker",
32+
matchedBy: "default",
33+
})),
2734
}));
2835

2936
vi.mock("./reply-dispatcher.js", () => ({
@@ -120,6 +127,12 @@ describe("handleFeishuMessage command authorization", () => {
120127

121128
beforeEach(() => {
122129
vi.clearAllMocks();
130+
mockResolveAgentRoute.mockReturnValue({
131+
agentId: "main",
132+
accountId: "default",
133+
sessionKey: "agent:main:feishu:dm:ou-attacker",
134+
matchedBy: "default",
135+
});
123136
mockCreateFeishuClient.mockReturnValue({
124137
contact: {
125138
user: {
@@ -133,12 +146,7 @@ describe("handleFeishuMessage command authorization", () => {
133146
},
134147
channel: {
135148
routing: {
136-
resolveAgentRoute: vi.fn(() => ({
137-
agentId: "main",
138-
accountId: "default",
139-
sessionKey: "agent:main:feishu:dm:ou-attacker",
140-
matchedBy: "default",
141-
})),
149+
resolveAgentRoute: mockResolveAgentRoute,
142150
},
143151
reply: {
144152
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
@@ -540,4 +548,117 @@ describe("handleFeishuMessage command authorization", () => {
540548
}),
541549
);
542550
});
551+
552+
it("routes group sessions by sender when groupSessionScope=group_sender", async () => {
553+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
554+
555+
const cfg: ClawdbotConfig = {
556+
channels: {
557+
feishu: {
558+
groups: {
559+
"oc-group": {
560+
requireMention: false,
561+
groupSessionScope: "group_sender",
562+
},
563+
},
564+
},
565+
},
566+
} as ClawdbotConfig;
567+
568+
const event: FeishuMessageEvent = {
569+
sender: { sender_id: { open_id: "ou-scope-user" } },
570+
message: {
571+
message_id: "msg-scope-group-sender",
572+
chat_id: "oc-group",
573+
chat_type: "group",
574+
message_type: "text",
575+
content: JSON.stringify({ text: "group sender scope" }),
576+
},
577+
};
578+
579+
await dispatchMessage({ cfg, event });
580+
581+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
582+
expect.objectContaining({
583+
peer: { kind: "group", id: "oc-group:sender:ou-scope-user" },
584+
parentPeer: null,
585+
}),
586+
);
587+
});
588+
589+
it("routes topic sessions and parentPeer when groupSessionScope=group_topic_sender", async () => {
590+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
591+
592+
const cfg: ClawdbotConfig = {
593+
channels: {
594+
feishu: {
595+
groups: {
596+
"oc-group": {
597+
requireMention: false,
598+
groupSessionScope: "group_topic_sender",
599+
},
600+
},
601+
},
602+
},
603+
} as ClawdbotConfig;
604+
605+
const event: FeishuMessageEvent = {
606+
sender: { sender_id: { open_id: "ou-topic-user" } },
607+
message: {
608+
message_id: "msg-scope-topic-sender",
609+
chat_id: "oc-group",
610+
chat_type: "group",
611+
root_id: "om_root_topic",
612+
message_type: "text",
613+
content: JSON.stringify({ text: "topic sender scope" }),
614+
},
615+
};
616+
617+
await dispatchMessage({ cfg, event });
618+
619+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
620+
expect.objectContaining({
621+
peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
622+
parentPeer: { kind: "group", id: "oc-group" },
623+
}),
624+
);
625+
});
626+
627+
it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
628+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
629+
630+
const cfg: ClawdbotConfig = {
631+
channels: {
632+
feishu: {
633+
topicSessionMode: "enabled",
634+
groups: {
635+
"oc-group": {
636+
requireMention: false,
637+
},
638+
},
639+
},
640+
},
641+
} as ClawdbotConfig;
642+
643+
const event: FeishuMessageEvent = {
644+
sender: { sender_id: { open_id: "ou-legacy" } },
645+
message: {
646+
message_id: "msg-legacy-topic-mode",
647+
chat_id: "oc-group",
648+
chat_type: "group",
649+
root_id: "om_root_legacy",
650+
message_type: "text",
651+
content: JSON.stringify({ text: "legacy topic mode" }),
652+
},
653+
};
654+
655+
await dispatchMessage({ cfg, event });
656+
657+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
658+
expect.objectContaining({
659+
peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
660+
parentPeer: { kind: "group", id: "oc-group" },
661+
}),
662+
);
663+
});
543664
});

extensions/feishu/src/bot.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -755,19 +755,39 @@ export async function handleFeishuMessage(params: {
755755
const feishuFrom = `feishu:${ctx.senderOpenId}`;
756756
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
757757

758-
// Resolve peer ID for session routing
759-
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
760-
// get a separate session from the main group chat.
758+
// Resolve peer ID for session routing.
759+
// Default is one session per group chat; this can be customized with groupSessionScope.
761760
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
762-
let topicSessionMode: "enabled" | "disabled" = "disabled";
763-
if (isGroup && ctx.rootId) {
764-
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
765-
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
766-
if (topicSessionMode === "enabled") {
767-
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
768-
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
769-
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
761+
let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
762+
"group";
763+
764+
if (isGroup) {
765+
const legacyTopicSessionMode =
766+
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
767+
groupSessionScope =
768+
groupConfig?.groupSessionScope ??
769+
feishuCfg?.groupSessionScope ??
770+
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
771+
772+
switch (groupSessionScope) {
773+
case "group_sender":
774+
peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`;
775+
break;
776+
case "group_topic":
777+
peerId = ctx.rootId ? `${ctx.chatId}:topic:${ctx.rootId}` : ctx.chatId;
778+
break;
779+
case "group_topic_sender":
780+
peerId = ctx.rootId
781+
? `${ctx.chatId}:topic:${ctx.rootId}:sender:${ctx.senderOpenId}`
782+
: `${ctx.chatId}:sender:${ctx.senderOpenId}`;
783+
break;
784+
case "group":
785+
default:
786+
peerId = ctx.chatId;
787+
break;
770788
}
789+
790+
log(`feishu[${account.accountId}]: group session scope=${groupSessionScope}, peer=${peerId}`);
771791
}
772792

773793
let route = core.channel.routing.resolveAgentRoute({
@@ -778,9 +798,11 @@ export async function handleFeishuMessage(params: {
778798
kind: isGroup ? "group" : "direct",
779799
id: peerId,
780800
},
781-
// Add parentPeer for binding inheritance in topic mode
801+
// Add parentPeer for binding inheritance in topic-scoped modes.
782802
parentPeer:
783-
isGroup && ctx.rootId && topicSessionMode === "enabled"
803+
isGroup &&
804+
ctx.rootId &&
805+
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
784806
? {
785807
kind: "group",
786808
id: ctx.chatId,

extensions/feishu/src/channel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
101101
items: { oneOf: [{ type: "string" }, { type: "number" }] },
102102
},
103103
requireMention: { type: "boolean" },
104+
groupSessionScope: {
105+
type: "string",
106+
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
107+
},
104108
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
105109
historyLimit: { type: "integer", minimum: 0 },
106110
dmHistoryLimit: { type: "integer", minimum: 0 },

extensions/feishu/src/config-schema.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,23 @@ const FeishuToolsConfigSchema = z
9191
.optional();
9292

9393
/**
94+
* Group session scope for routing Feishu group messages.
95+
* - "group" (default): one session per group chat
96+
* - "group_sender": one session per (group + sender)
97+
* - "group_topic": one session per group topic thread (falls back to group if no topic)
98+
* - "group_topic_sender": one session per (group + topic thread + sender),
99+
* falls back to (group + sender) if no topic
100+
*/
101+
const GroupSessionScopeSchema = z
102+
.enum(["group", "group_sender", "group_topic", "group_topic_sender"])
103+
.optional();
104+
105+
/**
106+
* @deprecated Use groupSessionScope instead.
107+
*
94108
* Topic session isolation mode for group chats.
95109
* - "disabled" (default): All messages in a group share one session
96110
* - "enabled": Messages in different topics get separate sessions
97-
*
98-
* When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
99-
* for messages within a topic thread, allowing isolated conversations.
100111
*/
101112
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
102113

@@ -108,6 +119,7 @@ export const FeishuGroupSchema = z
108119
enabled: z.boolean().optional(),
109120
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
110121
systemPrompt: z.string().optional(),
122+
groupSessionScope: GroupSessionScopeSchema,
111123
topicSessionMode: TopicSessionModeSchema,
112124
})
113125
.strict();
@@ -153,6 +165,8 @@ export const FeishuAccountConfigSchema = z
153165
connectionMode: FeishuConnectionModeSchema.optional(),
154166
webhookPath: z.string().optional(),
155167
...FeishuSharedConfigShape,
168+
groupSessionScope: GroupSessionScopeSchema,
169+
topicSessionMode: TopicSessionModeSchema,
156170
})
157171
.strict();
158172

@@ -171,6 +185,7 @@ export const FeishuConfigSchema = z
171185
dmPolicy: DmPolicySchema.optional().default("pairing"),
172186
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
173187
requireMention: z.boolean().optional().default(true),
188+
groupSessionScope: GroupSessionScopeSchema,
174189
topicSessionMode: TopicSessionModeSchema,
175190
// Dynamic agent creation for DM users
176191
dynamicAgentCreation: DynamicAgentCreationSchema,

0 commit comments

Comments
 (0)