@openclaw/feishu 2026.3.1 → 2026.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +2 -2
- package/package.json +1 -1
- package/src/accounts.test.ts +268 -11
- package/src/accounts.ts +101 -14
- package/src/bitable.ts +40 -28
- package/src/bot.checkBotMentioned.test.ts +9 -1
- package/src/bot.stripBotMention.test.ts +118 -22
- package/src/bot.test.ts +945 -77
- package/src/bot.ts +492 -165
- package/src/card-action.ts +1 -1
- package/src/channel.test.ts +1 -1
- package/src/channel.ts +72 -68
- package/src/chat.test.ts +2 -2
- package/src/chat.ts +1 -1
- package/src/client.test.ts +221 -4
- package/src/client.ts +70 -5
- package/src/config-schema.test.ts +33 -6
- package/src/config-schema.ts +18 -10
- package/src/dedup.ts +47 -1
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +29 -50
- package/src/doc-schema.ts +16 -22
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +8 -11
- package/src/docx.account-selection.test.ts +10 -16
- package/src/docx.test.ts +41 -189
- package/src/docx.ts +1 -1
- package/src/drive.ts +13 -17
- package/src/dynamic-agent.ts +1 -1
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +164 -14
- package/src/media.ts +44 -10
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +284 -25
- package/src/monitor.reaction.test.ts +395 -46
- package/src/monitor.startup.test.ts +25 -8
- package/src/monitor.startup.ts +20 -7
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +88 -9
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +4 -1
- package/src/monitor.ts +4 -4
- package/src/monitor.webhook-security.test.ts +13 -11
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +213 -106
- package/src/outbound.test.ts +178 -0
- package/src/outbound.ts +39 -6
- package/src/perm.ts +11 -15
- package/src/policy.test.ts +40 -0
- package/src/policy.ts +9 -10
- package/src/probe.test.ts +54 -36
- package/src/probe.ts +57 -37
- package/src/reactions.ts +1 -1
- package/src/reply-dispatcher.test.ts +216 -0
- package/src/reply-dispatcher.ts +89 -22
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +7 -3
- package/src/send.reply-fallback.test.ts +74 -0
- package/src/send.test.ts +1 -1
- package/src/send.ts +88 -49
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +96 -28
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +25 -1
- package/src/tool-account-routing.test.ts +3 -3
- package/src/tool-account.ts +1 -1
- package/src/tool-factory-test-harness.ts +1 -1
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/types.ts +11 -4
- package/src/typing.ts +1 -1
- package/src/wiki.ts +15 -19
package/src/bot.test.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
|
3
4
|
import type { FeishuMessageEvent } from "./bot.js";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildBroadcastSessionKey,
|
|
7
|
+
buildFeishuAgentBody,
|
|
8
|
+
handleFeishuMessage,
|
|
9
|
+
resolveBroadcastAgents,
|
|
10
|
+
toMessageResourceType,
|
|
11
|
+
} from "./bot.js";
|
|
5
12
|
import { setFeishuRuntime } from "./runtime.js";
|
|
6
13
|
|
|
7
14
|
const {
|
|
@@ -27,8 +34,10 @@ const {
|
|
|
27
34
|
mockCreateFeishuClient: vi.fn(),
|
|
28
35
|
mockResolveAgentRoute: vi.fn(() => ({
|
|
29
36
|
agentId: "main",
|
|
37
|
+
channel: "feishu",
|
|
30
38
|
accountId: "default",
|
|
31
39
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
40
|
+
mainSessionKey: "agent:main:main",
|
|
32
41
|
matchedBy: "default",
|
|
33
42
|
})),
|
|
34
43
|
}));
|
|
@@ -122,7 +131,9 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
122
131
|
const mockBuildPairingReply = vi.fn(() => "Pairing response");
|
|
123
132
|
const mockEnqueueSystemEvent = vi.fn();
|
|
124
133
|
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
|
134
|
+
id: "inbound-clip.mp4",
|
|
125
135
|
path: "/tmp/inbound-clip.mp4",
|
|
136
|
+
size: Buffer.byteLength("video"),
|
|
126
137
|
contentType: "video/mp4",
|
|
127
138
|
});
|
|
128
139
|
|
|
@@ -131,8 +142,10 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
131
142
|
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
|
132
143
|
mockResolveAgentRoute.mockReturnValue({
|
|
133
144
|
agentId: "main",
|
|
145
|
+
channel: "feishu",
|
|
134
146
|
accountId: "default",
|
|
135
147
|
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
148
|
+
mainSessionKey: "agent:main:main",
|
|
136
149
|
matchedBy: "default",
|
|
137
150
|
});
|
|
138
151
|
mockCreateFeishuClient.mockReturnValue({
|
|
@@ -143,38 +156,46 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
143
156
|
},
|
|
144
157
|
});
|
|
145
158
|
mockEnqueueSystemEvent.mockReset();
|
|
146
|
-
setFeishuRuntime(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
channel: {
|
|
151
|
-
routing: {
|
|
152
|
-
resolveAgentRoute: mockResolveAgentRoute,
|
|
153
|
-
},
|
|
154
|
-
reply: {
|
|
155
|
-
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
|
|
156
|
-
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
157
|
-
finalizeInboundContext: mockFinalizeInboundContext,
|
|
158
|
-
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
159
|
-
withReplyDispatcher: mockWithReplyDispatcher,
|
|
159
|
+
setFeishuRuntime(
|
|
160
|
+
createPluginRuntimeMock({
|
|
161
|
+
system: {
|
|
162
|
+
enqueueSystemEvent: mockEnqueueSystemEvent,
|
|
160
163
|
},
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
channel: {
|
|
165
|
+
routing: {
|
|
166
|
+
resolveAgentRoute:
|
|
167
|
+
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
168
|
+
},
|
|
169
|
+
reply: {
|
|
170
|
+
resolveEnvelopeFormatOptions: vi.fn(
|
|
171
|
+
() => ({}),
|
|
172
|
+
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
173
|
+
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
174
|
+
finalizeInboundContext:
|
|
175
|
+
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
176
|
+
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
177
|
+
withReplyDispatcher:
|
|
178
|
+
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
|
179
|
+
},
|
|
180
|
+
commands: {
|
|
181
|
+
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
|
182
|
+
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
|
183
|
+
},
|
|
184
|
+
media: {
|
|
185
|
+
saveMediaBuffer:
|
|
186
|
+
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
|
187
|
+
},
|
|
188
|
+
pairing: {
|
|
189
|
+
readAllowFromStore: mockReadAllowFromStore,
|
|
190
|
+
upsertPairingRequest: mockUpsertPairingRequest,
|
|
191
|
+
buildPairingReply: mockBuildPairingReply,
|
|
192
|
+
},
|
|
164
193
|
},
|
|
165
194
|
media: {
|
|
166
|
-
|
|
167
|
-
},
|
|
168
|
-
pairing: {
|
|
169
|
-
readAllowFromStore: mockReadAllowFromStore,
|
|
170
|
-
upsertPairingRequest: mockUpsertPairingRequest,
|
|
171
|
-
buildPairingReply: mockBuildPairingReply,
|
|
195
|
+
detectMime: vi.fn(async () => "application/octet-stream"),
|
|
172
196
|
},
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
detectMime: vi.fn(async () => "application/octet-stream"),
|
|
176
|
-
},
|
|
177
|
-
} as unknown as PluginRuntime);
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
178
199
|
});
|
|
179
200
|
|
|
180
201
|
it("does not enqueue inbound preview text as system events", async () => {
|
|
@@ -366,6 +387,41 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
366
387
|
);
|
|
367
388
|
});
|
|
368
389
|
|
|
390
|
+
it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
|
|
391
|
+
const cfg: ClawdbotConfig = {
|
|
392
|
+
channels: {
|
|
393
|
+
feishu: {
|
|
394
|
+
dmPolicy: "pairing",
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
} as ClawdbotConfig;
|
|
398
|
+
|
|
399
|
+
const event: FeishuMessageEvent = {
|
|
400
|
+
sender: {
|
|
401
|
+
sender_id: {
|
|
402
|
+
user_id: "u_mobile_only",
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
message: {
|
|
406
|
+
message_id: "msg-pairing-chat-reply",
|
|
407
|
+
chat_id: "oc_dm_chat_1",
|
|
408
|
+
chat_type: "p2p",
|
|
409
|
+
message_type: "text",
|
|
410
|
+
content: JSON.stringify({ text: "hello" }),
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
mockReadAllowFromStore.mockResolvedValue([]);
|
|
415
|
+
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
|
|
416
|
+
|
|
417
|
+
await dispatchMessage({ cfg, event });
|
|
418
|
+
|
|
419
|
+
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
420
|
+
expect.objectContaining({
|
|
421
|
+
to: "chat:oc_dm_chat_1",
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
});
|
|
369
425
|
it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
|
|
370
426
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
371
427
|
mockReadAllowFromStore.mockResolvedValue([]);
|
|
@@ -403,14 +459,17 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
403
459
|
id: "ou-unapproved",
|
|
404
460
|
meta: { name: undefined },
|
|
405
461
|
});
|
|
406
|
-
expect(mockBuildPairingReply).toHaveBeenCalledWith({
|
|
407
|
-
channel: "feishu",
|
|
408
|
-
idLine: "Your Feishu user id: ou-unapproved",
|
|
409
|
-
code: "ABCDEFGH",
|
|
410
|
-
});
|
|
411
462
|
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
412
463
|
expect.objectContaining({
|
|
413
|
-
to: "
|
|
464
|
+
to: "chat:oc-dm",
|
|
465
|
+
text: expect.stringContaining("Your Feishu user id: ou-unapproved"),
|
|
466
|
+
accountId: "default",
|
|
467
|
+
}),
|
|
468
|
+
);
|
|
469
|
+
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
470
|
+
expect.objectContaining({
|
|
471
|
+
to: "chat:oc-dm",
|
|
472
|
+
text: expect.stringContaining("Pairing code: ABCDEFGH"),
|
|
414
473
|
accountId: "default",
|
|
415
474
|
}),
|
|
416
475
|
);
|
|
@@ -465,6 +524,42 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
465
524
|
);
|
|
466
525
|
});
|
|
467
526
|
|
|
527
|
+
it("normalizes group mention-prefixed slash commands before command-auth probing", async () => {
|
|
528
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(true);
|
|
529
|
+
|
|
530
|
+
const cfg: ClawdbotConfig = {
|
|
531
|
+
channels: {
|
|
532
|
+
feishu: {
|
|
533
|
+
groups: {
|
|
534
|
+
"oc-group": {
|
|
535
|
+
requireMention: false,
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
} as ClawdbotConfig;
|
|
541
|
+
|
|
542
|
+
const event: FeishuMessageEvent = {
|
|
543
|
+
sender: {
|
|
544
|
+
sender_id: {
|
|
545
|
+
open_id: "ou-attacker",
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
message: {
|
|
549
|
+
message_id: "msg-group-mention-command-probe",
|
|
550
|
+
chat_id: "oc-group",
|
|
551
|
+
chat_type: "group",
|
|
552
|
+
message_type: "text",
|
|
553
|
+
content: JSON.stringify({ text: "@_user_1/model" }),
|
|
554
|
+
mentions: [{ key: "@_user_1", id: { open_id: "ou-bot" }, name: "Bot", tenant_key: "" }],
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
await dispatchMessage({ cfg, event });
|
|
559
|
+
|
|
560
|
+
expect(mockShouldComputeCommandAuthorized).toHaveBeenCalledWith("/model", cfg);
|
|
561
|
+
});
|
|
562
|
+
|
|
468
563
|
it("falls back to top-level allowFrom for group command authorization", async () => {
|
|
469
564
|
mockShouldComputeCommandAuthorized.mockReturnValue(true);
|
|
470
565
|
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
|
|
@@ -996,7 +1091,7 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
996
1091
|
channels: {
|
|
997
1092
|
feishu: {
|
|
998
1093
|
appId: "cli_test",
|
|
999
|
-
appSecret: "sec_test",
|
|
1094
|
+
appSecret: "sec_test", // pragma: allowlist secret
|
|
1000
1095
|
groups: {
|
|
1001
1096
|
"oc-group": {
|
|
1002
1097
|
requireMention: false,
|
|
@@ -1038,6 +1133,67 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1038
1133
|
);
|
|
1039
1134
|
});
|
|
1040
1135
|
|
|
1136
|
+
it("ignores stale non-existent contact scope permission errors", async () => {
|
|
1137
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1138
|
+
mockCreateFeishuClient.mockReturnValue({
|
|
1139
|
+
contact: {
|
|
1140
|
+
user: {
|
|
1141
|
+
get: vi.fn().mockRejectedValue({
|
|
1142
|
+
response: {
|
|
1143
|
+
data: {
|
|
1144
|
+
code: 99991672,
|
|
1145
|
+
msg: "permission denied: contact:contact.base:readonly https://open.feishu.cn/app/cli_scope_bug",
|
|
1146
|
+
},
|
|
1147
|
+
},
|
|
1148
|
+
}),
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const cfg: ClawdbotConfig = {
|
|
1154
|
+
channels: {
|
|
1155
|
+
feishu: {
|
|
1156
|
+
appId: "cli_scope_bug",
|
|
1157
|
+
appSecret: "sec_scope_bug", // pragma: allowlist secret
|
|
1158
|
+
groups: {
|
|
1159
|
+
"oc-group": {
|
|
1160
|
+
requireMention: false,
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
},
|
|
1165
|
+
} as ClawdbotConfig;
|
|
1166
|
+
|
|
1167
|
+
const event: FeishuMessageEvent = {
|
|
1168
|
+
sender: {
|
|
1169
|
+
sender_id: {
|
|
1170
|
+
open_id: "ou-perm-scope",
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
message: {
|
|
1174
|
+
message_id: "msg-perm-scope-1",
|
|
1175
|
+
chat_id: "oc-group",
|
|
1176
|
+
chat_type: "group",
|
|
1177
|
+
message_type: "text",
|
|
1178
|
+
content: JSON.stringify({ text: "hello group" }),
|
|
1179
|
+
},
|
|
1180
|
+
};
|
|
1181
|
+
|
|
1182
|
+
await dispatchMessage({ cfg, event });
|
|
1183
|
+
|
|
1184
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1185
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
1186
|
+
expect.objectContaining({
|
|
1187
|
+
BodyForAgent: expect.not.stringContaining("Permission grant URL"),
|
|
1188
|
+
}),
|
|
1189
|
+
);
|
|
1190
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
1191
|
+
expect.objectContaining({
|
|
1192
|
+
BodyForAgent: expect.stringContaining("ou-perm-scope: hello group"),
|
|
1193
|
+
}),
|
|
1194
|
+
);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1041
1197
|
it("routes group sessions by sender when groupSessionScope=group_sender", async () => {
|
|
1042
1198
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1043
1199
|
|
|
@@ -1113,16 +1269,16 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1113
1269
|
);
|
|
1114
1270
|
});
|
|
1115
1271
|
|
|
1116
|
-
it("
|
|
1272
|
+
it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
|
|
1117
1273
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1118
1274
|
|
|
1119
1275
|
const cfg: ClawdbotConfig = {
|
|
1120
1276
|
channels: {
|
|
1121
1277
|
feishu: {
|
|
1122
|
-
topicSessionMode: "enabled",
|
|
1123
1278
|
groups: {
|
|
1124
1279
|
"oc-group": {
|
|
1125
1280
|
requireMention: false,
|
|
1281
|
+
groupSessionScope: "group_topic_sender",
|
|
1126
1282
|
},
|
|
1127
1283
|
},
|
|
1128
1284
|
},
|
|
@@ -1130,14 +1286,15 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1130
1286
|
} as ClawdbotConfig;
|
|
1131
1287
|
|
|
1132
1288
|
const event: FeishuMessageEvent = {
|
|
1133
|
-
sender: { sender_id: { open_id: "ou-
|
|
1289
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1134
1290
|
message: {
|
|
1135
|
-
message_id: "msg-
|
|
1291
|
+
message_id: "msg-scope-topic-thread-id",
|
|
1136
1292
|
chat_id: "oc-group",
|
|
1137
1293
|
chat_type: "group",
|
|
1138
|
-
root_id: "
|
|
1294
|
+
root_id: "om_root_topic",
|
|
1295
|
+
thread_id: "omt_topic_1",
|
|
1139
1296
|
message_type: "text",
|
|
1140
|
-
content: JSON.stringify({ text: "
|
|
1297
|
+
content: JSON.stringify({ text: "topic sender scope" }),
|
|
1141
1298
|
},
|
|
1142
1299
|
};
|
|
1143
1300
|
|
|
@@ -1145,13 +1302,13 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1145
1302
|
|
|
1146
1303
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
1147
1304
|
expect.objectContaining({
|
|
1148
|
-
peer: { kind: "group", id: "oc-group:topic:
|
|
1305
|
+
peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
|
|
1149
1306
|
parentPeer: { kind: "group", id: "oc-group" },
|
|
1150
1307
|
}),
|
|
1151
1308
|
);
|
|
1152
1309
|
});
|
|
1153
1310
|
|
|
1154
|
-
it("uses
|
|
1311
|
+
it("uses thread_id as topic key when root_id is missing", async () => {
|
|
1155
1312
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1156
1313
|
|
|
1157
1314
|
const cfg: ClawdbotConfig = {
|
|
@@ -1160,8 +1317,7 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1160
1317
|
groups: {
|
|
1161
1318
|
"oc-group": {
|
|
1162
1319
|
requireMention: false,
|
|
1163
|
-
groupSessionScope: "
|
|
1164
|
-
replyInThread: "enabled",
|
|
1320
|
+
groupSessionScope: "group_topic_sender",
|
|
1165
1321
|
},
|
|
1166
1322
|
},
|
|
1167
1323
|
},
|
|
@@ -1169,13 +1325,14 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1169
1325
|
} as ClawdbotConfig;
|
|
1170
1326
|
|
|
1171
1327
|
const event: FeishuMessageEvent = {
|
|
1172
|
-
sender: { sender_id: { open_id: "ou-topic-
|
|
1328
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1173
1329
|
message: {
|
|
1174
|
-
message_id: "msg-
|
|
1330
|
+
message_id: "msg-scope-topic-thread-only",
|
|
1175
1331
|
chat_id: "oc-group",
|
|
1176
1332
|
chat_type: "group",
|
|
1333
|
+
thread_id: "omt_topic_1",
|
|
1177
1334
|
message_type: "text",
|
|
1178
|
-
content: JSON.stringify({ text: "
|
|
1335
|
+
content: JSON.stringify({ text: "topic sender scope" }),
|
|
1179
1336
|
},
|
|
1180
1337
|
};
|
|
1181
1338
|
|
|
@@ -1183,57 +1340,768 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1183
1340
|
|
|
1184
1341
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
1185
1342
|
expect.objectContaining({
|
|
1186
|
-
peer: { kind: "group", id: "oc-group:topic:
|
|
1343
|
+
peer: { kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
|
|
1187
1344
|
parentPeer: { kind: "group", id: "oc-group" },
|
|
1188
1345
|
}),
|
|
1189
1346
|
);
|
|
1190
1347
|
});
|
|
1191
1348
|
|
|
1192
|
-
it("
|
|
1349
|
+
it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
|
|
1193
1350
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1194
1351
|
|
|
1195
1352
|
const cfg: ClawdbotConfig = {
|
|
1196
1353
|
channels: {
|
|
1197
1354
|
feishu: {
|
|
1198
|
-
|
|
1355
|
+
topicSessionMode: "enabled",
|
|
1356
|
+
groups: {
|
|
1357
|
+
"oc-group": {
|
|
1358
|
+
requireMention: false,
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1199
1361
|
},
|
|
1200
1362
|
},
|
|
1201
1363
|
} as ClawdbotConfig;
|
|
1202
1364
|
|
|
1203
1365
|
const event: FeishuMessageEvent = {
|
|
1204
|
-
sender: {
|
|
1205
|
-
|
|
1206
|
-
|
|
1366
|
+
sender: { sender_id: { open_id: "ou-legacy" } },
|
|
1367
|
+
message: {
|
|
1368
|
+
message_id: "msg-legacy-topic-mode",
|
|
1369
|
+
chat_id: "oc-group",
|
|
1370
|
+
chat_type: "group",
|
|
1371
|
+
root_id: "om_root_legacy",
|
|
1372
|
+
message_type: "text",
|
|
1373
|
+
content: JSON.stringify({ text: "legacy topic mode" }),
|
|
1374
|
+
},
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
await dispatchMessage({ cfg, event });
|
|
1378
|
+
|
|
1379
|
+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
1380
|
+
expect.objectContaining({
|
|
1381
|
+
peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
|
|
1382
|
+
parentPeer: { kind: "group", id: "oc-group" },
|
|
1383
|
+
}),
|
|
1384
|
+
);
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it("maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist", async () => {
|
|
1388
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1389
|
+
|
|
1390
|
+
const cfg: ClawdbotConfig = {
|
|
1391
|
+
channels: {
|
|
1392
|
+
feishu: {
|
|
1393
|
+
topicSessionMode: "enabled",
|
|
1394
|
+
groups: {
|
|
1395
|
+
"oc-group": {
|
|
1396
|
+
requireMention: false,
|
|
1397
|
+
},
|
|
1398
|
+
},
|
|
1207
1399
|
},
|
|
1208
1400
|
},
|
|
1401
|
+
} as ClawdbotConfig;
|
|
1402
|
+
|
|
1403
|
+
const event: FeishuMessageEvent = {
|
|
1404
|
+
sender: { sender_id: { open_id: "ou-legacy-thread-id" } },
|
|
1209
1405
|
message: {
|
|
1210
|
-
message_id: "msg-
|
|
1211
|
-
chat_id: "oc-
|
|
1212
|
-
chat_type: "
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
}),
|
|
1406
|
+
message_id: "msg-legacy-topic-thread-id",
|
|
1407
|
+
chat_id: "oc-group",
|
|
1408
|
+
chat_type: "group",
|
|
1409
|
+
root_id: "om_root_legacy",
|
|
1410
|
+
thread_id: "omt_topic_legacy",
|
|
1411
|
+
message_type: "text",
|
|
1412
|
+
content: JSON.stringify({ text: "legacy topic mode" }),
|
|
1217
1413
|
},
|
|
1218
1414
|
};
|
|
1219
1415
|
|
|
1220
|
-
await
|
|
1221
|
-
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1222
|
-
});
|
|
1223
|
-
});
|
|
1416
|
+
await dispatchMessage({ cfg, event });
|
|
1224
1417
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1418
|
+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
1419
|
+
expect.objectContaining({
|
|
1420
|
+
peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
|
|
1421
|
+
parentPeer: { kind: "group", id: "oc-group" },
|
|
1422
|
+
}),
|
|
1423
|
+
);
|
|
1228
1424
|
});
|
|
1229
1425
|
|
|
1230
|
-
it("
|
|
1231
|
-
|
|
1232
|
-
});
|
|
1426
|
+
it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
|
|
1427
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1233
1428
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1429
|
+
const cfg: ClawdbotConfig = {
|
|
1430
|
+
channels: {
|
|
1431
|
+
feishu: {
|
|
1432
|
+
groups: {
|
|
1433
|
+
"oc-group": {
|
|
1434
|
+
requireMention: false,
|
|
1435
|
+
groupSessionScope: "group_topic",
|
|
1436
|
+
replyInThread: "enabled",
|
|
1437
|
+
},
|
|
1438
|
+
},
|
|
1439
|
+
},
|
|
1440
|
+
},
|
|
1441
|
+
} as ClawdbotConfig;
|
|
1442
|
+
|
|
1443
|
+
const event: FeishuMessageEvent = {
|
|
1444
|
+
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
1445
|
+
message: {
|
|
1446
|
+
message_id: "msg-new-topic-root",
|
|
1447
|
+
chat_id: "oc-group",
|
|
1448
|
+
chat_type: "group",
|
|
1449
|
+
message_type: "text",
|
|
1450
|
+
content: JSON.stringify({ text: "create topic" }),
|
|
1451
|
+
},
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
await dispatchMessage({ cfg, event });
|
|
1455
|
+
|
|
1456
|
+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
1457
|
+
expect.objectContaining({
|
|
1458
|
+
peer: { kind: "group", id: "oc-group:topic:msg-new-topic-root" },
|
|
1459
|
+
parentPeer: { kind: "group", id: "oc-group" },
|
|
1460
|
+
}),
|
|
1461
|
+
);
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it("keeps topic session key stable after first turn creates a thread", async () => {
|
|
1465
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1466
|
+
|
|
1467
|
+
const cfg: ClawdbotConfig = {
|
|
1468
|
+
channels: {
|
|
1469
|
+
feishu: {
|
|
1470
|
+
groups: {
|
|
1471
|
+
"oc-group": {
|
|
1472
|
+
requireMention: false,
|
|
1473
|
+
groupSessionScope: "group_topic",
|
|
1474
|
+
replyInThread: "enabled",
|
|
1475
|
+
},
|
|
1476
|
+
},
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
} as ClawdbotConfig;
|
|
1480
|
+
|
|
1481
|
+
const firstTurn: FeishuMessageEvent = {
|
|
1482
|
+
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
1483
|
+
message: {
|
|
1484
|
+
message_id: "msg-topic-first",
|
|
1485
|
+
chat_id: "oc-group",
|
|
1486
|
+
chat_type: "group",
|
|
1487
|
+
message_type: "text",
|
|
1488
|
+
content: JSON.stringify({ text: "create topic" }),
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
const secondTurn: FeishuMessageEvent = {
|
|
1492
|
+
sender: { sender_id: { open_id: "ou-topic-init" } },
|
|
1493
|
+
message: {
|
|
1494
|
+
message_id: "msg-topic-second",
|
|
1495
|
+
chat_id: "oc-group",
|
|
1496
|
+
chat_type: "group",
|
|
1497
|
+
root_id: "msg-topic-first",
|
|
1498
|
+
thread_id: "omt_topic_created",
|
|
1499
|
+
message_type: "text",
|
|
1500
|
+
content: JSON.stringify({ text: "follow up in same topic" }),
|
|
1501
|
+
},
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
await dispatchMessage({ cfg, event: firstTurn });
|
|
1505
|
+
await dispatchMessage({ cfg, event: secondTurn });
|
|
1506
|
+
|
|
1507
|
+
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
|
1508
|
+
1,
|
|
1509
|
+
expect.objectContaining({
|
|
1510
|
+
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
|
|
1511
|
+
}),
|
|
1512
|
+
);
|
|
1513
|
+
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
|
1514
|
+
2,
|
|
1515
|
+
expect.objectContaining({
|
|
1516
|
+
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
|
|
1517
|
+
}),
|
|
1518
|
+
);
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
it("replies to the topic root when handling a message inside an existing topic", async () => {
|
|
1522
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1523
|
+
|
|
1524
|
+
const cfg: ClawdbotConfig = {
|
|
1525
|
+
channels: {
|
|
1526
|
+
feishu: {
|
|
1527
|
+
groups: {
|
|
1528
|
+
"oc-group": {
|
|
1529
|
+
requireMention: false,
|
|
1530
|
+
replyInThread: "enabled",
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
},
|
|
1535
|
+
} as ClawdbotConfig;
|
|
1536
|
+
|
|
1537
|
+
const event: FeishuMessageEvent = {
|
|
1538
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1539
|
+
message: {
|
|
1540
|
+
message_id: "om_child_message",
|
|
1541
|
+
root_id: "om_root_topic",
|
|
1542
|
+
chat_id: "oc-group",
|
|
1543
|
+
chat_type: "group",
|
|
1544
|
+
message_type: "text",
|
|
1545
|
+
content: JSON.stringify({ text: "reply inside topic" }),
|
|
1546
|
+
},
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
await dispatchMessage({ cfg, event });
|
|
1550
|
+
|
|
1551
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
1552
|
+
expect.objectContaining({
|
|
1553
|
+
replyToMessageId: "om_root_topic",
|
|
1554
|
+
rootId: "om_root_topic",
|
|
1555
|
+
}),
|
|
1556
|
+
);
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
it("replies to triggering message in normal group even when root_id is present (#32980)", async () => {
|
|
1560
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1561
|
+
|
|
1562
|
+
const cfg: ClawdbotConfig = {
|
|
1563
|
+
channels: {
|
|
1564
|
+
feishu: {
|
|
1565
|
+
groups: {
|
|
1566
|
+
"oc-group": {
|
|
1567
|
+
requireMention: false,
|
|
1568
|
+
groupSessionScope: "group",
|
|
1569
|
+
},
|
|
1570
|
+
},
|
|
1571
|
+
},
|
|
1572
|
+
},
|
|
1573
|
+
} as ClawdbotConfig;
|
|
1574
|
+
|
|
1575
|
+
const event: FeishuMessageEvent = {
|
|
1576
|
+
sender: { sender_id: { open_id: "ou-normal-user" } },
|
|
1577
|
+
message: {
|
|
1578
|
+
message_id: "om_quote_reply",
|
|
1579
|
+
root_id: "om_original_msg",
|
|
1580
|
+
chat_id: "oc-group",
|
|
1581
|
+
chat_type: "group",
|
|
1582
|
+
message_type: "text",
|
|
1583
|
+
content: JSON.stringify({ text: "hello in normal group" }),
|
|
1584
|
+
},
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
await dispatchMessage({ cfg, event });
|
|
1588
|
+
|
|
1589
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
1590
|
+
expect.objectContaining({
|
|
1591
|
+
replyToMessageId: "om_quote_reply",
|
|
1592
|
+
rootId: "om_original_msg",
|
|
1593
|
+
}),
|
|
1594
|
+
);
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
it("replies to topic root in topic-mode group with root_id", async () => {
|
|
1598
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1599
|
+
|
|
1600
|
+
const cfg: ClawdbotConfig = {
|
|
1601
|
+
channels: {
|
|
1602
|
+
feishu: {
|
|
1603
|
+
groups: {
|
|
1604
|
+
"oc-group": {
|
|
1605
|
+
requireMention: false,
|
|
1606
|
+
groupSessionScope: "group_topic",
|
|
1607
|
+
},
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
},
|
|
1611
|
+
} as ClawdbotConfig;
|
|
1612
|
+
|
|
1613
|
+
const event: FeishuMessageEvent = {
|
|
1614
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1615
|
+
message: {
|
|
1616
|
+
message_id: "om_topic_reply",
|
|
1617
|
+
root_id: "om_topic_root",
|
|
1618
|
+
chat_id: "oc-group",
|
|
1619
|
+
chat_type: "group",
|
|
1620
|
+
message_type: "text",
|
|
1621
|
+
content: JSON.stringify({ text: "hello in topic group" }),
|
|
1622
|
+
},
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
await dispatchMessage({ cfg, event });
|
|
1626
|
+
|
|
1627
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
1628
|
+
expect.objectContaining({
|
|
1629
|
+
replyToMessageId: "om_topic_root",
|
|
1630
|
+
rootId: "om_topic_root",
|
|
1631
|
+
}),
|
|
1632
|
+
);
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
it("replies to topic root in topic-sender group with root_id", async () => {
|
|
1636
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1637
|
+
|
|
1638
|
+
const cfg: ClawdbotConfig = {
|
|
1639
|
+
channels: {
|
|
1640
|
+
feishu: {
|
|
1641
|
+
groups: {
|
|
1642
|
+
"oc-group": {
|
|
1643
|
+
requireMention: false,
|
|
1644
|
+
groupSessionScope: "group_topic_sender",
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
},
|
|
1648
|
+
},
|
|
1649
|
+
} as ClawdbotConfig;
|
|
1650
|
+
|
|
1651
|
+
const event: FeishuMessageEvent = {
|
|
1652
|
+
sender: { sender_id: { open_id: "ou-topic-sender-user" } },
|
|
1653
|
+
message: {
|
|
1654
|
+
message_id: "om_topic_sender_reply",
|
|
1655
|
+
root_id: "om_topic_sender_root",
|
|
1656
|
+
chat_id: "oc-group",
|
|
1657
|
+
chat_type: "group",
|
|
1658
|
+
message_type: "text",
|
|
1659
|
+
content: JSON.stringify({ text: "hello in topic sender group" }),
|
|
1660
|
+
},
|
|
1661
|
+
};
|
|
1662
|
+
|
|
1663
|
+
await dispatchMessage({ cfg, event });
|
|
1664
|
+
|
|
1665
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
1666
|
+
expect.objectContaining({
|
|
1667
|
+
replyToMessageId: "om_topic_sender_root",
|
|
1668
|
+
rootId: "om_topic_sender_root",
|
|
1669
|
+
}),
|
|
1670
|
+
);
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
it("forces thread replies when inbound message contains thread_id", async () => {
|
|
1674
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1675
|
+
|
|
1676
|
+
const cfg: ClawdbotConfig = {
|
|
1677
|
+
channels: {
|
|
1678
|
+
feishu: {
|
|
1679
|
+
groups: {
|
|
1680
|
+
"oc-group": {
|
|
1681
|
+
requireMention: false,
|
|
1682
|
+
groupSessionScope: "group",
|
|
1683
|
+
replyInThread: "disabled",
|
|
1684
|
+
},
|
|
1685
|
+
},
|
|
1686
|
+
},
|
|
1687
|
+
},
|
|
1688
|
+
} as ClawdbotConfig;
|
|
1689
|
+
|
|
1690
|
+
const event: FeishuMessageEvent = {
|
|
1691
|
+
sender: { sender_id: { open_id: "ou-thread-reply" } },
|
|
1692
|
+
message: {
|
|
1693
|
+
message_id: "msg-thread-reply",
|
|
1694
|
+
chat_id: "oc-group",
|
|
1695
|
+
chat_type: "group",
|
|
1696
|
+
thread_id: "omt_topic_thread_reply",
|
|
1697
|
+
message_type: "text",
|
|
1698
|
+
content: JSON.stringify({ text: "thread content" }),
|
|
1699
|
+
},
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
await dispatchMessage({ cfg, event });
|
|
1703
|
+
|
|
1704
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
1705
|
+
expect.objectContaining({
|
|
1706
|
+
replyInThread: true,
|
|
1707
|
+
threadReply: true,
|
|
1708
|
+
}),
|
|
1709
|
+
);
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
|
|
1713
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1714
|
+
|
|
1715
|
+
const cfg: ClawdbotConfig = {
|
|
1716
|
+
channels: {
|
|
1717
|
+
feishu: {
|
|
1718
|
+
dmPolicy: "open",
|
|
1719
|
+
},
|
|
1720
|
+
},
|
|
1721
|
+
} as ClawdbotConfig;
|
|
1722
|
+
|
|
1723
|
+
const event: FeishuMessageEvent = {
|
|
1724
|
+
sender: {
|
|
1725
|
+
sender_id: {
|
|
1726
|
+
open_id: "ou-image-dedup",
|
|
1727
|
+
},
|
|
1728
|
+
},
|
|
1729
|
+
message: {
|
|
1730
|
+
message_id: "msg-image-dedup",
|
|
1731
|
+
chat_id: "oc-dm",
|
|
1732
|
+
chat_type: "p2p",
|
|
1733
|
+
message_type: "image",
|
|
1734
|
+
content: JSON.stringify({
|
|
1735
|
+
image_key: "img_dedup_payload",
|
|
1736
|
+
}),
|
|
1737
|
+
},
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
|
|
1741
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1742
|
+
});
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
describe("toMessageResourceType", () => {
|
|
1746
|
+
it("maps image to image", () => {
|
|
1747
|
+
expect(toMessageResourceType("image")).toBe("image");
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
it("maps audio to file", () => {
|
|
1751
|
+
expect(toMessageResourceType("audio")).toBe("file");
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
it("maps video/file/sticker to file", () => {
|
|
1755
|
+
expect(toMessageResourceType("video")).toBe("file");
|
|
1756
|
+
expect(toMessageResourceType("file")).toBe("file");
|
|
1757
|
+
expect(toMessageResourceType("sticker")).toBe("file");
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
describe("resolveBroadcastAgents", () => {
|
|
1762
|
+
it("returns agent list when broadcast config has the peerId", () => {
|
|
1763
|
+
const cfg = { broadcast: { oc_group123: ["susan", "main"] } } as unknown as ClawdbotConfig;
|
|
1764
|
+
expect(resolveBroadcastAgents(cfg, "oc_group123")).toEqual(["susan", "main"]);
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
it("returns null when no broadcast config", () => {
|
|
1768
|
+
const cfg = {} as ClawdbotConfig;
|
|
1769
|
+
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
it("returns null when peerId not in broadcast", () => {
|
|
1773
|
+
const cfg = { broadcast: { oc_other: ["susan"] } } as unknown as ClawdbotConfig;
|
|
1774
|
+
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
it("returns null when agent list is empty", () => {
|
|
1778
|
+
const cfg = { broadcast: { oc_group123: [] } } as unknown as ClawdbotConfig;
|
|
1779
|
+
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
|
|
1780
|
+
});
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
describe("buildBroadcastSessionKey", () => {
|
|
1784
|
+
it("replaces agent ID prefix in session key", () => {
|
|
1785
|
+
expect(buildBroadcastSessionKey("agent:main:feishu:group:oc_group123", "main", "susan")).toBe(
|
|
1786
|
+
"agent:susan:feishu:group:oc_group123",
|
|
1787
|
+
);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
it("handles compound peer IDs", () => {
|
|
1791
|
+
expect(
|
|
1792
|
+
buildBroadcastSessionKey(
|
|
1793
|
+
"agent:main:feishu:group:oc_group123:sender:ou_user1",
|
|
1794
|
+
"main",
|
|
1795
|
+
"susan",
|
|
1796
|
+
),
|
|
1797
|
+
).toBe("agent:susan:feishu:group:oc_group123:sender:ou_user1");
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
it("returns base key unchanged when prefix does not match", () => {
|
|
1801
|
+
expect(buildBroadcastSessionKey("custom:key:format", "main", "susan")).toBe(
|
|
1802
|
+
"custom:key:format",
|
|
1803
|
+
);
|
|
1804
|
+
});
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
describe("broadcast dispatch", () => {
|
|
1808
|
+
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
|
1809
|
+
const mockDispatchReplyFromConfig = vi
|
|
1810
|
+
.fn()
|
|
1811
|
+
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
|
1812
|
+
const mockWithReplyDispatcher = vi.fn(
|
|
1813
|
+
async ({
|
|
1814
|
+
dispatcher,
|
|
1815
|
+
run,
|
|
1816
|
+
onSettled,
|
|
1817
|
+
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
|
|
1818
|
+
try {
|
|
1819
|
+
return await run();
|
|
1820
|
+
} finally {
|
|
1821
|
+
dispatcher.markComplete();
|
|
1822
|
+
try {
|
|
1823
|
+
await dispatcher.waitForIdle();
|
|
1824
|
+
} finally {
|
|
1825
|
+
await onSettled?.();
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
);
|
|
1830
|
+
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
|
|
1831
|
+
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
|
1832
|
+
path: "/tmp/inbound-clip.mp4",
|
|
1833
|
+
contentType: "video/mp4",
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
beforeEach(() => {
|
|
1837
|
+
vi.clearAllMocks();
|
|
1838
|
+
mockResolveAgentRoute.mockReturnValue({
|
|
1839
|
+
agentId: "main",
|
|
1840
|
+
channel: "feishu",
|
|
1841
|
+
accountId: "default",
|
|
1842
|
+
sessionKey: "agent:main:feishu:group:oc-broadcast-group",
|
|
1843
|
+
mainSessionKey: "agent:main:main",
|
|
1844
|
+
matchedBy: "default",
|
|
1845
|
+
});
|
|
1846
|
+
mockCreateFeishuClient.mockReturnValue({
|
|
1847
|
+
contact: {
|
|
1848
|
+
user: {
|
|
1849
|
+
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
|
|
1850
|
+
},
|
|
1851
|
+
},
|
|
1852
|
+
});
|
|
1853
|
+
setFeishuRuntime({
|
|
1854
|
+
system: {
|
|
1855
|
+
enqueueSystemEvent: vi.fn(),
|
|
1856
|
+
},
|
|
1857
|
+
channel: {
|
|
1858
|
+
routing: {
|
|
1859
|
+
resolveAgentRoute: mockResolveAgentRoute,
|
|
1860
|
+
},
|
|
1861
|
+
reply: {
|
|
1862
|
+
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
|
|
1863
|
+
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
1864
|
+
finalizeInboundContext: mockFinalizeInboundContext,
|
|
1865
|
+
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
1866
|
+
withReplyDispatcher: mockWithReplyDispatcher,
|
|
1867
|
+
},
|
|
1868
|
+
commands: {
|
|
1869
|
+
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
|
1870
|
+
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
|
1871
|
+
},
|
|
1872
|
+
media: {
|
|
1873
|
+
saveMediaBuffer: mockSaveMediaBuffer,
|
|
1874
|
+
},
|
|
1875
|
+
pairing: {
|
|
1876
|
+
readAllowFromStore: vi.fn().mockResolvedValue([]),
|
|
1877
|
+
upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }),
|
|
1878
|
+
buildPairingReply: vi.fn(() => "Pairing response"),
|
|
1879
|
+
},
|
|
1880
|
+
},
|
|
1881
|
+
media: {
|
|
1882
|
+
detectMime: vi.fn(async () => "application/octet-stream"),
|
|
1883
|
+
},
|
|
1884
|
+
} as unknown as PluginRuntime);
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
it("dispatches to all broadcast agents when bot is mentioned", async () => {
|
|
1888
|
+
const cfg: ClawdbotConfig = {
|
|
1889
|
+
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
|
1890
|
+
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
1891
|
+
channels: {
|
|
1892
|
+
feishu: {
|
|
1893
|
+
groups: {
|
|
1894
|
+
"oc-broadcast-group": {
|
|
1895
|
+
requireMention: true,
|
|
1896
|
+
},
|
|
1897
|
+
},
|
|
1898
|
+
},
|
|
1899
|
+
},
|
|
1900
|
+
} as unknown as ClawdbotConfig;
|
|
1901
|
+
|
|
1902
|
+
const event: FeishuMessageEvent = {
|
|
1903
|
+
sender: { sender_id: { open_id: "ou-sender" } },
|
|
1904
|
+
message: {
|
|
1905
|
+
message_id: "msg-broadcast-mentioned",
|
|
1906
|
+
chat_id: "oc-broadcast-group",
|
|
1907
|
+
chat_type: "group",
|
|
1908
|
+
message_type: "text",
|
|
1909
|
+
content: JSON.stringify({ text: "hello @bot" }),
|
|
1910
|
+
mentions: [
|
|
1911
|
+
{ key: "@_user_1", id: { open_id: "bot-open-id" }, name: "Bot", tenant_key: "" },
|
|
1912
|
+
],
|
|
1913
|
+
},
|
|
1914
|
+
};
|
|
1915
|
+
|
|
1916
|
+
await handleFeishuMessage({
|
|
1917
|
+
cfg,
|
|
1918
|
+
event,
|
|
1919
|
+
botOpenId: "bot-open-id",
|
|
1920
|
+
runtime: createRuntimeEnv(),
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
// Both agents should get dispatched
|
|
1924
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
|
1925
|
+
|
|
1926
|
+
// Verify session keys for both agents
|
|
1927
|
+
const sessionKeys = mockFinalizeInboundContext.mock.calls.map(
|
|
1928
|
+
(call: unknown[]) => (call[0] as { SessionKey: string }).SessionKey,
|
|
1929
|
+
);
|
|
1930
|
+
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
|
|
1931
|
+
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
|
|
1932
|
+
|
|
1933
|
+
// Active agent (mentioned) gets the real Feishu reply dispatcher
|
|
1934
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
|
|
1935
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
1936
|
+
expect.objectContaining({ agentId: "main" }),
|
|
1937
|
+
);
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
|
|
1941
|
+
const cfg: ClawdbotConfig = {
|
|
1942
|
+
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
|
1943
|
+
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
1944
|
+
channels: {
|
|
1945
|
+
feishu: {
|
|
1946
|
+
groups: {
|
|
1947
|
+
"oc-broadcast-group": {
|
|
1948
|
+
requireMention: true,
|
|
1949
|
+
},
|
|
1950
|
+
},
|
|
1951
|
+
},
|
|
1952
|
+
},
|
|
1953
|
+
} as unknown as ClawdbotConfig;
|
|
1954
|
+
|
|
1955
|
+
const event: FeishuMessageEvent = {
|
|
1956
|
+
sender: { sender_id: { open_id: "ou-sender" } },
|
|
1957
|
+
message: {
|
|
1958
|
+
message_id: "msg-broadcast-not-mentioned",
|
|
1959
|
+
chat_id: "oc-broadcast-group",
|
|
1960
|
+
chat_type: "group",
|
|
1961
|
+
message_type: "text",
|
|
1962
|
+
content: JSON.stringify({ text: "hello everyone" }),
|
|
1963
|
+
},
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
await handleFeishuMessage({
|
|
1967
|
+
cfg,
|
|
1968
|
+
event,
|
|
1969
|
+
runtime: createRuntimeEnv(),
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
// No dispatch: requireMention=true and bot not mentioned → returns early.
|
|
1973
|
+
// The mentioned bot's handler (on another account or same account with
|
|
1974
|
+
// matching botOpenId) will handle broadcast dispatch for all agents.
|
|
1975
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
1976
|
+
expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled();
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
it("preserves single-agent dispatch when no broadcast config", async () => {
|
|
1980
|
+
const cfg: ClawdbotConfig = {
|
|
1981
|
+
channels: {
|
|
1982
|
+
feishu: {
|
|
1983
|
+
groups: {
|
|
1984
|
+
"oc-broadcast-group": {
|
|
1985
|
+
requireMention: false,
|
|
1986
|
+
},
|
|
1987
|
+
},
|
|
1988
|
+
},
|
|
1989
|
+
},
|
|
1990
|
+
} as ClawdbotConfig;
|
|
1991
|
+
|
|
1992
|
+
const event: FeishuMessageEvent = {
|
|
1993
|
+
sender: { sender_id: { open_id: "ou-sender" } },
|
|
1994
|
+
message: {
|
|
1995
|
+
message_id: "msg-no-broadcast",
|
|
1996
|
+
chat_id: "oc-broadcast-group",
|
|
1997
|
+
chat_type: "group",
|
|
1998
|
+
message_type: "text",
|
|
1999
|
+
content: JSON.stringify({ text: "hello" }),
|
|
2000
|
+
},
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
await handleFeishuMessage({
|
|
2004
|
+
cfg,
|
|
2005
|
+
event,
|
|
2006
|
+
runtime: createRuntimeEnv(),
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
// Single dispatch (no broadcast)
|
|
2010
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
2011
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
|
|
2012
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
2013
|
+
expect.objectContaining({
|
|
2014
|
+
SessionKey: "agent:main:feishu:group:oc-broadcast-group",
|
|
2015
|
+
}),
|
|
2016
|
+
);
|
|
2017
|
+
});
|
|
2018
|
+
|
|
2019
|
+
it("cross-account broadcast dedup: second account skips dispatch", async () => {
|
|
2020
|
+
const cfg: ClawdbotConfig = {
|
|
2021
|
+
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
|
2022
|
+
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
2023
|
+
channels: {
|
|
2024
|
+
feishu: {
|
|
2025
|
+
groups: {
|
|
2026
|
+
"oc-broadcast-group": {
|
|
2027
|
+
requireMention: false,
|
|
2028
|
+
},
|
|
2029
|
+
},
|
|
2030
|
+
},
|
|
2031
|
+
},
|
|
2032
|
+
} as unknown as ClawdbotConfig;
|
|
2033
|
+
|
|
2034
|
+
const event: FeishuMessageEvent = {
|
|
2035
|
+
sender: { sender_id: { open_id: "ou-sender" } },
|
|
2036
|
+
message: {
|
|
2037
|
+
message_id: "msg-multi-account-dedup",
|
|
2038
|
+
chat_id: "oc-broadcast-group",
|
|
2039
|
+
chat_type: "group",
|
|
2040
|
+
message_type: "text",
|
|
2041
|
+
content: JSON.stringify({ text: "hello" }),
|
|
2042
|
+
},
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
// First account handles broadcast normally
|
|
2046
|
+
await handleFeishuMessage({
|
|
2047
|
+
cfg,
|
|
2048
|
+
event,
|
|
2049
|
+
runtime: createRuntimeEnv(),
|
|
2050
|
+
accountId: "account-A",
|
|
2051
|
+
});
|
|
2052
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
|
2053
|
+
|
|
2054
|
+
mockDispatchReplyFromConfig.mockClear();
|
|
2055
|
+
mockFinalizeInboundContext.mockClear();
|
|
2056
|
+
|
|
2057
|
+
// Second account: same message ID, different account.
|
|
2058
|
+
// Per-account dedup passes (different namespace), but cross-account
|
|
2059
|
+
// broadcast dedup blocks dispatch.
|
|
2060
|
+
await handleFeishuMessage({
|
|
2061
|
+
cfg,
|
|
2062
|
+
event,
|
|
2063
|
+
runtime: createRuntimeEnv(),
|
|
2064
|
+
accountId: "account-B",
|
|
2065
|
+
});
|
|
2066
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
it("skips unknown agents not in agents.list", async () => {
|
|
2070
|
+
const cfg: ClawdbotConfig = {
|
|
2071
|
+
broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] },
|
|
2072
|
+
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
2073
|
+
channels: {
|
|
2074
|
+
feishu: {
|
|
2075
|
+
groups: {
|
|
2076
|
+
"oc-broadcast-group": {
|
|
2077
|
+
requireMention: false,
|
|
2078
|
+
},
|
|
2079
|
+
},
|
|
2080
|
+
},
|
|
2081
|
+
},
|
|
2082
|
+
} as unknown as ClawdbotConfig;
|
|
2083
|
+
|
|
2084
|
+
const event: FeishuMessageEvent = {
|
|
2085
|
+
sender: { sender_id: { open_id: "ou-sender" } },
|
|
2086
|
+
message: {
|
|
2087
|
+
message_id: "msg-broadcast-unknown-agent",
|
|
2088
|
+
chat_id: "oc-broadcast-group",
|
|
2089
|
+
chat_type: "group",
|
|
2090
|
+
message_type: "text",
|
|
2091
|
+
content: JSON.stringify({ text: "hello" }),
|
|
2092
|
+
},
|
|
2093
|
+
};
|
|
2094
|
+
|
|
2095
|
+
await handleFeishuMessage({
|
|
2096
|
+
cfg,
|
|
2097
|
+
event,
|
|
2098
|
+
runtime: createRuntimeEnv(),
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
// Only susan should get dispatched (unknown-agent skipped)
|
|
2102
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
2103
|
+
const sessionKey = (mockFinalizeInboundContext.mock.calls[0]?.[0] as { SessionKey: string })
|
|
2104
|
+
.SessionKey;
|
|
2105
|
+
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
|
|
1238
2106
|
});
|
|
1239
2107
|
});
|