@openclaw/feishu 2026.3.2 → 2026.3.8-beta.1

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 (70) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +199 -13
  4. package/src/accounts.ts +45 -17
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +8 -0
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +516 -9
  9. package/src/bot.ts +366 -109
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +52 -64
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +207 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +14 -6
  18. package/src/config-schema.ts +5 -1
  19. package/src/dedup.ts +1 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/docx-batch-insert.test.ts +90 -0
  23. package/src/docx-batch-insert.ts +8 -11
  24. package/src/docx.account-selection.test.ts +3 -3
  25. package/src/docx.ts +1 -1
  26. package/src/drive.ts +13 -17
  27. package/src/dynamic-agent.ts +1 -1
  28. package/src/feishu-command-handler.ts +59 -0
  29. package/src/media.test.ts +60 -13
  30. package/src/media.ts +23 -9
  31. package/src/monitor.account.ts +19 -8
  32. package/src/monitor.reaction.test.ts +111 -105
  33. package/src/monitor.startup.test.ts +11 -10
  34. package/src/monitor.startup.ts +20 -7
  35. package/src/monitor.state.ts +4 -1
  36. package/src/monitor.test-mocks.ts +42 -9
  37. package/src/monitor.transport.ts +4 -1
  38. package/src/monitor.ts +4 -4
  39. package/src/monitor.webhook-security.test.ts +8 -23
  40. package/src/onboarding.status.test.ts +1 -1
  41. package/src/onboarding.test.ts +143 -0
  42. package/src/onboarding.ts +86 -71
  43. package/src/outbound.test.ts +178 -0
  44. package/src/outbound.ts +39 -6
  45. package/src/perm.ts +11 -15
  46. package/src/policy.test.ts +40 -0
  47. package/src/policy.ts +9 -10
  48. package/src/probe.test.ts +18 -18
  49. package/src/reactions.ts +1 -1
  50. package/src/reply-dispatcher.test.ts +175 -0
  51. package/src/reply-dispatcher.ts +69 -21
  52. package/src/runtime.ts +5 -13
  53. package/src/secret-input.ts +8 -14
  54. package/src/send-message.ts +71 -0
  55. package/src/send-target.test.ts +1 -1
  56. package/src/send-target.ts +1 -1
  57. package/src/send.reply-fallback.test.ts +74 -0
  58. package/src/send.test.ts +1 -1
  59. package/src/send.ts +88 -49
  60. package/src/streaming-card.test.ts +54 -0
  61. package/src/streaming-card.ts +96 -28
  62. package/src/targets.ts +5 -1
  63. package/src/tool-account-routing.test.ts +3 -3
  64. package/src/tool-account.ts +1 -1
  65. package/src/tool-factory-test-harness.ts +1 -1
  66. package/src/tool-result.test.ts +32 -0
  67. package/src/tool-result.ts +14 -0
  68. package/src/types.ts +2 -3
  69. package/src/typing.ts +1 -1
  70. package/src/wiki.ts +15 -19
package/src/bot.test.ts CHANGED
@@ -1,8 +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
3
  import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
4
4
  import type { FeishuMessageEvent } from "./bot.js";
5
- import { buildFeishuAgentBody, handleFeishuMessage, toMessageResourceType } from "./bot.js";
5
+ import {
6
+ buildBroadcastSessionKey,
7
+ buildFeishuAgentBody,
8
+ handleFeishuMessage,
9
+ resolveBroadcastAgents,
10
+ toMessageResourceType,
11
+ } from "./bot.js";
6
12
  import { setFeishuRuntime } from "./runtime.js";
7
13
 
8
14
  const {
@@ -453,14 +459,17 @@ describe("handleFeishuMessage command authorization", () => {
453
459
  id: "ou-unapproved",
454
460
  meta: { name: undefined },
455
461
  });
456
- expect(mockBuildPairingReply).toHaveBeenCalledWith({
457
- channel: "feishu",
458
- idLine: "Your Feishu user id: ou-unapproved",
459
- code: "ABCDEFGH",
460
- });
461
462
  expect(mockSendMessageFeishu).toHaveBeenCalledWith(
462
463
  expect.objectContaining({
463
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"),
464
473
  accountId: "default",
465
474
  }),
466
475
  );
@@ -515,6 +524,42 @@ describe("handleFeishuMessage command authorization", () => {
515
524
  );
516
525
  });
517
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
+
518
563
  it("falls back to top-level allowFrom for group command authorization", async () => {
519
564
  mockShouldComputeCommandAuthorized.mockReturnValue(true);
520
565
  mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
@@ -1046,7 +1091,7 @@ describe("handleFeishuMessage command authorization", () => {
1046
1091
  channels: {
1047
1092
  feishu: {
1048
1093
  appId: "cli_test",
1049
- appSecret: "sec_test",
1094
+ appSecret: "sec_test", // pragma: allowlist secret
1050
1095
  groups: {
1051
1096
  "oc-group": {
1052
1097
  requireMention: false,
@@ -1109,7 +1154,7 @@ describe("handleFeishuMessage command authorization", () => {
1109
1154
  channels: {
1110
1155
  feishu: {
1111
1156
  appId: "cli_scope_bug",
1112
- appSecret: "sec_scope_bug",
1157
+ appSecret: "sec_scope_bug", // pragma: allowlist secret
1113
1158
  groups: {
1114
1159
  "oc-group": {
1115
1160
  requireMention: false,
@@ -1511,6 +1556,120 @@ describe("handleFeishuMessage command authorization", () => {
1511
1556
  );
1512
1557
  });
1513
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
+
1514
1673
  it("forces thread replies when inbound message contains thread_id", async () => {
1515
1674
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1516
1675
 
@@ -1598,3 +1757,351 @@ describe("toMessageResourceType", () => {
1598
1757
  expect(toMessageResourceType("sticker")).toBe("file");
1599
1758
  });
1600
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");
2106
+ });
2107
+ });