@openclaw/feishu 2026.3.11 → 2026.3.13

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.
package/src/onboarding.ts CHANGED
@@ -370,6 +370,37 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
370
370
  },
371
371
  };
372
372
  }
373
+ const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey;
374
+ const encryptKeyPromptState = buildSingleChannelSecretPromptState({
375
+ accountConfigured: hasConfiguredSecretInput(currentEncryptKey),
376
+ hasConfigToken: hasConfiguredSecretInput(currentEncryptKey),
377
+ allowEnv: false,
378
+ });
379
+ const encryptKeyResult = await promptSingleChannelSecretInput({
380
+ cfg: next,
381
+ prompter,
382
+ providerHint: "feishu-webhook",
383
+ credentialLabel: "encrypt key",
384
+ accountConfigured: encryptKeyPromptState.accountConfigured,
385
+ canUseEnv: encryptKeyPromptState.canUseEnv,
386
+ hasConfigToken: encryptKeyPromptState.hasConfigToken,
387
+ envPrompt: "",
388
+ keepPrompt: "Feishu encrypt key already configured. Keep it?",
389
+ inputPrompt: "Enter Feishu encrypt key",
390
+ preferredEnvVar: "FEISHU_ENCRYPT_KEY",
391
+ });
392
+ if (encryptKeyResult.action === "set") {
393
+ next = {
394
+ ...next,
395
+ channels: {
396
+ ...next.channels,
397
+ feishu: {
398
+ ...next.channels?.feishu,
399
+ encryptKey: encryptKeyResult.value,
400
+ },
401
+ },
402
+ };
403
+ }
373
404
  const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
374
405
  const webhookPath = String(
375
406
  await prompter.text({
@@ -29,12 +29,16 @@ vi.mock("./runtime.js", () => ({
29
29
  import { feishuOutbound } from "./outbound.js";
30
30
  const sendText = feishuOutbound.sendText!;
31
31
 
32
+ function resetOutboundMocks() {
33
+ vi.clearAllMocks();
34
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
35
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
36
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
37
+ }
38
+
32
39
  describe("feishuOutbound.sendText local-image auto-convert", () => {
33
40
  beforeEach(() => {
34
- vi.clearAllMocks();
35
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
36
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
37
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
41
+ resetOutboundMocks();
38
42
  });
39
43
 
40
44
  async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
@@ -181,10 +185,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
181
185
 
182
186
  describe("feishuOutbound.sendText replyToId forwarding", () => {
183
187
  beforeEach(() => {
184
- vi.clearAllMocks();
185
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
186
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
187
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
188
+ resetOutboundMocks();
188
189
  });
189
190
 
190
191
  it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => {
@@ -249,10 +250,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
249
250
 
250
251
  describe("feishuOutbound.sendMedia replyToId forwarding", () => {
251
252
  beforeEach(() => {
252
- vi.clearAllMocks();
253
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
254
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
255
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
253
+ resetOutboundMocks();
256
254
  });
257
255
 
258
256
  it("forwards replyToId to sendMediaFeishu", async () => {
@@ -292,10 +290,7 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
292
290
 
293
291
  describe("feishuOutbound.sendMedia renderMode", () => {
294
292
  beforeEach(() => {
295
- vi.clearAllMocks();
296
- sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
297
- sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
298
- sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
293
+ resetOutboundMocks();
299
294
  });
300
295
 
301
296
  it("uses markdown cards for captions when renderMode=card", async () => {
package/src/probe.test.ts CHANGED
@@ -8,6 +8,22 @@ vi.mock("./client.js", () => ({
8
8
 
9
9
  import { FEISHU_PROBE_REQUEST_TIMEOUT_MS, probeFeishu, clearProbeCache } from "./probe.js";
10
10
 
11
+ const DEFAULT_CREDS = { appId: "cli_123", appSecret: "secret" } as const; // pragma: allowlist secret
12
+ const DEFAULT_SUCCESS_RESPONSE = {
13
+ code: 0,
14
+ bot: { bot_name: "TestBot", open_id: "ou_abc123" },
15
+ } as const;
16
+ const DEFAULT_SUCCESS_RESULT = {
17
+ ok: true,
18
+ appId: "cli_123",
19
+ botName: "TestBot",
20
+ botOpenId: "ou_abc123",
21
+ } as const;
22
+ const BOT1_RESPONSE = {
23
+ code: 0,
24
+ bot: { bot_name: "Bot1", open_id: "ou_1" },
25
+ } as const;
26
+
11
27
  function makeRequestFn(response: Record<string, unknown>) {
12
28
  return vi.fn().mockResolvedValue(response);
13
29
  }
@@ -18,6 +34,64 @@ function setupClient(response: Record<string, unknown>) {
18
34
  return requestFn;
19
35
  }
20
36
 
37
+ function setupSuccessClient() {
38
+ return setupClient(DEFAULT_SUCCESS_RESPONSE);
39
+ }
40
+
41
+ async function expectDefaultSuccessResult(
42
+ creds = DEFAULT_CREDS,
43
+ expected: Awaited<ReturnType<typeof probeFeishu>> = DEFAULT_SUCCESS_RESULT,
44
+ ) {
45
+ const result = await probeFeishu(creds);
46
+ expect(result).toEqual(expected);
47
+ }
48
+
49
+ async function withFakeTimers(run: () => Promise<void>) {
50
+ vi.useFakeTimers();
51
+ try {
52
+ await run();
53
+ } finally {
54
+ vi.useRealTimers();
55
+ }
56
+ }
57
+
58
+ async function expectErrorResultCached(params: {
59
+ requestFn: ReturnType<typeof vi.fn>;
60
+ expectedError: string;
61
+ ttlMs: number;
62
+ }) {
63
+ createFeishuClientMock.mockReturnValue({ request: params.requestFn });
64
+
65
+ const first = await probeFeishu(DEFAULT_CREDS);
66
+ const second = await probeFeishu(DEFAULT_CREDS);
67
+ expect(first).toMatchObject({ ok: false, error: params.expectedError });
68
+ expect(second).toMatchObject({ ok: false, error: params.expectedError });
69
+ expect(params.requestFn).toHaveBeenCalledTimes(1);
70
+
71
+ vi.advanceTimersByTime(params.ttlMs + 1);
72
+
73
+ await probeFeishu(DEFAULT_CREDS);
74
+ expect(params.requestFn).toHaveBeenCalledTimes(2);
75
+ }
76
+
77
+ async function expectFreshDefaultProbeAfter(
78
+ requestFn: ReturnType<typeof vi.fn>,
79
+ invalidate: () => void,
80
+ ) {
81
+ await probeFeishu(DEFAULT_CREDS);
82
+ expect(requestFn).toHaveBeenCalledTimes(1);
83
+
84
+ invalidate();
85
+
86
+ await probeFeishu(DEFAULT_CREDS);
87
+ expect(requestFn).toHaveBeenCalledTimes(2);
88
+ }
89
+
90
+ async function readSequentialDefaultProbePair() {
91
+ const first = await probeFeishu(DEFAULT_CREDS);
92
+ return { first, second: await probeFeishu(DEFAULT_CREDS) };
93
+ }
94
+
21
95
  describe("probeFeishu", () => {
22
96
  beforeEach(() => {
23
97
  clearProbeCache();
@@ -44,28 +118,16 @@ describe("probeFeishu", () => {
44
118
  });
45
119
 
46
120
  it("returns bot info on successful probe", async () => {
47
- const requestFn = setupClient({
48
- code: 0,
49
- bot: { bot_name: "TestBot", open_id: "ou_abc123" },
50
- });
121
+ const requestFn = setupSuccessClient();
51
122
 
52
- const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
53
- expect(result).toEqual({
54
- ok: true,
55
- appId: "cli_123",
56
- botName: "TestBot",
57
- botOpenId: "ou_abc123",
58
- });
123
+ await expectDefaultSuccessResult();
59
124
  expect(requestFn).toHaveBeenCalledTimes(1);
60
125
  });
61
126
 
62
127
  it("passes the probe timeout to the Feishu request", async () => {
63
- const requestFn = setupClient({
64
- code: 0,
65
- bot: { bot_name: "TestBot", open_id: "ou_abc123" },
66
- });
128
+ const requestFn = setupSuccessClient();
67
129
 
68
- await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
130
+ await probeFeishu(DEFAULT_CREDS);
69
131
 
70
132
  expect(requestFn).toHaveBeenCalledWith(
71
133
  expect.objectContaining({
@@ -77,19 +139,16 @@ describe("probeFeishu", () => {
77
139
  });
78
140
 
79
141
  it("returns timeout error when request exceeds timeout", async () => {
80
- vi.useFakeTimers();
81
- try {
142
+ await withFakeTimers(async () => {
82
143
  const requestFn = vi.fn().mockImplementation(() => new Promise(() => {}));
83
144
  createFeishuClientMock.mockReturnValue({ request: requestFn });
84
145
 
85
- const promise = probeFeishu({ appId: "cli_123", appSecret: "secret" }, { timeoutMs: 1_000 });
146
+ const promise = probeFeishu(DEFAULT_CREDS, { timeoutMs: 1_000 });
86
147
  await vi.advanceTimersByTimeAsync(1_000);
87
148
  const result = await promise;
88
149
 
89
150
  expect(result).toMatchObject({ ok: false, error: "probe timed out after 1000ms" });
90
- } finally {
91
- vi.useRealTimers();
92
- }
151
+ });
93
152
  });
94
153
 
95
154
  it("returns aborted when abort signal is already aborted", async () => {
@@ -106,14 +165,9 @@ describe("probeFeishu", () => {
106
165
  expect(createFeishuClientMock).not.toHaveBeenCalled();
107
166
  });
108
167
  it("returns cached result on subsequent calls within TTL", async () => {
109
- const requestFn = setupClient({
110
- code: 0,
111
- bot: { bot_name: "TestBot", open_id: "ou_abc123" },
112
- });
168
+ const requestFn = setupSuccessClient();
113
169
 
114
- const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
115
- const first = await probeFeishu(creds);
116
- const second = await probeFeishu(creds);
170
+ const { first, second } = await readSequentialDefaultProbePair();
117
171
 
118
172
  expect(first).toEqual(second);
119
173
  // Only one API call should have been made
@@ -121,76 +175,37 @@ describe("probeFeishu", () => {
121
175
  });
122
176
 
123
177
  it("makes a fresh API call after cache expires", async () => {
124
- vi.useFakeTimers();
125
- try {
126
- const requestFn = setupClient({
127
- code: 0,
128
- bot: { bot_name: "TestBot", open_id: "ou_abc123" },
129
- });
130
-
131
- const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
132
- await probeFeishu(creds);
133
- expect(requestFn).toHaveBeenCalledTimes(1);
134
-
135
- // Advance time past the success TTL
136
- vi.advanceTimersByTime(10 * 60 * 1000 + 1);
178
+ await withFakeTimers(async () => {
179
+ const requestFn = setupSuccessClient();
137
180
 
138
- await probeFeishu(creds);
139
- expect(requestFn).toHaveBeenCalledTimes(2);
140
- } finally {
141
- vi.useRealTimers();
142
- }
181
+ await expectFreshDefaultProbeAfter(requestFn, () => {
182
+ vi.advanceTimersByTime(10 * 60 * 1000 + 1);
183
+ });
184
+ });
143
185
  });
144
186
 
145
187
  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 });
150
-
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);
157
-
158
- vi.advanceTimersByTime(60 * 1000 + 1);
159
-
160
- await probeFeishu(creds);
161
- expect(requestFn).toHaveBeenCalledTimes(2);
162
- } finally {
163
- vi.useRealTimers();
164
- }
188
+ await withFakeTimers(async () => {
189
+ await expectErrorResultCached({
190
+ requestFn: makeRequestFn({ code: 99, msg: "token expired" }),
191
+ expectedError: "API error: token expired",
192
+ ttlMs: 60 * 1000,
193
+ });
194
+ });
165
195
  });
166
196
 
167
197
  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 });
172
-
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);
179
-
180
- vi.advanceTimersByTime(60 * 1000 + 1);
181
-
182
- await probeFeishu(creds);
183
- expect(requestFn).toHaveBeenCalledTimes(2);
184
- } finally {
185
- vi.useRealTimers();
186
- }
198
+ await withFakeTimers(async () => {
199
+ await expectErrorResultCached({
200
+ requestFn: vi.fn().mockRejectedValue(new Error("network error")),
201
+ expectedError: "network error",
202
+ ttlMs: 60 * 1000,
203
+ });
204
+ });
187
205
  });
188
206
 
189
207
  it("caches per account independently", async () => {
190
- const requestFn = setupClient({
191
- code: 0,
192
- bot: { bot_name: "Bot1", open_id: "ou_1" },
193
- });
208
+ const requestFn = setupClient(BOT1_RESPONSE);
194
209
 
195
210
  await probeFeishu({ appId: "cli_aaa", appSecret: "s1" }); // pragma: allowlist secret
196
211
  expect(requestFn).toHaveBeenCalledTimes(1);
@@ -205,10 +220,7 @@ describe("probeFeishu", () => {
205
220
  });
206
221
 
207
222
  it("does not share cache between accounts with same appId but different appSecret", async () => {
208
- const requestFn = setupClient({
209
- code: 0,
210
- bot: { bot_name: "Bot1", open_id: "ou_1" },
211
- });
223
+ const requestFn = setupClient(BOT1_RESPONSE);
212
224
 
213
225
  // First account with appId + secret A
214
226
  await probeFeishu({ appId: "cli_shared", appSecret: "secret_aaa" }); // pragma: allowlist secret
@@ -221,10 +233,7 @@ describe("probeFeishu", () => {
221
233
  });
222
234
 
223
235
  it("uses accountId for cache key when available", async () => {
224
- const requestFn = setupClient({
225
- code: 0,
226
- bot: { bot_name: "Bot1", open_id: "ou_1" },
227
- });
236
+ const requestFn = setupClient(BOT1_RESPONSE);
228
237
 
229
238
  // Two accounts with same appId+appSecret but different accountIds are cached separately
230
239
  await probeFeishu({ accountId: "acct-1", appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
@@ -239,19 +248,11 @@ describe("probeFeishu", () => {
239
248
  });
240
249
 
241
250
  it("clearProbeCache forces fresh API call", async () => {
242
- const requestFn = setupClient({
243
- code: 0,
244
- bot: { bot_name: "TestBot", open_id: "ou_abc123" },
245
- });
246
-
247
- const creds = { appId: "cli_123", appSecret: "secret" }; // pragma: allowlist secret
248
- await probeFeishu(creds);
249
- expect(requestFn).toHaveBeenCalledTimes(1);
251
+ const requestFn = setupSuccessClient();
250
252
 
251
- clearProbeCache();
252
-
253
- await probeFeishu(creds);
254
- expect(requestFn).toHaveBeenCalledTimes(2);
253
+ await expectFreshDefaultProbeAfter(requestFn, () => {
254
+ clearProbeCache();
255
+ });
255
256
  });
256
257
 
257
258
  it("handles response.data.bot fallback path", async () => {
@@ -260,10 +261,8 @@ describe("probeFeishu", () => {
260
261
  data: { bot: { bot_name: "DataBot", open_id: "ou_data" } },
261
262
  });
262
263
 
263
- const result = await probeFeishu({ appId: "cli_123", appSecret: "secret" }); // pragma: allowlist secret
264
- expect(result).toEqual({
265
- ok: true,
266
- appId: "cli_123",
264
+ await expectDefaultSuccessResult(DEFAULT_CREDS, {
265
+ ...DEFAULT_SUCCESS_RESULT,
267
266
  botName: "DataBot",
268
267
  botOpenId: "ou_data",
269
268
  });
package/src/reactions.ts CHANGED
@@ -9,6 +9,20 @@ export type FeishuReaction = {
9
9
  operatorId: string;
10
10
  };
11
11
 
12
+ function resolveConfiguredFeishuClient(params: { cfg: ClawdbotConfig; accountId?: string }) {
13
+ const account = resolveFeishuAccount(params);
14
+ if (!account.configured) {
15
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
16
+ }
17
+ return createFeishuClient(account);
18
+ }
19
+
20
+ function assertFeishuReactionApiSuccess(response: { code?: number; msg?: string }, action: string) {
21
+ if (response.code !== 0) {
22
+ throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`);
23
+ }
24
+ }
25
+
12
26
  /**
13
27
  * Add a reaction (emoji) to a message.
14
28
  * @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART"
@@ -21,12 +35,7 @@ export async function addReactionFeishu(params: {
21
35
  accountId?: string;
22
36
  }): Promise<{ reactionId: string }> {
23
37
  const { cfg, messageId, emojiType, accountId } = params;
24
- const account = resolveFeishuAccount({ cfg, accountId });
25
- if (!account.configured) {
26
- throw new Error(`Feishu account "${account.accountId}" not configured`);
27
- }
28
-
29
- const client = createFeishuClient(account);
38
+ const client = resolveConfiguredFeishuClient({ cfg, accountId });
30
39
 
31
40
  const response = (await client.im.messageReaction.create({
32
41
  path: { message_id: messageId },
@@ -41,9 +50,7 @@ export async function addReactionFeishu(params: {
41
50
  data?: { reaction_id?: string };
42
51
  };
43
52
 
44
- if (response.code !== 0) {
45
- throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
46
- }
53
+ assertFeishuReactionApiSuccess(response, "add reaction");
47
54
 
48
55
  const reactionId = response.data?.reaction_id;
49
56
  if (!reactionId) {
@@ -63,12 +70,7 @@ export async function removeReactionFeishu(params: {
63
70
  accountId?: string;
64
71
  }): Promise<void> {
65
72
  const { cfg, messageId, reactionId, accountId } = params;
66
- const account = resolveFeishuAccount({ cfg, accountId });
67
- if (!account.configured) {
68
- throw new Error(`Feishu account "${account.accountId}" not configured`);
69
- }
70
-
71
- const client = createFeishuClient(account);
73
+ const client = resolveConfiguredFeishuClient({ cfg, accountId });
72
74
 
73
75
  const response = (await client.im.messageReaction.delete({
74
76
  path: {
@@ -77,9 +79,7 @@ export async function removeReactionFeishu(params: {
77
79
  },
78
80
  })) as { code?: number; msg?: string };
79
81
 
80
- if (response.code !== 0) {
81
- throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
82
- }
82
+ assertFeishuReactionApiSuccess(response, "remove reaction");
83
83
  }
84
84
 
85
85
  /**
@@ -92,12 +92,7 @@ export async function listReactionsFeishu(params: {
92
92
  accountId?: string;
93
93
  }): Promise<FeishuReaction[]> {
94
94
  const { cfg, messageId, emojiType, accountId } = params;
95
- const account = resolveFeishuAccount({ cfg, accountId });
96
- if (!account.configured) {
97
- throw new Error(`Feishu account "${account.accountId}" not configured`);
98
- }
99
-
100
- const client = createFeishuClient(account);
95
+ const client = resolveConfiguredFeishuClient({ cfg, accountId });
101
96
 
102
97
  const response = (await client.im.messageReaction.list({
103
98
  path: { message_id: messageId },
@@ -115,9 +110,7 @@ export async function listReactionsFeishu(params: {
115
110
  };
116
111
  };
117
112
 
118
- if (response.code !== 0) {
119
- throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
120
- }
113
+ assertFeishuReactionApiSuccess(response, "list reactions");
121
114
 
122
115
  const items = response.data?.items ?? [];
123
116
  return items.map((item) => ({