@openclaw/feishu 2026.2.25 → 2026.3.2

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 (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
package/src/bot.test.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
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 { handleFeishuMessage } from "./bot.js";
5
+ import { buildFeishuAgentBody, handleFeishuMessage, toMessageResourceType } from "./bot.js";
5
6
  import { setFeishuRuntime } from "./runtime.js";
6
7
 
7
8
  const {
@@ -9,6 +10,8 @@ const {
9
10
  mockSendMessageFeishu,
10
11
  mockGetMessageFeishu,
11
12
  mockDownloadMessageResourceFeishu,
13
+ mockCreateFeishuClient,
14
+ mockResolveAgentRoute,
12
15
  } = vi.hoisted(() => ({
13
16
  mockCreateFeishuReplyDispatcher: vi.fn(() => ({
14
17
  dispatcher: vi.fn(),
@@ -22,6 +25,15 @@ const {
22
25
  contentType: "video/mp4",
23
26
  fileName: "clip.mp4",
24
27
  }),
28
+ mockCreateFeishuClient: vi.fn(),
29
+ mockResolveAgentRoute: vi.fn(() => ({
30
+ agentId: "main",
31
+ channel: "feishu",
32
+ accountId: "default",
33
+ sessionKey: "agent:main:feishu:dm:ou-attacker",
34
+ mainSessionKey: "agent:main:main",
35
+ matchedBy: "default",
36
+ })),
25
37
  }));
26
38
 
27
39
  vi.mock("./reply-dispatcher.js", () => ({
@@ -37,6 +49,10 @@ vi.mock("./media.js", () => ({
37
49
  downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
38
50
  }));
39
51
 
52
+ vi.mock("./client.js", () => ({
53
+ createFeishuClient: mockCreateFeishuClient,
54
+ }));
55
+
40
56
  function createRuntimeEnv(): RuntimeEnv {
41
57
  return {
42
58
  log: vi.fn(),
@@ -55,59 +71,156 @@ async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessa
55
71
  });
56
72
  }
57
73
 
74
+ describe("buildFeishuAgentBody", () => {
75
+ it("builds message id, speaker, quoted content, mentions, and permission notice in order", () => {
76
+ const body = buildFeishuAgentBody({
77
+ ctx: {
78
+ content: "hello world",
79
+ senderName: "Sender Name",
80
+ senderOpenId: "ou-sender",
81
+ messageId: "msg-42",
82
+ mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
83
+ },
84
+ quotedContent: "previous message",
85
+ permissionErrorForAgent: {
86
+ code: 99991672,
87
+ message: "permission denied",
88
+ grantUrl: "https://open.feishu.cn/app/cli_test",
89
+ },
90
+ });
91
+
92
+ expect(body).toBe(
93
+ '[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]',
94
+ );
95
+ });
96
+ });
97
+
58
98
  describe("handleFeishuMessage command authorization", () => {
59
99
  const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
60
100
  const mockDispatchReplyFromConfig = vi
61
101
  .fn()
62
102
  .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
103
+ const mockWithReplyDispatcher = vi.fn(
104
+ async ({
105
+ dispatcher,
106
+ run,
107
+ onSettled,
108
+ }: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
109
+ try {
110
+ return await run();
111
+ } finally {
112
+ dispatcher.markComplete();
113
+ try {
114
+ await dispatcher.waitForIdle();
115
+ } finally {
116
+ await onSettled?.();
117
+ }
118
+ }
119
+ },
120
+ );
63
121
  const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
64
122
  const mockShouldComputeCommandAuthorized = vi.fn(() => true);
65
123
  const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
66
124
  const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
67
125
  const mockBuildPairingReply = vi.fn(() => "Pairing response");
126
+ const mockEnqueueSystemEvent = vi.fn();
68
127
  const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
128
+ id: "inbound-clip.mp4",
69
129
  path: "/tmp/inbound-clip.mp4",
130
+ size: Buffer.byteLength("video"),
70
131
  contentType: "video/mp4",
71
132
  });
72
133
 
73
134
  beforeEach(() => {
74
135
  vi.clearAllMocks();
75
- setFeishuRuntime({
76
- system: {
77
- enqueueSystemEvent: vi.fn(),
78
- },
79
- channel: {
80
- routing: {
81
- resolveAgentRoute: vi.fn(() => ({
82
- agentId: "main",
83
- accountId: "default",
84
- sessionKey: "agent:main:feishu:dm:ou-attacker",
85
- matchedBy: "default",
86
- })),
87
- },
88
- reply: {
89
- resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
90
- formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
91
- finalizeInboundContext: mockFinalizeInboundContext,
92
- dispatchReplyFromConfig: mockDispatchReplyFromConfig,
93
- },
94
- commands: {
95
- shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
96
- resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
136
+ mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
137
+ mockResolveAgentRoute.mockReturnValue({
138
+ agentId: "main",
139
+ channel: "feishu",
140
+ accountId: "default",
141
+ sessionKey: "agent:main:feishu:dm:ou-attacker",
142
+ mainSessionKey: "agent:main:main",
143
+ matchedBy: "default",
144
+ });
145
+ mockCreateFeishuClient.mockReturnValue({
146
+ contact: {
147
+ user: {
148
+ get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
149
+ },
150
+ },
151
+ });
152
+ mockEnqueueSystemEvent.mockReset();
153
+ setFeishuRuntime(
154
+ createPluginRuntimeMock({
155
+ system: {
156
+ enqueueSystemEvent: mockEnqueueSystemEvent,
157
+ },
158
+ channel: {
159
+ routing: {
160
+ resolveAgentRoute:
161
+ mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
162
+ },
163
+ reply: {
164
+ resolveEnvelopeFormatOptions: vi.fn(
165
+ () => ({}),
166
+ ) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
167
+ formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
168
+ finalizeInboundContext:
169
+ mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
170
+ dispatchReplyFromConfig: mockDispatchReplyFromConfig,
171
+ withReplyDispatcher:
172
+ mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
173
+ },
174
+ commands: {
175
+ shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
176
+ resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
177
+ },
178
+ media: {
179
+ saveMediaBuffer:
180
+ mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
181
+ },
182
+ pairing: {
183
+ readAllowFromStore: mockReadAllowFromStore,
184
+ upsertPairingRequest: mockUpsertPairingRequest,
185
+ buildPairingReply: mockBuildPairingReply,
186
+ },
97
187
  },
98
188
  media: {
99
- saveMediaBuffer: mockSaveMediaBuffer,
189
+ detectMime: vi.fn(async () => "application/octet-stream"),
190
+ },
191
+ }),
192
+ );
193
+ });
194
+
195
+ it("does not enqueue inbound preview text as system events", async () => {
196
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
197
+
198
+ const cfg: ClawdbotConfig = {
199
+ channels: {
200
+ feishu: {
201
+ dmPolicy: "open",
100
202
  },
101
- pairing: {
102
- readAllowFromStore: mockReadAllowFromStore,
103
- upsertPairingRequest: mockUpsertPairingRequest,
104
- buildPairingReply: mockBuildPairingReply,
203
+ },
204
+ } as ClawdbotConfig;
205
+
206
+ const event: FeishuMessageEvent = {
207
+ sender: {
208
+ sender_id: {
209
+ open_id: "ou-attacker",
105
210
  },
106
211
  },
107
- media: {
108
- detectMime: vi.fn(async () => "application/octet-stream"),
212
+ message: {
213
+ message_id: "msg-no-system-preview",
214
+ chat_id: "oc-dm",
215
+ chat_type: "p2p",
216
+ message_type: "text",
217
+ content: JSON.stringify({ text: "hi there" }),
109
218
  },
110
- } as unknown as PluginRuntime);
219
+ };
220
+
221
+ await dispatchMessage({ cfg, event });
222
+
223
+ expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
111
224
  });
112
225
 
113
226
  it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
@@ -183,12 +296,126 @@ describe("handleFeishuMessage command authorization", () => {
183
296
 
184
297
  await dispatchMessage({ cfg, event });
185
298
 
186
- expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
299
+ expect(mockReadAllowFromStore).toHaveBeenCalledWith({
300
+ channel: "feishu",
301
+ accountId: "default",
302
+ });
187
303
  expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
188
304
  expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
189
305
  expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
190
306
  });
191
307
 
308
+ it("skips sender-name lookup when resolveSenderNames is false", async () => {
309
+ const cfg: ClawdbotConfig = {
310
+ channels: {
311
+ feishu: {
312
+ dmPolicy: "open",
313
+ allowFrom: ["*"],
314
+ resolveSenderNames: false,
315
+ },
316
+ },
317
+ } as ClawdbotConfig;
318
+
319
+ const event: FeishuMessageEvent = {
320
+ sender: {
321
+ sender_id: {
322
+ open_id: "ou-attacker",
323
+ },
324
+ },
325
+ message: {
326
+ message_id: "msg-skip-sender-lookup",
327
+ chat_id: "oc-dm",
328
+ chat_type: "p2p",
329
+ message_type: "text",
330
+ content: JSON.stringify({ text: "hello" }),
331
+ },
332
+ };
333
+
334
+ await dispatchMessage({ cfg, event });
335
+
336
+ expect(mockCreateFeishuClient).not.toHaveBeenCalled();
337
+ });
338
+
339
+ it("propagates parent/root message ids into inbound context for reply reconstruction", async () => {
340
+ mockGetMessageFeishu.mockResolvedValueOnce({
341
+ messageId: "om_parent_001",
342
+ chatId: "oc-group",
343
+ content: "quoted content",
344
+ contentType: "text",
345
+ });
346
+
347
+ const cfg: ClawdbotConfig = {
348
+ channels: {
349
+ feishu: {
350
+ enabled: true,
351
+ dmPolicy: "open",
352
+ },
353
+ },
354
+ } as ClawdbotConfig;
355
+
356
+ const event: FeishuMessageEvent = {
357
+ sender: {
358
+ sender_id: {
359
+ open_id: "ou-replier",
360
+ },
361
+ },
362
+ message: {
363
+ message_id: "om_reply_001",
364
+ root_id: "om_root_001",
365
+ parent_id: "om_parent_001",
366
+ chat_id: "oc-dm",
367
+ chat_type: "p2p",
368
+ message_type: "text",
369
+ content: JSON.stringify({ text: "reply text" }),
370
+ },
371
+ };
372
+
373
+ await dispatchMessage({ cfg, event });
374
+
375
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
376
+ expect.objectContaining({
377
+ ReplyToId: "om_parent_001",
378
+ RootMessageId: "om_root_001",
379
+ ReplyToBody: "quoted content",
380
+ }),
381
+ );
382
+ });
383
+
384
+ it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
385
+ const cfg: ClawdbotConfig = {
386
+ channels: {
387
+ feishu: {
388
+ dmPolicy: "pairing",
389
+ },
390
+ },
391
+ } as ClawdbotConfig;
392
+
393
+ const event: FeishuMessageEvent = {
394
+ sender: {
395
+ sender_id: {
396
+ user_id: "u_mobile_only",
397
+ },
398
+ },
399
+ message: {
400
+ message_id: "msg-pairing-chat-reply",
401
+ chat_id: "oc_dm_chat_1",
402
+ chat_type: "p2p",
403
+ message_type: "text",
404
+ content: JSON.stringify({ text: "hello" }),
405
+ },
406
+ };
407
+
408
+ mockReadAllowFromStore.mockResolvedValue([]);
409
+ mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
410
+
411
+ await dispatchMessage({ cfg, event });
412
+
413
+ expect(mockSendMessageFeishu).toHaveBeenCalledWith(
414
+ expect.objectContaining({
415
+ to: "chat:oc_dm_chat_1",
416
+ }),
417
+ );
418
+ });
192
419
  it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
193
420
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
194
421
  mockReadAllowFromStore.mockResolvedValue([]);
@@ -222,6 +449,7 @@ describe("handleFeishuMessage command authorization", () => {
222
449
 
223
450
  expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
224
451
  channel: "feishu",
452
+ accountId: "default",
225
453
  id: "ou-unapproved",
226
454
  meta: { name: undefined },
227
455
  });
@@ -232,7 +460,7 @@ describe("handleFeishuMessage command authorization", () => {
232
460
  });
233
461
  expect(mockSendMessageFeishu).toHaveBeenCalledWith(
234
462
  expect.objectContaining({
235
- to: "user:ou-unapproved",
463
+ to: "chat:oc-dm",
236
464
  accountId: "default",
237
465
  }),
238
466
  );
@@ -335,13 +563,19 @@ describe("handleFeishuMessage command authorization", () => {
335
563
  );
336
564
  });
337
565
 
338
- it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
566
+ it("allows group sender when global groupSenderAllowFrom includes sender", async () => {
339
567
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
340
568
 
341
569
  const cfg: ClawdbotConfig = {
342
570
  channels: {
343
571
  feishu: {
344
- dmPolicy: "open",
572
+ groupPolicy: "open",
573
+ groupSenderAllowFrom: ["ou-allowed"],
574
+ groups: {
575
+ "oc-group": {
576
+ requireMention: false,
577
+ },
578
+ },
345
579
  },
346
580
  },
347
581
  } as ClawdbotConfig;
@@ -349,37 +583,1018 @@ describe("handleFeishuMessage command authorization", () => {
349
583
  const event: FeishuMessageEvent = {
350
584
  sender: {
351
585
  sender_id: {
352
- open_id: "ou-sender",
586
+ open_id: "ou-allowed",
353
587
  },
354
588
  },
355
589
  message: {
356
- message_id: "msg-video-inbound",
357
- chat_id: "oc-dm",
358
- chat_type: "p2p",
359
- message_type: "video",
360
- content: JSON.stringify({
361
- file_key: "file_video_payload",
362
- image_key: "img_thumb_payload",
363
- file_name: "clip.mp4",
364
- }),
590
+ message_id: "msg-global-group-sender-allow",
591
+ chat_id: "oc-group",
592
+ chat_type: "group",
593
+ message_type: "text",
594
+ content: JSON.stringify({ text: "hello" }),
365
595
  },
366
596
  };
367
597
 
368
598
  await dispatchMessage({ cfg, event });
369
599
 
370
- expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
600
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
371
601
  expect.objectContaining({
372
- messageId: "msg-video-inbound",
373
- fileKey: "file_video_payload",
374
- type: "file",
602
+ ChatType: "group",
603
+ SenderId: "ou-allowed",
375
604
  }),
376
605
  );
377
- expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
378
- expect.any(Buffer),
379
- "video/mp4",
380
- "inbound",
381
- expect.any(Number),
382
- "clip.mp4",
383
- );
606
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
607
+ });
608
+
609
+ it("blocks group sender when global groupSenderAllowFrom excludes sender", async () => {
610
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
611
+
612
+ const cfg: ClawdbotConfig = {
613
+ channels: {
614
+ feishu: {
615
+ groupPolicy: "open",
616
+ groupSenderAllowFrom: ["ou-allowed"],
617
+ groups: {
618
+ "oc-group": {
619
+ requireMention: false,
620
+ },
621
+ },
622
+ },
623
+ },
624
+ } as ClawdbotConfig;
625
+
626
+ const event: FeishuMessageEvent = {
627
+ sender: {
628
+ sender_id: {
629
+ open_id: "ou-blocked",
630
+ },
631
+ },
632
+ message: {
633
+ message_id: "msg-global-group-sender-block",
634
+ chat_id: "oc-group",
635
+ chat_type: "group",
636
+ message_type: "text",
637
+ content: JSON.stringify({ text: "hello" }),
638
+ },
639
+ };
640
+
641
+ await dispatchMessage({ cfg, event });
642
+
643
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
644
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
645
+ });
646
+
647
+ it("prefers per-group allowFrom over global groupSenderAllowFrom", async () => {
648
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
649
+
650
+ const cfg: ClawdbotConfig = {
651
+ channels: {
652
+ feishu: {
653
+ groupPolicy: "open",
654
+ groupSenderAllowFrom: ["ou-global"],
655
+ groups: {
656
+ "oc-group": {
657
+ allowFrom: ["ou-group-only"],
658
+ requireMention: false,
659
+ },
660
+ },
661
+ },
662
+ },
663
+ } as ClawdbotConfig;
664
+
665
+ const event: FeishuMessageEvent = {
666
+ sender: {
667
+ sender_id: {
668
+ open_id: "ou-global",
669
+ },
670
+ },
671
+ message: {
672
+ message_id: "msg-per-group-precedence",
673
+ chat_id: "oc-group",
674
+ chat_type: "group",
675
+ message_type: "text",
676
+ content: JSON.stringify({ text: "hello" }),
677
+ },
678
+ };
679
+
680
+ await dispatchMessage({ cfg, event });
681
+
682
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
683
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
684
+ });
685
+
686
+ it("drops message when groupConfig.enabled is false", async () => {
687
+ const cfg: ClawdbotConfig = {
688
+ channels: {
689
+ feishu: {
690
+ groups: {
691
+ "oc-disabled-group": {
692
+ enabled: false,
693
+ },
694
+ },
695
+ },
696
+ },
697
+ } as ClawdbotConfig;
698
+
699
+ const event: FeishuMessageEvent = {
700
+ sender: {
701
+ sender_id: { open_id: "ou-sender" },
702
+ },
703
+ message: {
704
+ message_id: "msg-disabled-group",
705
+ chat_id: "oc-disabled-group",
706
+ chat_type: "group",
707
+ message_type: "text",
708
+ content: JSON.stringify({ text: "hello" }),
709
+ },
710
+ };
711
+
712
+ await dispatchMessage({ cfg, event });
713
+
714
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
715
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
716
+ });
717
+
718
+ it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
719
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
720
+
721
+ const cfg: ClawdbotConfig = {
722
+ channels: {
723
+ feishu: {
724
+ dmPolicy: "open",
725
+ },
726
+ },
727
+ } as ClawdbotConfig;
728
+
729
+ const event: FeishuMessageEvent = {
730
+ sender: {
731
+ sender_id: {
732
+ open_id: "ou-sender",
733
+ },
734
+ },
735
+ message: {
736
+ message_id: "msg-video-inbound",
737
+ chat_id: "oc-dm",
738
+ chat_type: "p2p",
739
+ message_type: "video",
740
+ content: JSON.stringify({
741
+ file_key: "file_video_payload",
742
+ image_key: "img_thumb_payload",
743
+ file_name: "clip.mp4",
744
+ }),
745
+ },
746
+ };
747
+
748
+ await dispatchMessage({ cfg, event });
749
+
750
+ expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
751
+ expect.objectContaining({
752
+ messageId: "msg-video-inbound",
753
+ fileKey: "file_video_payload",
754
+ type: "file",
755
+ }),
756
+ );
757
+ expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
758
+ expect.any(Buffer),
759
+ "video/mp4",
760
+ "inbound",
761
+ expect.any(Number),
762
+ "clip.mp4",
763
+ );
764
+ });
765
+
766
+ it("uses media message_type file_key (not thumbnail image_key) for inbound mobile video download", async () => {
767
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
768
+
769
+ const cfg: ClawdbotConfig = {
770
+ channels: {
771
+ feishu: {
772
+ dmPolicy: "open",
773
+ },
774
+ },
775
+ } as ClawdbotConfig;
776
+
777
+ const event: FeishuMessageEvent = {
778
+ sender: {
779
+ sender_id: {
780
+ open_id: "ou-sender",
781
+ },
782
+ },
783
+ message: {
784
+ message_id: "msg-media-inbound",
785
+ chat_id: "oc-dm",
786
+ chat_type: "p2p",
787
+ message_type: "media",
788
+ content: JSON.stringify({
789
+ file_key: "file_media_payload",
790
+ image_key: "img_media_thumb",
791
+ file_name: "mobile.mp4",
792
+ }),
793
+ },
794
+ };
795
+
796
+ await dispatchMessage({ cfg, event });
797
+
798
+ expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
799
+ expect.objectContaining({
800
+ messageId: "msg-media-inbound",
801
+ fileKey: "file_media_payload",
802
+ type: "file",
803
+ }),
804
+ );
805
+ expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
806
+ expect.any(Buffer),
807
+ "video/mp4",
808
+ "inbound",
809
+ expect.any(Number),
810
+ "clip.mp4",
811
+ );
812
+ });
813
+
814
+ it("downloads embedded media tags from post messages as files", async () => {
815
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
816
+
817
+ const cfg: ClawdbotConfig = {
818
+ channels: {
819
+ feishu: {
820
+ dmPolicy: "open",
821
+ },
822
+ },
823
+ } as ClawdbotConfig;
824
+
825
+ const event: FeishuMessageEvent = {
826
+ sender: {
827
+ sender_id: {
828
+ open_id: "ou-sender",
829
+ },
830
+ },
831
+ message: {
832
+ message_id: "msg-post-media",
833
+ chat_id: "oc-dm",
834
+ chat_type: "p2p",
835
+ message_type: "post",
836
+ content: JSON.stringify({
837
+ title: "Rich text",
838
+ content: [
839
+ [
840
+ {
841
+ tag: "media",
842
+ file_key: "file_post_media_payload",
843
+ file_name: "embedded.mov",
844
+ },
845
+ ],
846
+ ],
847
+ }),
848
+ },
849
+ };
850
+
851
+ await dispatchMessage({ cfg, event });
852
+
853
+ expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
854
+ expect.objectContaining({
855
+ messageId: "msg-post-media",
856
+ fileKey: "file_post_media_payload",
857
+ type: "file",
858
+ }),
859
+ );
860
+ expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
861
+ expect.any(Buffer),
862
+ "video/mp4",
863
+ "inbound",
864
+ expect.any(Number),
865
+ );
866
+ });
867
+
868
+ it("includes message_id in BodyForAgent on its own line", async () => {
869
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
870
+
871
+ const cfg: ClawdbotConfig = {
872
+ channels: {
873
+ feishu: {
874
+ dmPolicy: "open",
875
+ },
876
+ },
877
+ } as ClawdbotConfig;
878
+
879
+ const event: FeishuMessageEvent = {
880
+ sender: {
881
+ sender_id: {
882
+ open_id: "ou-msgid",
883
+ },
884
+ },
885
+ message: {
886
+ message_id: "msg-message-id-line",
887
+ chat_id: "oc-dm",
888
+ chat_type: "p2p",
889
+ message_type: "text",
890
+ content: JSON.stringify({ text: "hello" }),
891
+ },
892
+ };
893
+
894
+ await dispatchMessage({ cfg, event });
895
+
896
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
897
+ expect.objectContaining({
898
+ BodyForAgent: "[message_id: msg-message-id-line]\nou-msgid: hello",
899
+ }),
900
+ );
901
+ });
902
+
903
+ it("expands merge_forward content from API sub-messages", async () => {
904
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
905
+ const mockGetMerged = vi.fn().mockResolvedValue({
906
+ code: 0,
907
+ data: {
908
+ items: [
909
+ {
910
+ message_id: "container",
911
+ msg_type: "merge_forward",
912
+ body: { content: JSON.stringify({ text: "Merged and Forwarded Message" }) },
913
+ },
914
+ {
915
+ message_id: "sub-2",
916
+ upper_message_id: "container",
917
+ msg_type: "file",
918
+ body: { content: JSON.stringify({ file_name: "report.pdf" }) },
919
+ create_time: "2000",
920
+ },
921
+ {
922
+ message_id: "sub-1",
923
+ upper_message_id: "container",
924
+ msg_type: "text",
925
+ body: { content: JSON.stringify({ text: "alpha" }) },
926
+ create_time: "1000",
927
+ },
928
+ ],
929
+ },
930
+ });
931
+ mockCreateFeishuClient.mockReturnValue({
932
+ contact: {
933
+ user: {
934
+ get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
935
+ },
936
+ },
937
+ im: {
938
+ message: {
939
+ get: mockGetMerged,
940
+ },
941
+ },
942
+ });
943
+
944
+ const cfg: ClawdbotConfig = {
945
+ channels: {
946
+ feishu: {
947
+ dmPolicy: "open",
948
+ },
949
+ },
950
+ } as ClawdbotConfig;
951
+
952
+ const event: FeishuMessageEvent = {
953
+ sender: {
954
+ sender_id: {
955
+ open_id: "ou-merge",
956
+ },
957
+ },
958
+ message: {
959
+ message_id: "msg-merge-forward",
960
+ chat_id: "oc-dm",
961
+ chat_type: "p2p",
962
+ message_type: "merge_forward",
963
+ content: JSON.stringify({ text: "Merged and Forwarded Message" }),
964
+ },
965
+ };
966
+
967
+ await dispatchMessage({ cfg, event });
968
+
969
+ expect(mockGetMerged).toHaveBeenCalledWith({
970
+ path: { message_id: "msg-merge-forward" },
971
+ });
972
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
973
+ expect.objectContaining({
974
+ BodyForAgent: expect.stringContaining(
975
+ "[Merged and Forwarded Messages]\n- alpha\n- [File: report.pdf]",
976
+ ),
977
+ }),
978
+ );
979
+ });
980
+
981
+ it("falls back when merge_forward API returns no sub-messages", async () => {
982
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
983
+ mockCreateFeishuClient.mockReturnValue({
984
+ contact: {
985
+ user: {
986
+ get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
987
+ },
988
+ },
989
+ im: {
990
+ message: {
991
+ get: vi.fn().mockResolvedValue({ code: 0, data: { items: [] } }),
992
+ },
993
+ },
994
+ });
995
+
996
+ const cfg: ClawdbotConfig = {
997
+ channels: {
998
+ feishu: {
999
+ dmPolicy: "open",
1000
+ },
1001
+ },
1002
+ } as ClawdbotConfig;
1003
+
1004
+ const event: FeishuMessageEvent = {
1005
+ sender: {
1006
+ sender_id: {
1007
+ open_id: "ou-merge-empty",
1008
+ },
1009
+ },
1010
+ message: {
1011
+ message_id: "msg-merge-empty",
1012
+ chat_id: "oc-dm",
1013
+ chat_type: "p2p",
1014
+ message_type: "merge_forward",
1015
+ content: JSON.stringify({ text: "Merged and Forwarded Message" }),
1016
+ },
1017
+ };
1018
+
1019
+ await dispatchMessage({ cfg, event });
1020
+
1021
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1022
+ expect.objectContaining({
1023
+ BodyForAgent: expect.stringContaining("[Merged and Forwarded Message - could not fetch]"),
1024
+ }),
1025
+ );
1026
+ });
1027
+
1028
+ it("dispatches once and appends permission notice to the main agent body", async () => {
1029
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1030
+ mockCreateFeishuClient.mockReturnValue({
1031
+ contact: {
1032
+ user: {
1033
+ get: vi.fn().mockRejectedValue({
1034
+ response: {
1035
+ data: {
1036
+ code: 99991672,
1037
+ msg: "permission denied https://open.feishu.cn/app/cli_test",
1038
+ },
1039
+ },
1040
+ }),
1041
+ },
1042
+ },
1043
+ });
1044
+
1045
+ const cfg: ClawdbotConfig = {
1046
+ channels: {
1047
+ feishu: {
1048
+ appId: "cli_test",
1049
+ appSecret: "sec_test",
1050
+ groups: {
1051
+ "oc-group": {
1052
+ requireMention: false,
1053
+ },
1054
+ },
1055
+ },
1056
+ },
1057
+ } as ClawdbotConfig;
1058
+
1059
+ const event: FeishuMessageEvent = {
1060
+ sender: {
1061
+ sender_id: {
1062
+ open_id: "ou-perm",
1063
+ },
1064
+ },
1065
+ message: {
1066
+ message_id: "msg-perm-1",
1067
+ chat_id: "oc-group",
1068
+ chat_type: "group",
1069
+ message_type: "text",
1070
+ content: JSON.stringify({ text: "hello group" }),
1071
+ },
1072
+ };
1073
+
1074
+ await dispatchMessage({ cfg, event });
1075
+
1076
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1077
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1078
+ expect.objectContaining({
1079
+ BodyForAgent: expect.stringContaining(
1080
+ "Permission grant URL: https://open.feishu.cn/app/cli_test",
1081
+ ),
1082
+ }),
1083
+ );
1084
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1085
+ expect.objectContaining({
1086
+ BodyForAgent: expect.stringContaining("ou-perm: hello group"),
1087
+ }),
1088
+ );
1089
+ });
1090
+
1091
+ it("ignores stale non-existent contact scope permission errors", async () => {
1092
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1093
+ mockCreateFeishuClient.mockReturnValue({
1094
+ contact: {
1095
+ user: {
1096
+ get: vi.fn().mockRejectedValue({
1097
+ response: {
1098
+ data: {
1099
+ code: 99991672,
1100
+ msg: "permission denied: contact:contact.base:readonly https://open.feishu.cn/app/cli_scope_bug",
1101
+ },
1102
+ },
1103
+ }),
1104
+ },
1105
+ },
1106
+ });
1107
+
1108
+ const cfg: ClawdbotConfig = {
1109
+ channels: {
1110
+ feishu: {
1111
+ appId: "cli_scope_bug",
1112
+ appSecret: "sec_scope_bug",
1113
+ groups: {
1114
+ "oc-group": {
1115
+ requireMention: false,
1116
+ },
1117
+ },
1118
+ },
1119
+ },
1120
+ } as ClawdbotConfig;
1121
+
1122
+ const event: FeishuMessageEvent = {
1123
+ sender: {
1124
+ sender_id: {
1125
+ open_id: "ou-perm-scope",
1126
+ },
1127
+ },
1128
+ message: {
1129
+ message_id: "msg-perm-scope-1",
1130
+ chat_id: "oc-group",
1131
+ chat_type: "group",
1132
+ message_type: "text",
1133
+ content: JSON.stringify({ text: "hello group" }),
1134
+ },
1135
+ };
1136
+
1137
+ await dispatchMessage({ cfg, event });
1138
+
1139
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1140
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1141
+ expect.objectContaining({
1142
+ BodyForAgent: expect.not.stringContaining("Permission grant URL"),
1143
+ }),
1144
+ );
1145
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1146
+ expect.objectContaining({
1147
+ BodyForAgent: expect.stringContaining("ou-perm-scope: hello group"),
1148
+ }),
1149
+ );
1150
+ });
1151
+
1152
+ it("routes group sessions by sender when groupSessionScope=group_sender", async () => {
1153
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1154
+
1155
+ const cfg: ClawdbotConfig = {
1156
+ channels: {
1157
+ feishu: {
1158
+ groups: {
1159
+ "oc-group": {
1160
+ requireMention: false,
1161
+ groupSessionScope: "group_sender",
1162
+ },
1163
+ },
1164
+ },
1165
+ },
1166
+ } as ClawdbotConfig;
1167
+
1168
+ const event: FeishuMessageEvent = {
1169
+ sender: { sender_id: { open_id: "ou-scope-user" } },
1170
+ message: {
1171
+ message_id: "msg-scope-group-sender",
1172
+ chat_id: "oc-group",
1173
+ chat_type: "group",
1174
+ message_type: "text",
1175
+ content: JSON.stringify({ text: "group sender scope" }),
1176
+ },
1177
+ };
1178
+
1179
+ await dispatchMessage({ cfg, event });
1180
+
1181
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1182
+ expect.objectContaining({
1183
+ peer: { kind: "group", id: "oc-group:sender:ou-scope-user" },
1184
+ parentPeer: null,
1185
+ }),
1186
+ );
1187
+ });
1188
+
1189
+ it("routes topic sessions and parentPeer when groupSessionScope=group_topic_sender", async () => {
1190
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1191
+
1192
+ const cfg: ClawdbotConfig = {
1193
+ channels: {
1194
+ feishu: {
1195
+ groups: {
1196
+ "oc-group": {
1197
+ requireMention: false,
1198
+ groupSessionScope: "group_topic_sender",
1199
+ },
1200
+ },
1201
+ },
1202
+ },
1203
+ } as ClawdbotConfig;
1204
+
1205
+ const event: FeishuMessageEvent = {
1206
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1207
+ message: {
1208
+ message_id: "msg-scope-topic-sender",
1209
+ chat_id: "oc-group",
1210
+ chat_type: "group",
1211
+ root_id: "om_root_topic",
1212
+ message_type: "text",
1213
+ content: JSON.stringify({ text: "topic sender scope" }),
1214
+ },
1215
+ };
1216
+
1217
+ await dispatchMessage({ cfg, event });
1218
+
1219
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1220
+ expect.objectContaining({
1221
+ peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
1222
+ parentPeer: { kind: "group", id: "oc-group" },
1223
+ }),
1224
+ );
1225
+ });
1226
+
1227
+ it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
1228
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1229
+
1230
+ const cfg: ClawdbotConfig = {
1231
+ channels: {
1232
+ feishu: {
1233
+ groups: {
1234
+ "oc-group": {
1235
+ requireMention: false,
1236
+ groupSessionScope: "group_topic_sender",
1237
+ },
1238
+ },
1239
+ },
1240
+ },
1241
+ } as ClawdbotConfig;
1242
+
1243
+ const event: FeishuMessageEvent = {
1244
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1245
+ message: {
1246
+ message_id: "msg-scope-topic-thread-id",
1247
+ chat_id: "oc-group",
1248
+ chat_type: "group",
1249
+ root_id: "om_root_topic",
1250
+ thread_id: "omt_topic_1",
1251
+ message_type: "text",
1252
+ content: JSON.stringify({ text: "topic sender scope" }),
1253
+ },
1254
+ };
1255
+
1256
+ await dispatchMessage({ cfg, event });
1257
+
1258
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1259
+ expect.objectContaining({
1260
+ peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
1261
+ parentPeer: { kind: "group", id: "oc-group" },
1262
+ }),
1263
+ );
1264
+ });
1265
+
1266
+ it("uses thread_id as topic key when root_id is missing", async () => {
1267
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1268
+
1269
+ const cfg: ClawdbotConfig = {
1270
+ channels: {
1271
+ feishu: {
1272
+ groups: {
1273
+ "oc-group": {
1274
+ requireMention: false,
1275
+ groupSessionScope: "group_topic_sender",
1276
+ },
1277
+ },
1278
+ },
1279
+ },
1280
+ } as ClawdbotConfig;
1281
+
1282
+ const event: FeishuMessageEvent = {
1283
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1284
+ message: {
1285
+ message_id: "msg-scope-topic-thread-only",
1286
+ chat_id: "oc-group",
1287
+ chat_type: "group",
1288
+ thread_id: "omt_topic_1",
1289
+ message_type: "text",
1290
+ content: JSON.stringify({ text: "topic sender scope" }),
1291
+ },
1292
+ };
1293
+
1294
+ await dispatchMessage({ cfg, event });
1295
+
1296
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1297
+ expect.objectContaining({
1298
+ peer: { kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
1299
+ parentPeer: { kind: "group", id: "oc-group" },
1300
+ }),
1301
+ );
1302
+ });
1303
+
1304
+ it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
1305
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1306
+
1307
+ const cfg: ClawdbotConfig = {
1308
+ channels: {
1309
+ feishu: {
1310
+ topicSessionMode: "enabled",
1311
+ groups: {
1312
+ "oc-group": {
1313
+ requireMention: false,
1314
+ },
1315
+ },
1316
+ },
1317
+ },
1318
+ } as ClawdbotConfig;
1319
+
1320
+ const event: FeishuMessageEvent = {
1321
+ sender: { sender_id: { open_id: "ou-legacy" } },
1322
+ message: {
1323
+ message_id: "msg-legacy-topic-mode",
1324
+ chat_id: "oc-group",
1325
+ chat_type: "group",
1326
+ root_id: "om_root_legacy",
1327
+ message_type: "text",
1328
+ content: JSON.stringify({ text: "legacy topic mode" }),
1329
+ },
1330
+ };
1331
+
1332
+ await dispatchMessage({ cfg, event });
1333
+
1334
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1335
+ expect.objectContaining({
1336
+ peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
1337
+ parentPeer: { kind: "group", id: "oc-group" },
1338
+ }),
1339
+ );
1340
+ });
1341
+
1342
+ it("maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist", async () => {
1343
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1344
+
1345
+ const cfg: ClawdbotConfig = {
1346
+ channels: {
1347
+ feishu: {
1348
+ topicSessionMode: "enabled",
1349
+ groups: {
1350
+ "oc-group": {
1351
+ requireMention: false,
1352
+ },
1353
+ },
1354
+ },
1355
+ },
1356
+ } as ClawdbotConfig;
1357
+
1358
+ const event: FeishuMessageEvent = {
1359
+ sender: { sender_id: { open_id: "ou-legacy-thread-id" } },
1360
+ message: {
1361
+ message_id: "msg-legacy-topic-thread-id",
1362
+ chat_id: "oc-group",
1363
+ chat_type: "group",
1364
+ root_id: "om_root_legacy",
1365
+ thread_id: "omt_topic_legacy",
1366
+ message_type: "text",
1367
+ content: JSON.stringify({ text: "legacy topic mode" }),
1368
+ },
1369
+ };
1370
+
1371
+ await dispatchMessage({ cfg, event });
1372
+
1373
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1374
+ expect.objectContaining({
1375
+ peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
1376
+ parentPeer: { kind: "group", id: "oc-group" },
1377
+ }),
1378
+ );
1379
+ });
1380
+
1381
+ it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
1382
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1383
+
1384
+ const cfg: ClawdbotConfig = {
1385
+ channels: {
1386
+ feishu: {
1387
+ groups: {
1388
+ "oc-group": {
1389
+ requireMention: false,
1390
+ groupSessionScope: "group_topic",
1391
+ replyInThread: "enabled",
1392
+ },
1393
+ },
1394
+ },
1395
+ },
1396
+ } as ClawdbotConfig;
1397
+
1398
+ const event: FeishuMessageEvent = {
1399
+ sender: { sender_id: { open_id: "ou-topic-init" } },
1400
+ message: {
1401
+ message_id: "msg-new-topic-root",
1402
+ chat_id: "oc-group",
1403
+ chat_type: "group",
1404
+ message_type: "text",
1405
+ content: JSON.stringify({ text: "create topic" }),
1406
+ },
1407
+ };
1408
+
1409
+ await dispatchMessage({ cfg, event });
1410
+
1411
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1412
+ expect.objectContaining({
1413
+ peer: { kind: "group", id: "oc-group:topic:msg-new-topic-root" },
1414
+ parentPeer: { kind: "group", id: "oc-group" },
1415
+ }),
1416
+ );
1417
+ });
1418
+
1419
+ it("keeps topic session key stable after first turn creates a thread", async () => {
1420
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1421
+
1422
+ const cfg: ClawdbotConfig = {
1423
+ channels: {
1424
+ feishu: {
1425
+ groups: {
1426
+ "oc-group": {
1427
+ requireMention: false,
1428
+ groupSessionScope: "group_topic",
1429
+ replyInThread: "enabled",
1430
+ },
1431
+ },
1432
+ },
1433
+ },
1434
+ } as ClawdbotConfig;
1435
+
1436
+ const firstTurn: FeishuMessageEvent = {
1437
+ sender: { sender_id: { open_id: "ou-topic-init" } },
1438
+ message: {
1439
+ message_id: "msg-topic-first",
1440
+ chat_id: "oc-group",
1441
+ chat_type: "group",
1442
+ message_type: "text",
1443
+ content: JSON.stringify({ text: "create topic" }),
1444
+ },
1445
+ };
1446
+ const secondTurn: FeishuMessageEvent = {
1447
+ sender: { sender_id: { open_id: "ou-topic-init" } },
1448
+ message: {
1449
+ message_id: "msg-topic-second",
1450
+ chat_id: "oc-group",
1451
+ chat_type: "group",
1452
+ root_id: "msg-topic-first",
1453
+ thread_id: "omt_topic_created",
1454
+ message_type: "text",
1455
+ content: JSON.stringify({ text: "follow up in same topic" }),
1456
+ },
1457
+ };
1458
+
1459
+ await dispatchMessage({ cfg, event: firstTurn });
1460
+ await dispatchMessage({ cfg, event: secondTurn });
1461
+
1462
+ expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
1463
+ 1,
1464
+ expect.objectContaining({
1465
+ peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
1466
+ }),
1467
+ );
1468
+ expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
1469
+ 2,
1470
+ expect.objectContaining({
1471
+ peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
1472
+ }),
1473
+ );
1474
+ });
1475
+
1476
+ it("replies to the topic root when handling a message inside an existing topic", async () => {
1477
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1478
+
1479
+ const cfg: ClawdbotConfig = {
1480
+ channels: {
1481
+ feishu: {
1482
+ groups: {
1483
+ "oc-group": {
1484
+ requireMention: false,
1485
+ replyInThread: "enabled",
1486
+ },
1487
+ },
1488
+ },
1489
+ },
1490
+ } as ClawdbotConfig;
1491
+
1492
+ const event: FeishuMessageEvent = {
1493
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1494
+ message: {
1495
+ message_id: "om_child_message",
1496
+ root_id: "om_root_topic",
1497
+ chat_id: "oc-group",
1498
+ chat_type: "group",
1499
+ message_type: "text",
1500
+ content: JSON.stringify({ text: "reply inside topic" }),
1501
+ },
1502
+ };
1503
+
1504
+ await dispatchMessage({ cfg, event });
1505
+
1506
+ expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1507
+ expect.objectContaining({
1508
+ replyToMessageId: "om_root_topic",
1509
+ rootId: "om_root_topic",
1510
+ }),
1511
+ );
1512
+ });
1513
+
1514
+ it("forces thread replies when inbound message contains thread_id", async () => {
1515
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1516
+
1517
+ const cfg: ClawdbotConfig = {
1518
+ channels: {
1519
+ feishu: {
1520
+ groups: {
1521
+ "oc-group": {
1522
+ requireMention: false,
1523
+ groupSessionScope: "group",
1524
+ replyInThread: "disabled",
1525
+ },
1526
+ },
1527
+ },
1528
+ },
1529
+ } as ClawdbotConfig;
1530
+
1531
+ const event: FeishuMessageEvent = {
1532
+ sender: { sender_id: { open_id: "ou-thread-reply" } },
1533
+ message: {
1534
+ message_id: "msg-thread-reply",
1535
+ chat_id: "oc-group",
1536
+ chat_type: "group",
1537
+ thread_id: "omt_topic_thread_reply",
1538
+ message_type: "text",
1539
+ content: JSON.stringify({ text: "thread content" }),
1540
+ },
1541
+ };
1542
+
1543
+ await dispatchMessage({ cfg, event });
1544
+
1545
+ expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1546
+ expect.objectContaining({
1547
+ replyInThread: true,
1548
+ threadReply: true,
1549
+ }),
1550
+ );
1551
+ });
1552
+
1553
+ it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
1554
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1555
+
1556
+ const cfg: ClawdbotConfig = {
1557
+ channels: {
1558
+ feishu: {
1559
+ dmPolicy: "open",
1560
+ },
1561
+ },
1562
+ } as ClawdbotConfig;
1563
+
1564
+ const event: FeishuMessageEvent = {
1565
+ sender: {
1566
+ sender_id: {
1567
+ open_id: "ou-image-dedup",
1568
+ },
1569
+ },
1570
+ message: {
1571
+ message_id: "msg-image-dedup",
1572
+ chat_id: "oc-dm",
1573
+ chat_type: "p2p",
1574
+ message_type: "image",
1575
+ content: JSON.stringify({
1576
+ image_key: "img_dedup_payload",
1577
+ }),
1578
+ },
1579
+ };
1580
+
1581
+ await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
1582
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1583
+ });
1584
+ });
1585
+
1586
+ describe("toMessageResourceType", () => {
1587
+ it("maps image to image", () => {
1588
+ expect(toMessageResourceType("image")).toBe("image");
1589
+ });
1590
+
1591
+ it("maps audio to file", () => {
1592
+ expect(toMessageResourceType("audio")).toBe("file");
1593
+ });
1594
+
1595
+ it("maps video/file/sticker to file", () => {
1596
+ expect(toMessageResourceType("video")).toBe("file");
1597
+ expect(toMessageResourceType("file")).toBe("file");
1598
+ expect(toMessageResourceType("sticker")).toBe("file");
384
1599
  });
385
1600
  });