@openclaw/feishu 2026.3.1 → 2026.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. package/src/wiki.ts +15 -19
package/src/outbound.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
3
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
4
4
  import { resolveFeishuAccount } from "./accounts.js";
5
5
  import { sendMediaFeishu } from "./media.js";
6
6
  import { getFeishuRuntime } from "./runtime.js";
@@ -43,21 +43,37 @@ function shouldUseCard(text: string): boolean {
43
43
  return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
44
44
  }
45
45
 
46
+ function resolveReplyToMessageId(params: {
47
+ replyToId?: string | null;
48
+ threadId?: string | number | null;
49
+ }): string | undefined {
50
+ const replyToId = params.replyToId?.trim();
51
+ if (replyToId) {
52
+ return replyToId;
53
+ }
54
+ if (params.threadId == null) {
55
+ return undefined;
56
+ }
57
+ const trimmed = String(params.threadId).trim();
58
+ return trimmed || undefined;
59
+ }
60
+
46
61
  async function sendOutboundText(params: {
47
62
  cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
48
63
  to: string;
49
64
  text: string;
65
+ replyToMessageId?: string;
50
66
  accountId?: string;
51
67
  }) {
52
- const { cfg, to, text, accountId } = params;
68
+ const { cfg, to, text, accountId, replyToMessageId } = params;
53
69
  const account = resolveFeishuAccount({ cfg, accountId });
54
70
  const renderMode = account.config?.renderMode ?? "auto";
55
71
 
56
72
  if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
57
- return sendMarkdownCardFeishu({ cfg, to, text, accountId });
73
+ return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId });
58
74
  }
59
75
 
60
- return sendMessageFeishu({ cfg, to, text, accountId });
76
+ return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId });
61
77
  }
62
78
 
63
79
  export const feishuOutbound: ChannelOutboundAdapter = {
@@ -65,7 +81,8 @@ export const feishuOutbound: ChannelOutboundAdapter = {
65
81
  chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
66
82
  chunkerMode: "markdown",
67
83
  textChunkLimit: 4000,
68
- sendText: async ({ cfg, to, text, accountId }) => {
84
+ sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
85
+ const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
69
86
  // Scheme A compatibility shim:
70
87
  // when upstream accidentally returns a local image path as plain text,
71
88
  // auto-upload and send as Feishu image message instead of leaking path text.
@@ -77,6 +94,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
77
94
  to,
78
95
  mediaUrl: localImagePath,
79
96
  accountId: accountId ?? undefined,
97
+ replyToMessageId,
80
98
  });
81
99
  return { channel: "feishu", ...result };
82
100
  } catch (err) {
@@ -90,10 +108,21 @@ export const feishuOutbound: ChannelOutboundAdapter = {
90
108
  to,
91
109
  text,
92
110
  accountId: accountId ?? undefined,
111
+ replyToMessageId,
93
112
  });
94
113
  return { channel: "feishu", ...result };
95
114
  },
96
- sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
115
+ sendMedia: async ({
116
+ cfg,
117
+ to,
118
+ text,
119
+ mediaUrl,
120
+ accountId,
121
+ mediaLocalRoots,
122
+ replyToId,
123
+ threadId,
124
+ }) => {
125
+ const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
97
126
  // Send text first if provided
98
127
  if (text?.trim()) {
99
128
  await sendOutboundText({
@@ -101,6 +130,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
101
130
  to,
102
131
  text,
103
132
  accountId: accountId ?? undefined,
133
+ replyToMessageId,
104
134
  });
105
135
  }
106
136
 
@@ -113,6 +143,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
113
143
  mediaUrl,
114
144
  accountId: accountId ?? undefined,
115
145
  mediaLocalRoots,
146
+ replyToMessageId,
116
147
  });
117
148
  return { channel: "feishu", ...result };
118
149
  } catch (err) {
@@ -125,6 +156,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
125
156
  to,
126
157
  text: fallbackText,
127
158
  accountId: accountId ?? undefined,
159
+ replyToMessageId,
128
160
  });
129
161
  return { channel: "feishu", ...result };
130
162
  }
@@ -136,6 +168,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
136
168
  to,
137
169
  text: text ?? "",
138
170
  accountId: accountId ?? undefined,
171
+ replyToMessageId,
139
172
  });
140
173
  return { channel: "feishu", ...result };
141
174
  },
package/src/perm.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
3
3
  import { listEnabledFeishuAccounts } from "./accounts.js";
4
4
  import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
5
5
  import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
6
-
7
- // ============ Helpers ============
8
-
9
- function json(data: unknown) {
10
- return {
11
- content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
12
- details: data,
13
- };
14
- }
6
+ import {
7
+ jsonToolResult,
8
+ toolExecutionErrorResult,
9
+ unknownToolActionResult,
10
+ } from "./tool-result.js";
15
11
 
16
12
  type ListTokenType =
17
13
  | "doc"
@@ -154,21 +150,21 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
154
150
  });
155
151
  switch (p.action) {
156
152
  case "list":
157
- return json(await listMembers(client, p.token, p.type));
153
+ return jsonToolResult(await listMembers(client, p.token, p.type));
158
154
  case "add":
159
- return json(
155
+ return jsonToolResult(
160
156
  await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
161
157
  );
162
158
  case "remove":
163
- return json(
159
+ return jsonToolResult(
164
160
  await removeMember(client, p.token, p.type, p.member_type, p.member_id),
165
161
  );
166
162
  default:
167
163
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
168
- return json({ error: `Unknown action: ${(p as any).action}` });
164
+ return unknownToolActionResult((p as { action?: unknown }).action);
169
165
  }
170
166
  } catch (err) {
171
- return json({ error: err instanceof Error ? err.message : String(err) });
167
+ return toolExecutionErrorResult(err);
172
168
  }
173
169
  },
174
170
  };
@@ -110,5 +110,45 @@ describe("feishu policy", () => {
110
110
  }),
111
111
  ).toBe(true);
112
112
  });
113
+
114
+ it("allows group when groupPolicy is 'open'", () => {
115
+ expect(
116
+ isFeishuGroupAllowed({
117
+ groupPolicy: "open",
118
+ allowFrom: [],
119
+ senderId: "oc_group_999",
120
+ }),
121
+ ).toBe(true);
122
+ });
123
+
124
+ it("treats 'allowall' as equivalent to 'open'", () => {
125
+ expect(
126
+ isFeishuGroupAllowed({
127
+ groupPolicy: "allowall",
128
+ allowFrom: [],
129
+ senderId: "oc_group_999",
130
+ }),
131
+ ).toBe(true);
132
+ });
133
+
134
+ it("rejects group when groupPolicy is 'disabled'", () => {
135
+ expect(
136
+ isFeishuGroupAllowed({
137
+ groupPolicy: "disabled",
138
+ allowFrom: ["oc_group_999"],
139
+ senderId: "oc_group_999",
140
+ }),
141
+ ).toBe(false);
142
+ });
143
+
144
+ it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
145
+ expect(
146
+ isFeishuGroupAllowed({
147
+ groupPolicy: "allowlist",
148
+ allowFrom: [],
149
+ senderId: "oc_group_999",
150
+ }),
151
+ ).toBe(false);
152
+ });
113
153
  });
114
154
  });
package/src/policy.ts CHANGED
@@ -2,7 +2,8 @@ import type {
2
2
  AllowlistMatch,
3
3
  ChannelGroupContext,
4
4
  GroupToolPolicyConfig,
5
- } from "openclaw/plugin-sdk";
5
+ } from "openclaw/plugin-sdk/feishu";
6
+ import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/feishu";
6
7
  import { normalizeFeishuTarget } from "./targets.js";
7
8
  import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
8
9
 
@@ -92,20 +93,18 @@ export function resolveFeishuGroupToolPolicy(
92
93
  }
93
94
 
94
95
  export function isFeishuGroupAllowed(params: {
95
- groupPolicy: "open" | "allowlist" | "disabled";
96
+ groupPolicy: "open" | "allowlist" | "disabled" | "allowall";
96
97
  allowFrom: Array<string | number>;
97
98
  senderId: string;
98
99
  senderIds?: Array<string | null | undefined>;
99
100
  senderName?: string | null;
100
101
  }): boolean {
101
- const { groupPolicy } = params;
102
- if (groupPolicy === "disabled") {
103
- return false;
104
- }
105
- if (groupPolicy === "open") {
106
- return true;
107
- }
108
- return resolveFeishuAllowlistMatch(params).allowed;
102
+ return evaluateSenderGroupAccessForPolicy({
103
+ groupPolicy: params.groupPolicy === "allowall" ? "open" : params.groupPolicy,
104
+ groupAllowFrom: params.allowFrom.map((entry) => String(entry)),
105
+ senderId: params.senderId,
106
+ isSenderAllowed: () => resolveFeishuAllowlistMatch(params).allowed,
107
+ }).allowed;
109
108
  }
110
109
 
111
110
  export function resolveFeishuReplyPolicy(params: {
package/src/probe.test.ts CHANGED
@@ -34,7 +34,7 @@ describe("probeFeishu", () => {
34
34
  });
35
35
 
36
36
  it("returns error when appId is missing", async () => {
37
- const result = await probeFeishu({ appSecret: "secret" } as never);
37
+ const result = await probeFeishu({ appSecret: "secret" } as never); // pragma: allowlist secret
38
38
  expect(result).toEqual({ ok: false, error: "missing credentials (appId, appSecret)" });
39
39
  });
40
40
 
@@ -49,7 +49,7 @@ describe("probeFeishu", () => {
49
49
  bot: { bot_name: "TestBot", open_id: "ou_abc123" },
50
50
  });
51
51
 
52
- const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
52
+ const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
53
53
  expect(result).toEqual({
54
54
  ok: true,
55
55
  appId: "cli_123",
@@ -59,13 +59,13 @@ describe("probeFeishu", () => {
59
59
  expect(requestFn).toHaveBeenCalledTimes(1);
60
60
  });
61
61
 
62
- it("uses explicit timeout for bot info request", async () => {
62
+ it("passes the probe timeout to the Feishu request", async () => {
63
63
  const requestFn = setupClient({
64
64
  code: 0,
65
65
  bot: { bot_name: "TestBot", open_id: "ou_abc123" },
66
66
  });
67
67
 
68
- await probeFeishu({ appId: "cli_123", appSecret: "secret" });
68
+ await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
69
69
 
70
70
  expect(requestFn).toHaveBeenCalledWith(
71
71
  expect.objectContaining({
@@ -98,21 +98,20 @@ describe("probeFeishu", () => {
98
98
  abortController.abort();
99
99
 
100
100
  const result = await probeFeishu(
101
- { appId: "cli_123", appSecret: "secret" },
101
+ { appId: "cli_123", appSecret: "secret" }, // pragma: allowlist secret
102
102
  { abortSignal: abortController.signal },
103
103
  );
104
104
 
105
105
  expect(result).toMatchObject({ ok: false, error: "probe aborted" });
106
106
  expect(createFeishuClientMock).not.toHaveBeenCalled();
107
107
  });
108
-
109
108
  it("returns cached result on subsequent calls within TTL", async () => {
110
109
  const requestFn = setupClient({
111
110
  code: 0,
112
111
  bot: { bot_name: "TestBot", open_id: "ou_abc123" },
113
112
  });
114
113
 
115
- const creds = { appId: "cli_123", appSecret: "secret" };
114
+ const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
116
115
  const first = await probeFeishu(creds);
117
116
  const second = await probeFeishu(creds);
118
117
 
@@ -129,11 +128,11 @@ describe("probeFeishu", () => {
129
128
  bot: { bot_name: "TestBot", open_id: "ou_abc123" },
130
129
  });
131
130
 
132
- const creds = { appId: "cli_123", appSecret: "secret" };
131
+ const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
133
132
  await probeFeishu(creds);
134
133
  expect(requestFn).toHaveBeenCalledTimes(1);
135
134
 
136
- // Advance time past the 10-minute TTL
135
+ // Advance time past the success TTL
137
136
  vi.advanceTimersByTime(10 * 60 * 1000 + 1);
138
137
 
139
138
  await probeFeishu(creds);
@@ -143,29 +142,48 @@ describe("probeFeishu", () => {
143
142
  }
144
143
  });
145
144
 
146
- it("does not cache failed probe results (API error)", async () => {
147
- const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
148
- createFeishuClientMock.mockReturnValue({ request: requestFn });
145
+ it("caches failed probe results (API error) for the error TTL", async () => {
146
+ vi.useFakeTimers();
147
+ try {
148
+ const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
149
+ createFeishuClientMock.mockReturnValue({ request: requestFn });
149
150
 
150
- const creds = { appId: "cli_123", appSecret: "secret" };
151
- const first = await probeFeishu(creds);
152
- expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
151
+ const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
152
+ const first = await probeFeishu(creds);
153
+ const second = await probeFeishu(creds);
154
+ expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
155
+ expect(second).toMatchObject({ ok: false, error: "API error: token expired" });
156
+ expect(requestFn).toHaveBeenCalledTimes(1);
153
157
 
154
- // Second call should make a fresh request since failures are not cached
155
- await probeFeishu(creds);
156
- expect(requestFn).toHaveBeenCalledTimes(2);
158
+ vi.advanceTimersByTime(60 * 1000 + 1);
159
+
160
+ await probeFeishu(creds);
161
+ expect(requestFn).toHaveBeenCalledTimes(2);
162
+ } finally {
163
+ vi.useRealTimers();
164
+ }
157
165
  });
158
166
 
159
- it("does not cache results when request throws", async () => {
160
- const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
161
- createFeishuClientMock.mockReturnValue({ request: requestFn });
167
+ it("caches thrown request errors for the error TTL", async () => {
168
+ vi.useFakeTimers();
169
+ try {
170
+ const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
171
+ createFeishuClientMock.mockReturnValue({ request: requestFn });
162
172
 
163
- const creds = { appId: "cli_123", appSecret: "secret" };
164
- const first = await probeFeishu(creds);
165
- expect(first).toMatchObject({ ok: false, error: "network error" });
173
+ const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
174
+ const first = await probeFeishu(creds);
175
+ const second = await probeFeishu(creds);
176
+ expect(first).toMatchObject({ ok: false, error: "network error" });
177
+ expect(second).toMatchObject({ ok: false, error: "network error" });
178
+ expect(requestFn).toHaveBeenCalledTimes(1);
166
179
 
167
- await probeFeishu(creds);
168
- expect(requestFn).toHaveBeenCalledTimes(2);
180
+ vi.advanceTimersByTime(60 * 1000 + 1);
181
+
182
+ await probeFeishu(creds);
183
+ expect(requestFn).toHaveBeenCalledTimes(2);
184
+ } finally {
185
+ vi.useRealTimers();
186
+ }
169
187
  });
170
188
 
171
189
  it("caches per account independently", async () => {
@@ -174,15 +192,15 @@ describe("probeFeishu", () => {
174
192
  bot: { bot_name: "Bot1", open_id: "ou_1" },
175
193
  });
176
194
 
177
- await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
195
+ await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
178
196
  expect(requestFn).toHaveBeenCalledTimes(1);
179
197
 
180
198
  // Different appId should trigger a new API call
181
- await probeFeishu({ appId: "cli_bbb", appSecret: "s2" });
199
+ await probeFeishu({ appId: "cli_bbb", appSecret: "s2" }); // pragma: allowlist secret
182
200
  expect(requestFn).toHaveBeenCalledTimes(2);
183
201
 
184
202
  // Same appId + appSecret as first call should return cached
185
- await probeFeishu({ appId: "cli_aaa", appSecret: "s1" });
203
+ await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
186
204
  expect(requestFn).toHaveBeenCalledTimes(2);
187
205
  });
188
206
 
@@ -193,12 +211,12 @@ describe("probeFeishu", () => {
193
211
  });
194
212
 
195
213
  // First account with appId + secret A
196
- await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" });
214
+ await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret
197
215
  expect(requestFn).toHaveBeenCalledTimes(1);
198
216
 
199
217
  // Second account with same appId but different secret (e.g. after rotation)
200
218
  // must NOT reuse the cached result
201
- await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" });
219
+ await probeFeishu({ appId: "cli_shared", appSecret: "secret_bbb" }); // pragma: allowlist secret
202
220
  expect(requestFn).toHaveBeenCalledTimes(2);
203
221
  });
204
222
 
@@ -209,14 +227,14 @@ describe("probeFeishu", () => {
209
227
  });
210
228
 
211
229
  // Two accounts with same appId+appSecret but different accountIds are cached separately
212
- await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
230
+ await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
213
231
  expect(requestFn).toHaveBeenCalledTimes(1);
214
232
 
215
- await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" });
233
+ await probeFeishu({ accountId: "acct-2", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
216
234
  expect(requestFn).toHaveBeenCalledTimes(2);
217
235
 
218
236
  // Same accountId should return cached
219
- await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" });
237
+ await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
220
238
  expect(requestFn).toHaveBeenCalledTimes(2);
221
239
  });
222
240
 
@@ -226,7 +244,7 @@ describe("probeFeishu", () => {
226
244
  bot: { bot_name: "TestBot", open_id: "ou_abc123" },
227
245
  });
228
246
 
229
- const creds = { appId: "cli_123", appSecret: "secret" };
247
+ const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
230
248
  await probeFeishu(creds);
231
249
  expect(requestFn).toHaveBeenCalledTimes(1);
232
250
 
@@ -242,7 +260,7 @@ describe("probeFeishu", () => {
242
260
  data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
243
261
  });
244
262
 
245
- const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" });
263
+ const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
246
264
  expect(result).toEqual({
247
265
  ok: true,
248
266
  appId: "cli_123",
package/src/probe.ts CHANGED
@@ -2,15 +2,16 @@ import { raceWithTimeoutAndAbort } from "./async.js";
2
2
  import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
3
3
  import type { FeishuProbeResult } from "./types.js";
4
4
 
5
- /** Cache successful probe results to reduce API calls (bot info is static).
5
+ /** Cache probe results to reduce repeated health-check calls.
6
6
  * Gateway health checks call probeFeishu() every minute; without caching this
7
7
  * burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
8
- * A 10-min TTL cuts that to ~4,320 calls/month. (#26684) */
8
+ * Successful bot info is effectively static, while failures are cached briefly
9
+ * to avoid hammering the API during transient outages. */
9
10
  const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
10
- const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
11
+ const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
12
+ const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
11
13
  const MAX_PROBE_CACHE_SIZE = 64;
12
14
  export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
13
-
14
15
  export type ProbeFeishuOptions = {
15
16
  timeoutMs?: number;
16
17
  abortSignal?: AbortSignal;
@@ -23,6 +24,21 @@ type FeishuBotInfoResponse = {
23
24
  data?: { bot?: { bot_name?: string; open_id?: string } };
24
25
  };
25
26
 
27
+ function setCachedProbeResult(
28
+ cacheKey: string,
29
+ result: FeishuProbeResult,
30
+ ttlMs: number,
31
+ ): FeishuProbeResult {
32
+ probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
33
+ if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
34
+ const oldest = probeCache.keys().next().value;
35
+ if (oldest !== undefined) {
36
+ probeCache.delete(oldest);
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
26
42
  export async function probeFeishu(
27
43
  creds?: FeishuClientCredentials,
28
44
  options: ProbeFeishuOptions = {},
@@ -78,11 +94,15 @@ export async function probeFeishu(
78
94
  };
79
95
  }
80
96
  if (responseResult.status === "timeout") {
81
- return {
82
- ok: false,
83
- appId: creds.appId,
84
- error: `probe timed out after ${timeoutMs}ms`,
85
- };
97
+ return setCachedProbeResult(
98
+ cacheKey,
99
+ {
100
+ ok: false,
101
+ appId: creds.appId,
102
+ error: `probe timed out after ${timeoutMs}ms`,
103
+ },
104
+ PROBE_ERROR_TTL_MS,
105
+ );
86
106
  }
87
107
 
88
108
  const response = responseResult.value;
@@ -95,38 +115,38 @@ export async function probeFeishu(
95
115
  }
96
116
 
97
117
  if (response.code !== 0) {
98
- return {
99
- ok: false,
100
- appId: creds.appId,
101
- error: `API error: ${response.msg || `code ${response.code}`}`,
102
- };
118
+ return setCachedProbeResult(
119
+ cacheKey,
120
+ {
121
+ ok: false,
122
+ appId: creds.appId,
123
+ error: `API error: ${response.msg || `code ${response.code}`}`,
124
+ },
125
+ PROBE_ERROR_TTL_MS,
126
+ );
103
127
  }
104
128
 
105
129
  const bot = response.bot || response.data?.bot;
106
- const result: FeishuProbeResult = {
107
- ok: true,
108
- appId: creds.appId,
109
- botName: bot?.bot_name,
110
- botOpenId: bot?.open_id,
111
- };
112
-
113
- // Cache successful results only
114
- probeCache.set(cacheKey, { result, expiresAt: Date.now() + PROBE_CACHE_TTL_MS });
115
- // Evict oldest entry if cache exceeds max size
116
- if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
117
- const oldest = probeCache.keys().next().value;
118
- if (oldest !== undefined) {
119
- probeCache.delete(oldest);
120
- }
121
- }
122
-
123
- return result;
130
+ return setCachedProbeResult(
131
+ cacheKey,
132
+ {
133
+ ok: true,
134
+ appId: creds.appId,
135
+ botName: bot?.bot_name,
136
+ botOpenId: bot?.open_id,
137
+ },
138
+ PROBE_SUCCESS_TTL_MS,
139
+ );
124
140
  } catch (err) {
125
- return {
126
- ok: false,
127
- appId: creds.appId,
128
- error: err instanceof Error ? err.message : String(err),
129
- };
141
+ return setCachedProbeResult(
142
+ cacheKey,
143
+ {
144
+ ok: false,
145
+ appId: creds.appId,
146
+ error: err instanceof Error ? err.message : String(err),
147
+ },
148
+ PROBE_ERROR_TTL_MS,
149
+ );
130
150
  }
131
151
  }
132
152
 
package/src/reactions.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { resolveFeishuAccount } from "./accounts.js";
3
3
  import { createFeishuClient } from "./client.js";
4
4