@nextclaw/channel-plugin-feishu 0.2.12 → 0.2.14

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 (102) hide show
  1. package/README.md +3 -1
  2. package/index.ts +65 -0
  3. package/openclaw.plugin.json +3 -7
  4. package/package.json +33 -9
  5. package/skills/feishu-doc/SKILL.md +211 -0
  6. package/skills/feishu-doc/references/block-types.md +103 -0
  7. package/skills/feishu-drive/SKILL.md +97 -0
  8. package/skills/feishu-perm/SKILL.md +119 -0
  9. package/skills/feishu-wiki/SKILL.md +111 -0
  10. package/src/accounts.test.ts +371 -0
  11. package/src/accounts.ts +244 -0
  12. package/src/async.ts +62 -0
  13. package/src/bitable.ts +725 -0
  14. package/src/bot.card-action.test.ts +63 -0
  15. package/src/bot.checkBotMentioned.test.ts +193 -0
  16. package/src/bot.stripBotMention.test.ts +134 -0
  17. package/src/bot.test.ts +2107 -0
  18. package/src/bot.ts +1556 -0
  19. package/src/card-action.ts +79 -0
  20. package/src/channel.test.ts +48 -0
  21. package/src/channel.ts +369 -0
  22. package/src/chat-schema.ts +24 -0
  23. package/src/chat.test.ts +89 -0
  24. package/src/chat.ts +130 -0
  25. package/src/client.test.ts +324 -0
  26. package/src/client.ts +196 -0
  27. package/src/config-schema.test.ts +247 -0
  28. package/src/config-schema.ts +306 -0
  29. package/src/dedup.ts +203 -0
  30. package/src/directory.test.ts +40 -0
  31. package/src/directory.ts +156 -0
  32. package/src/doc-schema.ts +182 -0
  33. package/src/docx-batch-insert.test.ts +90 -0
  34. package/src/docx-batch-insert.ts +187 -0
  35. package/src/docx-color-text.ts +149 -0
  36. package/src/docx-table-ops.ts +298 -0
  37. package/src/docx.account-selection.test.ts +70 -0
  38. package/src/docx.test.ts +445 -0
  39. package/src/docx.ts +1460 -0
  40. package/src/drive-schema.ts +46 -0
  41. package/src/drive.ts +228 -0
  42. package/src/dynamic-agent.ts +131 -0
  43. package/src/external-keys.test.ts +20 -0
  44. package/src/external-keys.ts +19 -0
  45. package/src/feishu-command-handler.ts +59 -0
  46. package/src/media.test.ts +523 -0
  47. package/src/media.ts +484 -0
  48. package/src/mention.ts +133 -0
  49. package/src/monitor.account.ts +562 -0
  50. package/src/monitor.reaction.test.ts +653 -0
  51. package/src/monitor.startup.test.ts +190 -0
  52. package/src/monitor.startup.ts +64 -0
  53. package/src/monitor.state.defaults.test.ts +46 -0
  54. package/src/monitor.state.ts +155 -0
  55. package/src/monitor.test-mocks.ts +45 -0
  56. package/src/monitor.transport.ts +264 -0
  57. package/src/monitor.ts +95 -0
  58. package/src/monitor.webhook-e2e.test.ts +214 -0
  59. package/src/monitor.webhook-security.test.ts +142 -0
  60. package/src/monitor.webhook.test-helpers.ts +98 -0
  61. package/src/onboarding.status.test.ts +25 -0
  62. package/src/onboarding.test.ts +143 -0
  63. package/src/onboarding.ts +489 -0
  64. package/src/outbound.test.ts +356 -0
  65. package/src/outbound.ts +176 -0
  66. package/src/perm-schema.ts +52 -0
  67. package/src/perm.ts +176 -0
  68. package/src/policy.test.ts +154 -0
  69. package/src/policy.ts +123 -0
  70. package/src/post.test.ts +105 -0
  71. package/src/post.ts +274 -0
  72. package/src/probe.test.ts +270 -0
  73. package/src/probe.ts +156 -0
  74. package/src/reactions.ts +153 -0
  75. package/src/reply-dispatcher.test.ts +513 -0
  76. package/src/reply-dispatcher.ts +397 -0
  77. package/src/runtime.ts +6 -0
  78. package/src/secret-input.ts +13 -0
  79. package/src/send-message.ts +71 -0
  80. package/src/send-result.ts +29 -0
  81. package/src/send-target.test.ts +74 -0
  82. package/src/send-target.ts +29 -0
  83. package/src/send.reply-fallback.test.ts +189 -0
  84. package/src/send.test.ts +168 -0
  85. package/src/send.ts +481 -0
  86. package/src/streaming-card.test.ts +54 -0
  87. package/src/streaming-card.ts +374 -0
  88. package/src/targets.test.ts +70 -0
  89. package/src/targets.ts +107 -0
  90. package/src/tool-account-routing.test.ts +129 -0
  91. package/src/tool-account.ts +70 -0
  92. package/src/tool-factory-test-harness.ts +76 -0
  93. package/src/tool-result.test.ts +32 -0
  94. package/src/tool-result.ts +14 -0
  95. package/src/tools-config.test.ts +21 -0
  96. package/src/tools-config.ts +22 -0
  97. package/src/types.ts +103 -0
  98. package/src/typing.test.ts +144 -0
  99. package/src/typing.ts +210 -0
  100. package/src/wiki-schema.ts +55 -0
  101. package/src/wiki.ts +233 -0
  102. package/index.js +0 -27
@@ -0,0 +1,142 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createFeishuClientMockModule,
4
+ createFeishuRuntimeMockModule,
5
+ } from "./monitor.test-mocks.js";
6
+ import {
7
+ buildWebhookConfig,
8
+ getFreePort,
9
+ withRunningWebhookMonitor,
10
+ } from "./monitor.webhook.test-helpers.js";
11
+
12
+ const probeFeishuMock = vi.hoisted(() => vi.fn());
13
+
14
+ vi.mock("./probe.js", () => ({
15
+ probeFeishu: probeFeishuMock,
16
+ }));
17
+
18
+ vi.mock("./client.js", () => createFeishuClientMockModule());
19
+ vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
20
+
21
+ vi.mock("@larksuiteoapi/node-sdk", () => ({
22
+ adaptDefault: vi.fn(
23
+ () => (_req: unknown, res: { statusCode?: number; end: (s: string) => void }) => {
24
+ res.statusCode = 200;
25
+ res.end("ok");
26
+ },
27
+ ),
28
+ }));
29
+
30
+ import {
31
+ clearFeishuWebhookRateLimitStateForTest,
32
+ getFeishuWebhookRateLimitStateSizeForTest,
33
+ isWebhookRateLimitedForTest,
34
+ monitorFeishuProvider,
35
+ stopFeishuMonitor,
36
+ } from "./monitor.js";
37
+
38
+ afterEach(() => {
39
+ clearFeishuWebhookRateLimitStateForTest();
40
+ stopFeishuMonitor();
41
+ });
42
+
43
+ describe("Feishu webhook security hardening", () => {
44
+ it("rejects webhook mode without verificationToken", async () => {
45
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
46
+
47
+ const cfg = buildWebhookConfig({
48
+ accountId: "missing-token",
49
+ path: "/hook-missing-token",
50
+ port: await getFreePort(),
51
+ });
52
+
53
+ await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(
54
+ /requires verificationToken/i,
55
+ );
56
+ });
57
+
58
+ it("rejects webhook mode without encryptKey", async () => {
59
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
60
+
61
+ const cfg = buildWebhookConfig({
62
+ accountId: "missing-encrypt-key",
63
+ path: "/hook-missing-encrypt",
64
+ port: await getFreePort(),
65
+ verificationToken: "verify_token",
66
+ });
67
+
68
+ await expect(monitorFeishuProvider({ config: cfg })).rejects.toThrow(/requires encryptKey/i);
69
+ });
70
+
71
+ it("returns 415 for POST requests without json content type", async () => {
72
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
73
+ await withRunningWebhookMonitor(
74
+ {
75
+ accountId: "content-type",
76
+ path: "/hook-content-type",
77
+ verificationToken: "verify_token",
78
+ encryptKey: "encrypt_key",
79
+ },
80
+ monitorFeishuProvider,
81
+ async (url) => {
82
+ const response = await fetch(url, {
83
+ method: "POST",
84
+ headers: { "content-type": "text/plain" },
85
+ body: "{}",
86
+ });
87
+
88
+ expect(response.status).toBe(415);
89
+ expect(await response.text()).toBe("Unsupported Media Type");
90
+ },
91
+ );
92
+ });
93
+
94
+ it("rate limits webhook burst traffic with 429", async () => {
95
+ probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
96
+ await withRunningWebhookMonitor(
97
+ {
98
+ accountId: "rate-limit",
99
+ path: "/hook-rate-limit",
100
+ verificationToken: "verify_token",
101
+ encryptKey: "encrypt_key",
102
+ },
103
+ monitorFeishuProvider,
104
+ async (url) => {
105
+ let saw429 = false;
106
+ for (let i = 0; i < 130; i += 1) {
107
+ const response = await fetch(url, {
108
+ method: "POST",
109
+ headers: { "content-type": "text/plain" },
110
+ body: "{}",
111
+ });
112
+ if (response.status === 429) {
113
+ saw429 = true;
114
+ expect(await response.text()).toBe("Too Many Requests");
115
+ break;
116
+ }
117
+ }
118
+
119
+ expect(saw429).toBe(true);
120
+ },
121
+ );
122
+ });
123
+
124
+ it("caps tracked webhook rate-limit keys to prevent unbounded growth", () => {
125
+ const now = 1_000_000;
126
+ for (let i = 0; i < 4_500; i += 1) {
127
+ isWebhookRateLimitedForTest(`/feishu-rate-limit:key-${i}`, now);
128
+ }
129
+ expect(getFeishuWebhookRateLimitStateSizeForTest()).toBeLessThanOrEqual(4_096);
130
+ });
131
+
132
+ it("prunes stale webhook rate-limit state after window elapses", () => {
133
+ const now = 2_000_000;
134
+ for (let i = 0; i < 100; i += 1) {
135
+ isWebhookRateLimitedForTest(`/feishu-rate-limit-stale:key-${i}`, now);
136
+ }
137
+ expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(100);
138
+
139
+ isWebhookRateLimitedForTest("/feishu-rate-limit-stale:fresh", now + 60_001);
140
+ expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(1);
141
+ });
142
+ });
@@ -0,0 +1,98 @@
1
+ import { createServer } from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
4
+ import { vi } from "vitest";
5
+ import type { monitorFeishuProvider } from "./monitor.js";
6
+
7
+ export async function getFreePort(): Promise<number> {
8
+ const server = createServer();
9
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
10
+ const address = server.address() as AddressInfo | null;
11
+ if (!address) {
12
+ throw new Error("missing server address");
13
+ }
14
+ await new Promise<void>((resolve) => server.close(() => resolve()));
15
+ return address.port;
16
+ }
17
+
18
+ async function waitUntilServerReady(url: string): Promise<void> {
19
+ for (let i = 0; i < 50; i += 1) {
20
+ try {
21
+ const response = await fetch(url, { method: "GET" });
22
+ if (response.status >= 200 && response.status < 500) {
23
+ return;
24
+ }
25
+ } catch {
26
+ // retry
27
+ }
28
+ await new Promise((resolve) => setTimeout(resolve, 20));
29
+ }
30
+ throw new Error(`server did not start: ${url}`);
31
+ }
32
+
33
+ export function buildWebhookConfig(params: {
34
+ accountId: string;
35
+ path: string;
36
+ port: number;
37
+ verificationToken?: string;
38
+ encryptKey?: string;
39
+ }): ClawdbotConfig {
40
+ return {
41
+ channels: {
42
+ feishu: {
43
+ enabled: true,
44
+ accounts: {
45
+ [params.accountId]: {
46
+ enabled: true,
47
+ appId: "cli_test",
48
+ appSecret: "secret_test", // pragma: allowlist secret
49
+ connectionMode: "webhook",
50
+ webhookHost: "127.0.0.1",
51
+ webhookPort: params.port,
52
+ webhookPath: params.path,
53
+ encryptKey: params.encryptKey,
54
+ verificationToken: params.verificationToken,
55
+ },
56
+ },
57
+ },
58
+ },
59
+ } as ClawdbotConfig;
60
+ }
61
+
62
+ export async function withRunningWebhookMonitor(
63
+ params: {
64
+ accountId: string;
65
+ path: string;
66
+ verificationToken: string;
67
+ encryptKey: string;
68
+ },
69
+ monitor: typeof monitorFeishuProvider,
70
+ run: (url: string) => Promise<void>,
71
+ ) {
72
+ const port = await getFreePort();
73
+ const cfg = buildWebhookConfig({
74
+ accountId: params.accountId,
75
+ path: params.path,
76
+ port,
77
+ encryptKey: params.encryptKey,
78
+ verificationToken: params.verificationToken,
79
+ });
80
+
81
+ const abortController = new AbortController();
82
+ const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
83
+ const monitorPromise = monitor({
84
+ config: cfg,
85
+ runtime,
86
+ abortSignal: abortController.signal,
87
+ });
88
+
89
+ const url = `http://127.0.0.1:${port}${params.path}`;
90
+ await waitUntilServerReady(url);
91
+
92
+ try {
93
+ await run(url);
94
+ } finally {
95
+ abortController.abort();
96
+ await monitorPromise;
97
+ }
98
+ }
@@ -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
+ });