@openclaw/feishu 2026.3.1 → 2026.3.2

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/docx.test.ts CHANGED
@@ -114,6 +114,29 @@ describe("feishu_doc image fetch hardening", () => {
114
114
  scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
115
115
  });
116
116
 
117
+ function resolveFeishuDocTool(context: Record<string, unknown> = {}) {
118
+ const registerTool = vi.fn();
119
+ registerFeishuDocTools({
120
+ config: {
121
+ channels: {
122
+ feishu: {
123
+ appId: "app_id",
124
+ appSecret: "app_secret",
125
+ },
126
+ },
127
+ } as any,
128
+ logger: { debug: vi.fn(), info: vi.fn() } as any,
129
+ registerTool,
130
+ } as any);
131
+
132
+ const tool = registerTool.mock.calls
133
+ .map((call) => call[0])
134
+ .map((candidate) => (typeof candidate === "function" ? candidate(context) : candidate))
135
+ .find((candidate) => candidate.name === "feishu_doc");
136
+ expect(tool).toBeDefined();
137
+ return tool as { execute: (callId: string, params: Record<string, unknown>) => Promise<any> };
138
+ }
139
+
117
140
  it("inserts blocks sequentially to preserve document order", async () => {
118
141
  const blocks = [
119
142
  { block_type: 3, block_id: "h1" },
@@ -135,22 +158,7 @@ describe("feishu_doc image fetch hardening", () => {
135
158
  data: { children: [{ block_type: 3, block_id: "h1" }] },
136
159
  });
137
160
 
138
- const registerTool = vi.fn();
139
- registerFeishuDocTools({
140
- config: {
141
- channels: {
142
- feishu: { appId: "app_id", appSecret: "app_secret" },
143
- },
144
- } as any,
145
- logger: { debug: vi.fn(), info: vi.fn() } as any,
146
- registerTool,
147
- } as any);
148
-
149
- const feishuDocTool = registerTool.mock.calls
150
- .map((call) => call[0])
151
- .map((tool) => (typeof tool === "function" ? tool({}) : tool))
152
- .find((tool) => tool.name === "feishu_doc");
153
- expect(feishuDocTool).toBeDefined();
161
+ const feishuDocTool = resolveFeishuDocTool();
154
162
 
155
163
  const result = await feishuDocTool.execute("tool-call", {
156
164
  action: "append",
@@ -194,22 +202,7 @@ describe("feishu_doc image fetch hardening", () => {
194
202
  },
195
203
  }));
196
204
 
197
- const registerTool = vi.fn();
198
- registerFeishuDocTools({
199
- config: {
200
- channels: {
201
- feishu: { appId: "app_id", appSecret: "app_secret" },
202
- },
203
- } as any,
204
- logger: { debug: vi.fn(), info: vi.fn() } as any,
205
- registerTool,
206
- } as any);
207
-
208
- const feishuDocTool = registerTool.mock.calls
209
- .map((call) => call[0])
210
- .map((tool) => (typeof tool === "function" ? tool({}) : tool))
211
- .find((tool) => tool.name === "feishu_doc");
212
- expect(feishuDocTool).toBeDefined();
205
+ const feishuDocTool = resolveFeishuDocTool();
213
206
 
214
207
  const longMarkdown = Array.from(
215
208
  { length: 120 },
@@ -254,22 +247,7 @@ describe("feishu_doc image fetch hardening", () => {
254
247
  data: { children: data.children },
255
248
  }));
256
249
 
257
- const registerTool = vi.fn();
258
- registerFeishuDocTools({
259
- config: {
260
- channels: {
261
- feishu: { appId: "app_id", appSecret: "app_secret" },
262
- },
263
- } as any,
264
- logger: { debug: vi.fn(), info: vi.fn() } as any,
265
- registerTool,
266
- } as any);
267
-
268
- const feishuDocTool = registerTool.mock.calls
269
- .map((call) => call[0])
270
- .map((tool) => (typeof tool === "function" ? tool({}) : tool))
271
- .find((tool) => tool.name === "feishu_doc");
272
- expect(feishuDocTool).toBeDefined();
250
+ const feishuDocTool = resolveFeishuDocTool();
273
251
 
274
252
  const fencedMarkdown = [
275
253
  "## Section",
@@ -306,25 +284,7 @@ describe("feishu_doc image fetch hardening", () => {
306
284
  new Error("Blocked: resolves to private/internal IP address"),
307
285
  );
308
286
 
309
- const registerTool = vi.fn();
310
- registerFeishuDocTools({
311
- config: {
312
- channels: {
313
- feishu: {
314
- appId: "app_id",
315
- appSecret: "app_secret",
316
- },
317
- },
318
- } as any,
319
- logger: { debug: vi.fn(), info: vi.fn() } as any,
320
- registerTool,
321
- } as any);
322
-
323
- const feishuDocTool = registerTool.mock.calls
324
- .map((call) => call[0])
325
- .map((tool) => (typeof tool === "function" ? tool({}) : tool))
326
- .find((tool) => tool.name === "feishu_doc");
327
- expect(feishuDocTool).toBeDefined();
287
+ const feishuDocTool = resolveFeishuDocTool();
328
288
 
329
289
  const result = await feishuDocTool.execute("tool-call", {
330
290
  action: "write",
@@ -341,29 +301,10 @@ describe("feishu_doc image fetch hardening", () => {
341
301
  });
342
302
 
343
303
  it("create grants permission only to trusted Feishu requester", async () => {
344
- const registerTool = vi.fn();
345
- registerFeishuDocTools({
346
- config: {
347
- channels: {
348
- feishu: {
349
- appId: "app_id",
350
- appSecret: "app_secret",
351
- },
352
- },
353
- } as any,
354
- logger: { debug: vi.fn(), info: vi.fn() } as any,
355
- registerTool,
356
- } as any);
357
-
358
- const feishuDocTool = registerTool.mock.calls
359
- .map((call) => call[0])
360
- .map((tool) =>
361
- typeof tool === "function"
362
- ? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" })
363
- : tool,
364
- )
365
- .find((tool) => tool.name === "feishu_doc");
366
- expect(feishuDocTool).toBeDefined();
304
+ const feishuDocTool = resolveFeishuDocTool({
305
+ messageChannel: "feishu",
306
+ requesterSenderId: "ou_123",
307
+ });
367
308
 
368
309
  const result = await feishuDocTool.execute("tool-call", {
369
310
  action: "create",
@@ -386,25 +327,9 @@ describe("feishu_doc image fetch hardening", () => {
386
327
  });
387
328
 
388
329
  it("create skips requester grant when trusted requester identity is unavailable", async () => {
389
- const registerTool = vi.fn();
390
- registerFeishuDocTools({
391
- config: {
392
- channels: {
393
- feishu: {
394
- appId: "app_id",
395
- appSecret: "app_secret",
396
- },
397
- },
398
- } as any,
399
- logger: { debug: vi.fn(), info: vi.fn() } as any,
400
- registerTool,
401
- } as any);
402
-
403
- const feishuDocTool = registerTool.mock.calls
404
- .map((call) => call[0])
405
- .map((tool) => (typeof tool === "function" ? tool({ messageChannel: "feishu" }) : tool))
406
- .find((tool) => tool.name === "feishu_doc");
407
- expect(feishuDocTool).toBeDefined();
330
+ const feishuDocTool = resolveFeishuDocTool({
331
+ messageChannel: "feishu",
332
+ });
408
333
 
409
334
  const result = await feishuDocTool.execute("tool-call", {
410
335
  action: "create",
@@ -417,29 +342,10 @@ describe("feishu_doc image fetch hardening", () => {
417
342
  });
418
343
 
419
344
  it("create never grants permissions when grant_to_requester is false", async () => {
420
- const registerTool = vi.fn();
421
- registerFeishuDocTools({
422
- config: {
423
- channels: {
424
- feishu: {
425
- appId: "app_id",
426
- appSecret: "app_secret",
427
- },
428
- },
429
- } as any,
430
- logger: { debug: vi.fn(), info: vi.fn() } as any,
431
- registerTool,
432
- } as any);
433
-
434
- const feishuDocTool = registerTool.mock.calls
435
- .map((call) => call[0])
436
- .map((tool) =>
437
- typeof tool === "function"
438
- ? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" })
439
- : tool,
440
- )
441
- .find((tool) => tool.name === "feishu_doc");
442
- expect(feishuDocTool).toBeDefined();
345
+ const feishuDocTool = resolveFeishuDocTool({
346
+ messageChannel: "feishu",
347
+ requesterSenderId: "ou_123",
348
+ });
443
349
 
444
350
  const result = await feishuDocTool.execute("tool-call", {
445
351
  action: "create",
@@ -457,25 +363,7 @@ describe("feishu_doc image fetch hardening", () => {
457
363
  data: { document: { title: "Created Doc" } },
458
364
  });
459
365
 
460
- const registerTool = vi.fn();
461
- registerFeishuDocTools({
462
- config: {
463
- channels: {
464
- feishu: {
465
- appId: "app_id",
466
- appSecret: "app_secret",
467
- },
468
- },
469
- } as any,
470
- logger: { debug: vi.fn(), info: vi.fn() } as any,
471
- registerTool,
472
- } as any);
473
-
474
- const feishuDocTool = registerTool.mock.calls
475
- .map((call) => call[0])
476
- .map((tool) => (typeof tool === "function" ? tool({}) : tool))
477
- .find((tool) => tool.name === "feishu_doc");
478
- expect(feishuDocTool).toBeDefined();
366
+ const feishuDocTool = resolveFeishuDocTool();
479
367
 
480
368
  const result = await feishuDocTool.execute("tool-call", {
481
369
  action: "create",
@@ -496,25 +384,7 @@ describe("feishu_doc image fetch hardening", () => {
496
384
  const localPath = join(tmpdir(), `feishu-docx-upload-${Date.now()}.txt`);
497
385
  await fs.writeFile(localPath, "hello from local file", "utf8");
498
386
 
499
- const registerTool = vi.fn();
500
- registerFeishuDocTools({
501
- config: {
502
- channels: {
503
- feishu: {
504
- appId: "app_id",
505
- appSecret: "app_secret",
506
- },
507
- },
508
- } as any,
509
- logger: { debug: vi.fn(), info: vi.fn() } as any,
510
- registerTool,
511
- } as any);
512
-
513
- const feishuDocTool = registerTool.mock.calls
514
- .map((call) => call[0])
515
- .map((tool) => (typeof tool === "function" ? tool({}) : tool))
516
- .find((tool) => tool.name === "feishu_doc");
517
- expect(feishuDocTool).toBeDefined();
387
+ const feishuDocTool = resolveFeishuDocTool();
518
388
 
519
389
  const result = await feishuDocTool.execute("tool-call", {
520
390
  action: "upload_file",
@@ -557,25 +427,7 @@ describe("feishu_doc image fetch hardening", () => {
557
427
  await fs.writeFile(localPath, "hello from local file", "utf8");
558
428
 
559
429
  try {
560
- const registerTool = vi.fn();
561
- registerFeishuDocTools({
562
- config: {
563
- channels: {
564
- feishu: {
565
- appId: "app_id",
566
- appSecret: "app_secret",
567
- },
568
- },
569
- } as any,
570
- logger: { debug: vi.fn(), info: vi.fn() } as any,
571
- registerTool,
572
- } as any);
573
-
574
- const feishuDocTool = registerTool.mock.calls
575
- .map((call) => call[0])
576
- .map((tool) => (typeof tool === "function" ? tool({}) : tool))
577
- .find((tool) => tool.name === "feishu_doc");
578
- expect(feishuDocTool).toBeDefined();
430
+ const feishuDocTool = resolveFeishuDocTool();
579
431
 
580
432
  const result = await feishuDocTool.execute("tool-call", {
581
433
  action: "upload_file",
package/src/media.test.ts CHANGED
@@ -36,7 +36,12 @@ vi.mock("./runtime.js", () => ({
36
36
  }),
37
37
  }));
38
38
 
39
- import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
39
+ import {
40
+ downloadImageFeishu,
41
+ downloadMessageResourceFeishu,
42
+ sanitizeFileNameForUpload,
43
+ sendMediaFeishu,
44
+ } from "./media.js";
40
45
 
41
46
  function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
42
47
  expect(pathValue).not.toContain(key);
@@ -334,6 +339,104 @@ describe("sendMediaFeishu msg_type routing", () => {
334
339
 
335
340
  expect(messageResourceGetMock).not.toHaveBeenCalled();
336
341
  });
342
+
343
+ it("encodes Chinese filenames for file uploads", async () => {
344
+ await sendMediaFeishu({
345
+ cfg: {} as any,
346
+ to: "user:ou_target",
347
+ mediaBuffer: Buffer.from("doc"),
348
+ fileName: "测试文档.pdf",
349
+ });
350
+
351
+ const createCall = fileCreateMock.mock.calls[0][0];
352
+ expect(createCall.data.file_name).not.toBe("测试文档.pdf");
353
+ expect(createCall.data.file_name).toBe(encodeURIComponent("测试文档") + ".pdf");
354
+ });
355
+
356
+ it("preserves ASCII filenames unchanged for file uploads", async () => {
357
+ await sendMediaFeishu({
358
+ cfg: {} as any,
359
+ to: "user:ou_target",
360
+ mediaBuffer: Buffer.from("doc"),
361
+ fileName: "report-2026.pdf",
362
+ });
363
+
364
+ const createCall = fileCreateMock.mock.calls[0][0];
365
+ expect(createCall.data.file_name).toBe("report-2026.pdf");
366
+ });
367
+
368
+ it("encodes special characters (em-dash, full-width brackets) in filenames", async () => {
369
+ await sendMediaFeishu({
370
+ cfg: {} as any,
371
+ to: "user:ou_target",
372
+ mediaBuffer: Buffer.from("doc"),
373
+ fileName: "报告—详情(2026).md",
374
+ });
375
+
376
+ const createCall = fileCreateMock.mock.calls[0][0];
377
+ expect(createCall.data.file_name).toMatch(/\.md$/);
378
+ expect(createCall.data.file_name).not.toContain("—");
379
+ expect(createCall.data.file_name).not.toContain("(");
380
+ });
381
+ });
382
+
383
+ describe("sanitizeFileNameForUpload", () => {
384
+ it("returns ASCII filenames unchanged", () => {
385
+ expect(sanitizeFileNameForUpload("report.pdf")).toBe("report.pdf");
386
+ expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt");
387
+ });
388
+
389
+ it("encodes Chinese characters in basename, preserves extension", () => {
390
+ const result = sanitizeFileNameForUpload("测试文件.md");
391
+ expect(result).toBe(encodeURIComponent("测试文件") + ".md");
392
+ expect(result).toMatch(/\.md$/);
393
+ });
394
+
395
+ it("encodes em-dash and full-width brackets", () => {
396
+ const result = sanitizeFileNameForUpload("文件—说明(v2).pdf");
397
+ expect(result).toMatch(/\.pdf$/);
398
+ expect(result).not.toContain("—");
399
+ expect(result).not.toContain("(");
400
+ expect(result).not.toContain(")");
401
+ });
402
+
403
+ it("encodes single quotes and parentheses per RFC 5987", () => {
404
+ const result = sanitizeFileNameForUpload("文件'(test).txt");
405
+ expect(result).toContain("%27");
406
+ expect(result).toContain("%28");
407
+ expect(result).toContain("%29");
408
+ expect(result).toMatch(/\.txt$/);
409
+ });
410
+
411
+ it("handles filenames without extension", () => {
412
+ const result = sanitizeFileNameForUpload("测试文件");
413
+ expect(result).toBe(encodeURIComponent("测试文件"));
414
+ });
415
+
416
+ it("handles mixed ASCII and non-ASCII", () => {
417
+ const result = sanitizeFileNameForUpload("Report_报告_2026.xlsx");
418
+ expect(result).toMatch(/\.xlsx$/);
419
+ expect(result).not.toContain("报告");
420
+ });
421
+
422
+ it("encodes non-ASCII extensions", () => {
423
+ const result = sanitizeFileNameForUpload("报告.文档");
424
+ expect(result).toContain("%E6%96%87%E6%A1%A3");
425
+ expect(result).not.toContain("文档");
426
+ });
427
+
428
+ it("encodes emoji filenames", () => {
429
+ const result = sanitizeFileNameForUpload("report_😀.txt");
430
+ expect(result).toContain("%F0%9F%98%80");
431
+ expect(result).toMatch(/\.txt$/);
432
+ });
433
+
434
+ it("encodes mixed ASCII and non-ASCII extensions", () => {
435
+ const result = sanitizeFileNameForUpload("notes_总结.v测试");
436
+ expect(result).toContain("notes_");
437
+ expect(result).toContain("%E6%B5%8B%E8%AF%95");
438
+ expect(result).not.toContain("测试");
439
+ });
337
440
  });
338
441
 
339
442
  describe("downloadMessageResourceFeishu", () => {
package/src/media.ts CHANGED
@@ -207,6 +207,24 @@ export async function uploadImageFeishu(params: {
207
207
  return { imageKey };
208
208
  }
209
209
 
210
+ /**
211
+ * Encode a filename for safe use in Feishu multipart/form-data uploads.
212
+ * Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
213
+ * the upload to silently fail when passed raw through the SDK's form-data
214
+ * serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
215
+ * Feishu's server decodes and preserves the original display name.
216
+ */
217
+ export function sanitizeFileNameForUpload(fileName: string): string {
218
+ const ASCII_ONLY = /^[\x20-\x7E]+$/;
219
+ if (ASCII_ONLY.test(fileName)) {
220
+ return fileName;
221
+ }
222
+ return encodeURIComponent(fileName)
223
+ .replace(/'/g, "%27")
224
+ .replace(/\(/g, "%28")
225
+ .replace(/\)/g, "%29");
226
+ }
227
+
210
228
  /**
211
229
  * Upload a file to Feishu and get a file_key for sending.
212
230
  * Max file size: 30MB
@@ -232,10 +250,12 @@ export async function uploadFileFeishu(params: {
232
250
  // See: https://github.com/larksuite/node-sdk/issues/121
233
251
  const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
234
252
 
253
+ const safeFileName = sanitizeFileNameForUpload(fileName);
254
+
235
255
  const response = await client.im.file.create({
236
256
  data: {
237
257
  file_type: fileType,
238
- file_name: fileName,
258
+ file_name: safeFileName,
239
259
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
240
260
  file: fileData as any,
241
261
  ...(duration !== undefined && { duration }),
package/src/mention.ts CHANGED
@@ -53,7 +53,7 @@ export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: s
53
53
  return false;
54
54
  }
55
55
 
56
- const isDirectMessage = event.message.chat_type === "p2p";
56
+ const isDirectMessage = event.message.chat_type !== "group";
57
57
  const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId);
58
58
 
59
59
  if (isDirectMessage) {