@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.
Files changed (76) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. 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 { buildFeishuAgentBody, handleFeishuMessage, toMessageResourceType } from "./bot.js";
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
- system: {
148
- enqueueSystemEvent: mockEnqueueSystemEvent,
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
- commands: {
162
- shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
163
- resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
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
- saveMediaBuffer: mockSaveMediaBuffer,
167
- },
168
- pairing: {
169
- readAllowFromStore: mockReadAllowFromStore,
170
- upsertPairingRequest: mockUpsertPairingRequest,
171
- buildPairingReply: mockBuildPairingReply,
195
+ detectMime: vi.fn(async () => "application/octet-stream"),
172
196
  },
173
- },
174
- media: {
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: "user:ou-unapproved",
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("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
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-legacy" } },
1289
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1134
1290
  message: {
1135
- message_id: "msg-legacy-topic-mode",
1291
+ message_id: "msg-scope-topic-thread-id",
1136
1292
  chat_id: "oc-group",
1137
1293
  chat_type: "group",
1138
- root_id: "om_root_legacy",
1294
+ root_id: "om_root_topic",
1295
+ thread_id: "omt_topic_1",
1139
1296
  message_type: "text",
1140
- content: JSON.stringify({ text: "legacy topic mode" }),
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:om_root_legacy" },
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 message_id as topic root when group_topic + replyInThread and no root_id", async () => {
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: "group_topic",
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-init" } },
1328
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1173
1329
  message: {
1174
- message_id: "msg-new-topic-root",
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: "create topic" }),
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:msg-new-topic-root" },
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("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
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
- dmPolicy: "open",
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
- sender_id: {
1206
- open_id: "ou-image-dedup",
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-image-dedup",
1211
- chat_id: "oc-dm",
1212
- chat_type: "p2p",
1213
- message_type: "image",
1214
- content: JSON.stringify({
1215
- image_key: "img_dedup_payload",
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 Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
1221
- expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1222
- });
1223
- });
1416
+ await dispatchMessage({ cfg, event });
1224
1417
 
1225
- describe("toMessageResourceType", () => {
1226
- it("maps image to image", () => {
1227
- expect(toMessageResourceType("image")).toBe("image");
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("maps audio to file", () => {
1231
- expect(toMessageResourceType("audio")).toBe("file");
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
- it("maps video/file/sticker to file", () => {
1235
- expect(toMessageResourceType("video")).toBe("file");
1236
- expect(toMessageResourceType("file")).toBe("file");
1237
- expect(toMessageResourceType("sticker")).toBe("file");
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
  });