mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:41:37 +00:00
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>
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### 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/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/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/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.
|
- 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.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {
|
|||||||
mockGetMessageFeishu,
|
mockGetMessageFeishu,
|
||||||
mockDownloadMessageResourceFeishu,
|
mockDownloadMessageResourceFeishu,
|
||||||
mockCreateFeishuClient,
|
mockCreateFeishuClient,
|
||||||
|
mockResolveAgentRoute,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||||
dispatcher: vi.fn(),
|
dispatcher: vi.fn(),
|
||||||
@@ -24,6 +25,12 @@ const {
|
|||||||
fileName: "clip.mp4",
|
fileName: "clip.mp4",
|
||||||
}),
|
}),
|
||||||
mockCreateFeishuClient: vi.fn(),
|
mockCreateFeishuClient: vi.fn(),
|
||||||
|
mockResolveAgentRoute: vi.fn(() => ({
|
||||||
|
agentId: "main",
|
||||||
|
accountId: "default",
|
||||||
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
||||||
|
matchedBy: "default",
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./reply-dispatcher.js", () => ({
|
vi.mock("./reply-dispatcher.js", () => ({
|
||||||
@@ -120,6 +127,12 @@ describe("handleFeishuMessage command authorization", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockResolveAgentRoute.mockReturnValue({
|
||||||
|
agentId: "main",
|
||||||
|
accountId: "default",
|
||||||
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
||||||
|
matchedBy: "default",
|
||||||
|
});
|
||||||
mockCreateFeishuClient.mockReturnValue({
|
mockCreateFeishuClient.mockReturnValue({
|
||||||
contact: {
|
contact: {
|
||||||
user: {
|
user: {
|
||||||
@@ -133,12 +146,7 @@ describe("handleFeishuMessage command authorization", () => {
|
|||||||
},
|
},
|
||||||
channel: {
|
channel: {
|
||||||
routing: {
|
routing: {
|
||||||
resolveAgentRoute: vi.fn(() => ({
|
resolveAgentRoute: mockResolveAgentRoute,
|
||||||
agentId: "main",
|
|
||||||
accountId: "default",
|
|
||||||
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
||||||
matchedBy: "default",
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
reply: {
|
reply: {
|
||||||
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
|
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" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -755,19 +755,39 @@ export async function handleFeishuMessage(params: {
|
|||||||
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
||||||
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
||||||
|
|
||||||
// Resolve peer ID for session routing
|
// Resolve peer ID for session routing.
|
||||||
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
|
// Default is one session per group chat; this can be customized with groupSessionScope.
|
||||||
// get a separate session from the main group chat.
|
|
||||||
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
||||||
let topicSessionMode: "enabled" | "disabled" = "disabled";
|
let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
|
||||||
if (isGroup && ctx.rootId) {
|
"group";
|
||||||
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
||||||
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
if (isGroup) {
|
||||||
if (topicSessionMode === "enabled") {
|
const legacyTopicSessionMode =
|
||||||
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
||||||
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
groupSessionScope =
|
||||||
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
|
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({
|
let route = core.channel.routing.resolveAgentRoute({
|
||||||
@@ -778,9 +798,11 @@ export async function handleFeishuMessage(params: {
|
|||||||
kind: isGroup ? "group" : "direct",
|
kind: isGroup ? "group" : "direct",
|
||||||
id: peerId,
|
id: peerId,
|
||||||
},
|
},
|
||||||
// Add parentPeer for binding inheritance in topic mode
|
// Add parentPeer for binding inheritance in topic-scoped modes.
|
||||||
parentPeer:
|
parentPeer:
|
||||||
isGroup && ctx.rootId && topicSessionMode === "enabled"
|
isGroup &&
|
||||||
|
ctx.rootId &&
|
||||||
|
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
||||||
? {
|
? {
|
||||||
kind: "group",
|
kind: "group",
|
||||||
id: ctx.chatId,
|
id: ctx.chatId,
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||||
},
|
},
|
||||||
requireMention: { type: "boolean" },
|
requireMention: { type: "boolean" },
|
||||||
|
groupSessionScope: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
|
||||||
|
},
|
||||||
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
||||||
historyLimit: { type: "integer", minimum: 0 },
|
historyLimit: { type: "integer", minimum: 0 },
|
||||||
dmHistoryLimit: { type: "integer", minimum: 0 },
|
dmHistoryLimit: { type: "integer", minimum: 0 },
|
||||||
|
|||||||
@@ -91,12 +91,23 @@ const FeishuToolsConfigSchema = z
|
|||||||
.optional();
|
.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.
|
* Topic session isolation mode for group chats.
|
||||||
* - "disabled" (default): All messages in a group share one session
|
* - "disabled" (default): All messages in a group share one session
|
||||||
* - "enabled": Messages in different topics get separate sessions
|
* - "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();
|
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
||||||
|
|
||||||
@@ -108,6 +119,7 @@ export const FeishuGroupSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
systemPrompt: z.string().optional(),
|
systemPrompt: z.string().optional(),
|
||||||
|
groupSessionScope: GroupSessionScopeSchema,
|
||||||
topicSessionMode: TopicSessionModeSchema,
|
topicSessionMode: TopicSessionModeSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -153,6 +165,8 @@ export const FeishuAccountConfigSchema = z
|
|||||||
connectionMode: FeishuConnectionModeSchema.optional(),
|
connectionMode: FeishuConnectionModeSchema.optional(),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
...FeishuSharedConfigShape,
|
...FeishuSharedConfigShape,
|
||||||
|
groupSessionScope: GroupSessionScopeSchema,
|
||||||
|
topicSessionMode: TopicSessionModeSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -171,6 +185,7 @@ export const FeishuConfigSchema = z
|
|||||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||||
requireMention: z.boolean().optional().default(true),
|
requireMention: z.boolean().optional().default(true),
|
||||||
|
groupSessionScope: GroupSessionScopeSchema,
|
||||||
topicSessionMode: TopicSessionModeSchema,
|
topicSessionMode: TopicSessionModeSchema,
|
||||||
// Dynamic agent creation for DM users
|
// Dynamic agent creation for DM users
|
||||||
dynamicAgentCreation: DynamicAgentCreationSchema,
|
dynamicAgentCreation: DynamicAgentCreationSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user