@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
@@ -4,27 +4,104 @@ import {
4
4
  createFixedWindowRateLimiter,
5
5
  createWebhookAnomalyTracker,
6
6
  type RuntimeEnv,
7
- WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
8
- WEBHOOK_RATE_LIMIT_DEFAULTS,
9
- } from "openclaw/plugin-sdk";
7
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS as WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
8
+ WEBHOOK_RATE_LIMIT_DEFAULTS as WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
9
+ } from "openclaw/plugin-sdk/feishu";
10
10
 
11
11
  export const wsClients = new Map<string, Lark.WSClient>();
12
12
  export const httpServers = new Map<string, http.Server>();
13
13
  export const botOpenIds = new Map<string, string>();
14
+ export const botNames = new Map<string, string>();
14
15
 
15
16
  export const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
16
17
  export const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
17
18
 
19
+ type WebhookRateLimitDefaults = {
20
+ windowMs: number;
21
+ maxRequests: number;
22
+ maxTrackedKeys: number;
23
+ };
24
+
25
+ type WebhookAnomalyDefaults = {
26
+ maxTrackedKeys: number;
27
+ ttlMs: number;
28
+ logEvery: number;
29
+ };
30
+
31
+ const FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS: WebhookRateLimitDefaults = {
32
+ windowMs: 60_000,
33
+ maxRequests: 120,
34
+ maxTrackedKeys: 4_096,
35
+ };
36
+
37
+ const FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS: WebhookAnomalyDefaults = {
38
+ maxTrackedKeys: 4_096,
39
+ ttlMs: 6 * 60 * 60_000,
40
+ logEvery: 25,
41
+ };
42
+
43
+ function coercePositiveInt(value: unknown, fallback: number): number {
44
+ if (typeof value !== "number" || !Number.isFinite(value)) {
45
+ return fallback;
46
+ }
47
+ const normalized = Math.floor(value);
48
+ return normalized > 0 ? normalized : fallback;
49
+ }
50
+
51
+ export function resolveFeishuWebhookRateLimitDefaultsForTest(
52
+ defaults: unknown,
53
+ ): WebhookRateLimitDefaults {
54
+ const resolved = defaults as Partial<WebhookRateLimitDefaults> | null | undefined;
55
+ return {
56
+ windowMs: coercePositiveInt(
57
+ resolved?.windowMs,
58
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.windowMs,
59
+ ),
60
+ maxRequests: coercePositiveInt(
61
+ resolved?.maxRequests,
62
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxRequests,
63
+ ),
64
+ maxTrackedKeys: coercePositiveInt(
65
+ resolved?.maxTrackedKeys,
66
+ FEISHU_WEBHOOK_RATE_LIMIT_FALLBACK_DEFAULTS.maxTrackedKeys,
67
+ ),
68
+ };
69
+ }
70
+
71
+ export function resolveFeishuWebhookAnomalyDefaultsForTest(
72
+ defaults: unknown,
73
+ ): WebhookAnomalyDefaults {
74
+ const resolved = defaults as Partial<WebhookAnomalyDefaults> | null | undefined;
75
+ return {
76
+ maxTrackedKeys: coercePositiveInt(
77
+ resolved?.maxTrackedKeys,
78
+ FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.maxTrackedKeys,
79
+ ),
80
+ ttlMs: coercePositiveInt(resolved?.ttlMs, FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.ttlMs),
81
+ logEvery: coercePositiveInt(
82
+ resolved?.logEvery,
83
+ FEISHU_WEBHOOK_ANOMALY_FALLBACK_DEFAULTS.logEvery,
84
+ ),
85
+ };
86
+ }
87
+
88
+ const feishuWebhookRateLimitDefaults = resolveFeishuWebhookRateLimitDefaultsForTest(
89
+ WEBHOOK_RATE_LIMIT_DEFAULTS_FROM_SDK,
90
+ );
91
+ const feishuWebhookAnomalyDefaults = resolveFeishuWebhookAnomalyDefaultsForTest(
92
+ WEBHOOK_ANOMALY_COUNTER_DEFAULTS_FROM_SDK,
93
+ );
94
+
18
95
  export const feishuWebhookRateLimiter = createFixedWindowRateLimiter({
19
- windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
20
- maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
21
- maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
96
+ windowMs: feishuWebhookRateLimitDefaults.windowMs,
97
+ maxRequests: feishuWebhookRateLimitDefaults.maxRequests,
98
+ maxTrackedKeys: feishuWebhookRateLimitDefaults.maxTrackedKeys,
22
99
  });
23
100
 
24
101
  const feishuWebhookAnomalyTracker = createWebhookAnomalyTracker({
25
- maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
26
- ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs,
27
- logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery,
102
+ maxTrackedKeys: feishuWebhookAnomalyDefaults.maxTrackedKeys,
103
+ ttlMs: feishuWebhookAnomalyDefaults.ttlMs,
104
+ logEvery: feishuWebhookAnomalyDefaults.logEvery,
28
105
  });
29
106
 
30
107
  export function clearFeishuWebhookRateLimitStateForTest(): void {
@@ -64,6 +141,7 @@ export function stopFeishuMonitorState(accountId?: string): void {
64
141
  httpServers.delete(accountId);
65
142
  }
66
143
  botOpenIds.delete(accountId);
144
+ botNames.delete(accountId);
67
145
  return;
68
146
  }
69
147
 
@@ -73,4 +151,5 @@ export function stopFeishuMonitorState(accountId?: string): void {
73
151
  }
74
152
  httpServers.clear();
75
153
  botOpenIds.clear();
154
+ botNames.clear();
76
155
  }
@@ -0,0 +1,45 @@
1
+ import { vi } from "vitest";
2
+
3
+ export function createFeishuClientMockModule(): {
4
+ createFeishuWSClient: () => { start: () => void };
5
+ createEventDispatcher: () => { register: () => void };
6
+ } {
7
+ return {
8
+ createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
9
+ createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
10
+ };
11
+ }
12
+
13
+ export function createFeishuRuntimeMockModule(): {
14
+ getFeishuRuntime: () => {
15
+ channel: {
16
+ debounce: {
17
+ resolveInboundDebounceMs: () => number;
18
+ createInboundDebouncer: () => {
19
+ enqueue: () => Promise<void>;
20
+ flushKey: () => Promise<void>;
21
+ };
22
+ };
23
+ text: {
24
+ hasControlCommand: () => boolean;
25
+ };
26
+ };
27
+ };
28
+ } {
29
+ return {
30
+ getFeishuRuntime: () => ({
31
+ channel: {
32
+ debounce: {
33
+ resolveInboundDebounceMs: () => 0,
34
+ createInboundDebouncer: () => ({
35
+ enqueue: async () => {},
36
+ flushKey: async () => {},
37
+ }),
38
+ },
39
+ text: {
40
+ hasControlCommand: () => false,
41
+ },
42
+ },
43
+ }),
44
+ };
45
+ }
@@ -4,9 +4,10 @@ import {
4
4
  applyBasicWebhookRequestGuards,
5
5
  type RuntimeEnv,
6
6
  installRequestBodyLimitGuard,
7
- } from "openclaw/plugin-sdk";
7
+ } from "openclaw/plugin-sdk/feishu";
8
8
  import { createFeishuWSClient } from "./client.js";
9
9
  import {
10
+ botNames,
10
11
  botOpenIds,
11
12
  FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
12
13
  FEISHU_WEBHOOK_MAX_BODY_BYTES,
@@ -42,6 +43,7 @@ export async function monitorWebSocket({
42
43
  const cleanup = () => {
43
44
  wsClients.delete(accountId);
44
45
  botOpenIds.delete(accountId);
46
+ botNames.delete(accountId);
45
47
  };
46
48
 
47
49
  const handleAbort = () => {
@@ -134,6 +136,7 @@ export async function monitorWebhook({
134
136
  server.close();
135
137
  httpServers.delete(accountId);
136
138
  botOpenIds.delete(accountId);
139
+ botNames.delete(accountId);
137
140
  };
138
141
 
139
142
  const handleAbort = () => {
package/src/monitor.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
2
2
  import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js";
3
3
  import {
4
4
  monitorSingleAccount,
5
5
  resolveReactionSyntheticEvent,
6
6
  type FeishuReactionCreatedEvent,
7
7
  } from "./monitor.account.js";
8
- import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
8
+ import { fetchBotIdentityForMonitor } from "./monitor.startup.js";
9
9
  import {
10
10
  clearFeishuWebhookRateLimitStateForTest,
11
11
  getFeishuWebhookRateLimitStateSizeForTest,
@@ -66,7 +66,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
66
66
  }
67
67
 
68
68
  // Probe sequentially so large multi-account startups do not burst Feishu's bot-info endpoint.
69
- const botOpenId = await fetchBotOpenIdForMonitor(account, {
69
+ const { botOpenId, botName } = await fetchBotIdentityForMonitor(account, {
70
70
  runtime: opts.runtime,
71
71
  abortSignal: opts.abortSignal,
72
72
  });
@@ -82,7 +82,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
82
82
  account,
83
83
  runtime: opts.runtime,
84
84
  abortSignal: opts.abortSignal,
85
- botOpenIdSource: { kind: "prefetched", botOpenId },
85
+ botOpenIdSource: { kind: "prefetched", botOpenId, botName },
86
86
  }),
87
87
  );
88
88
  }
@@ -1,10 +1,21 @@
1
1
  import { createServer } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
3
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ createFeishuClientMockModule,
7
+ createFeishuRuntimeMockModule,
8
+ } from "./monitor.test-mocks.js";
5
9
 
6
10
  const probeFeishuMock = vi.hoisted(() => vi.fn());
7
11
 
12
+ vi.mock("./probe.js", () => ({
13
+ probeFeishu: probeFeishuMock,
14
+ }));
15
+
16
+ vi.mock("./client.js", () => createFeishuClientMockModule());
17
+ vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
18
+
8
19
  vi.mock("@larksuiteoapi/node-sdk", () => ({
9
20
  adaptDefault: vi.fn(
10
21
  () => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
@@ -14,15 +25,6 @@ vi.mock("@larksuiteoapi/node-sdk", () => ({
14
25
  ),
15
26
  }));
16
27
 
17
- vi.mock("./probe.js", () => ({
18
- probeFeishu: probeFeishuMock,
19
- }));
20
-
21
- vi.mock("./client.js", () => ({
22
- createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
23
- createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
24
- }));
25
-
26
28
  import {
27
29
  clearFeishuWebhookRateLimitStateForTest,
28
30
  getFeishuWebhookRateLimitStateSizeForTest,
@@ -71,7 +73,7 @@ function buildConfig(params: {
71
73
  [params.accountId]: {
72
74
  enabled: true,
73
75
  appId: "cli_test",
74
- appSecret: "secret_test",
76
+ appSecret: "secret_test", // pragma: allowlist secret
75
77
  connectionMode: "webhook",
76
78
  webhookHost: "127.0.0.1",
77
79
  webhookPort: params.port,
@@ -0,0 +1,25 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
2
+ import { describe, expect, it } from "vitest";
3
+ import { feishuOnboardingAdapter } from "./onboarding.js";
4
+
5
+ describe("feishu onboarding status", () => {
6
+ it("treats SecretRef appSecret as configured when appId is present", async () => {
7
+ const status = await feishuOnboardingAdapter.getStatus({
8
+ cfg: {
9
+ channels: {
10
+ feishu: {
11
+ appId: "cli_a123456",
12
+ appSecret: {
13
+ source: "env",
14
+ provider: "default",
15
+ id: "FEISHU_APP_SECRET",
16
+ },
17
+ },
18
+ },
19
+ } as OpenClawConfig,
20
+ accountOverrides: {},
21
+ });
22
+
23
+ expect(status.configured).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("./probe.js", () => ({
4
+ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
5
+ }));
6
+
7
+ import { feishuOnboardingAdapter } from "./onboarding.js";
8
+
9
+ const baseConfigureContext = {
10
+ runtime: {} as never,
11
+ accountOverrides: {},
12
+ shouldPromptAccountIds: false,
13
+ forceAllowFrom: false,
14
+ };
15
+
16
+ const baseStatusContext = {
17
+ accountOverrides: {},
18
+ };
19
+
20
+ async function withEnvVars(values: Record<string, string | undefined>, run: () => Promise<void>) {
21
+ const previous = new Map<string, string | undefined>();
22
+ for (const [key, value] of Object.entries(values)) {
23
+ previous.set(key, process.env[key]);
24
+ if (value === undefined) {
25
+ delete process.env[key];
26
+ } else {
27
+ process.env[key] = value;
28
+ }
29
+ }
30
+
31
+ try {
32
+ await run();
33
+ } finally {
34
+ for (const [key, prior] of previous.entries()) {
35
+ if (prior === undefined) {
36
+ delete process.env[key];
37
+ } else {
38
+ process.env[key] = prior;
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) {
45
+ return await feishuOnboardingAdapter.getStatus({
46
+ cfg: {
47
+ channels: {
48
+ feishu: {
49
+ appId: { source: "env", id: params.appIdKey, provider: "default" },
50
+ appSecret: { source: "env", id: params.appSecretKey, provider: "default" },
51
+ },
52
+ },
53
+ } as never,
54
+ ...baseStatusContext,
55
+ });
56
+ }
57
+
58
+ describe("feishuOnboardingAdapter.configure", () => {
59
+ it("does not throw when config appId/appSecret are SecretRef objects", async () => {
60
+ const text = vi
61
+ .fn()
62
+ .mockResolvedValueOnce("cli_from_prompt")
63
+ .mockResolvedValueOnce("secret_from_prompt")
64
+ .mockResolvedValueOnce("oc_group_1");
65
+
66
+ const prompter = {
67
+ note: vi.fn(async () => undefined),
68
+ text,
69
+ confirm: vi.fn(async () => true),
70
+ select: vi.fn(
71
+ async ({ initialValue }: { initialValue?: string }) => initialValue ?? "allowlist",
72
+ ),
73
+ } as never;
74
+
75
+ await expect(
76
+ feishuOnboardingAdapter.configure({
77
+ cfg: {
78
+ channels: {
79
+ feishu: {
80
+ appId: { source: "env", id: "FEISHU_APP_ID", provider: "default" },
81
+ appSecret: { source: "env", id: "FEISHU_APP_SECRET", provider: "default" },
82
+ },
83
+ },
84
+ } as never,
85
+ prompter,
86
+ ...baseConfigureContext,
87
+ }),
88
+ ).resolves.toBeTruthy();
89
+ });
90
+ });
91
+
92
+ describe("feishuOnboardingAdapter.getStatus", () => {
93
+ it("does not fallback to top-level appId when account explicitly sets empty appId", async () => {
94
+ const status = await feishuOnboardingAdapter.getStatus({
95
+ cfg: {
96
+ channels: {
97
+ feishu: {
98
+ appId: "top_level_app",
99
+ accounts: {
100
+ main: {
101
+ appId: "",
102
+ appSecret: "sample-app-credential", // pragma: allowlist secret
103
+ },
104
+ },
105
+ },
106
+ },
107
+ } as never,
108
+ ...baseStatusContext,
109
+ });
110
+
111
+ expect(status.configured).toBe(false);
112
+ });
113
+
114
+ it("treats env SecretRef appId as not configured when env var is missing", async () => {
115
+ const appIdKey = "FEISHU_APP_ID_STATUS_MISSING_TEST";
116
+ const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_MISSING_TEST"; // pragma: allowlist secret
117
+ await withEnvVars(
118
+ {
119
+ [appIdKey]: undefined,
120
+ [appSecretKey]: "env-credential-456", // pragma: allowlist secret
121
+ },
122
+ async () => {
123
+ const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey });
124
+ expect(status.configured).toBe(false);
125
+ },
126
+ );
127
+ });
128
+
129
+ it("treats env SecretRef appId/appSecret as configured in status", async () => {
130
+ const appIdKey = "FEISHU_APP_ID_STATUS_TEST";
131
+ const appSecretKey = "FEISHU_APP_CREDENTIAL_STATUS_TEST"; // pragma: allowlist secret
132
+ await withEnvVars(
133
+ {
134
+ [appIdKey]: "cli_env_123",
135
+ [appSecretKey]: "env-credential-456", // pragma: allowlist secret
136
+ },
137
+ async () => {
138
+ const status = await getStatusWithEnvRefs({ appIdKey, appSecretKey });
139
+ expect(status.configured).toBe(true);
140
+ },
141
+ );
142
+ });
143
+ });