@openclaw/feishu 2026.2.13 → 2026.2.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.13",
3
+ "version": "2026.2.15",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -8,6 +8,9 @@
8
8
  "@sinclair/typebox": "0.34.48",
9
9
  "zod": "^4.3.6"
10
10
  },
11
+ "devDependencies": {
12
+ "openclaw": "workspace:*"
13
+ },
11
14
  "openclaw": {
12
15
  "extensions": [
13
16
  "./index.ts"
package/src/accounts.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
3
  import type {
4
4
  FeishuConfig,
5
5
  FeishuAccountConfig,
package/src/bot.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
2
  import {
3
+ buildAgentMediaPayload,
3
4
  buildPendingHistoryContextFromMap,
4
5
  recordPendingHistoryEntryIfEnabled,
5
6
  clearHistoryEntriesIfEnabled,
@@ -433,27 +434,6 @@ async function resolveFeishuMediaList(params: {
433
434
  * Build media payload for inbound context.
434
435
  * Similar to Discord's buildDiscordMediaPayload().
435
436
  */
436
- function buildFeishuMediaPayload(mediaList: FeishuMediaInfo[]): {
437
- MediaPath?: string;
438
- MediaType?: string;
439
- MediaUrl?: string;
440
- MediaPaths?: string[];
441
- MediaUrls?: string[];
442
- MediaTypes?: string[];
443
- } {
444
- const first = mediaList[0];
445
- const mediaPaths = mediaList.map((media) => media.path);
446
- const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
447
- return {
448
- MediaPath: first?.path,
449
- MediaType: first?.contentType,
450
- MediaUrl: first?.path,
451
- MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
452
- MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
453
- MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
454
- };
455
- }
456
-
457
437
  export function parseFeishuMessageEvent(
458
438
  event: FeishuMessageEvent,
459
439
  botOpenId?: string,
@@ -766,7 +746,7 @@ export async function handleFeishuMessage(params: {
766
746
  log,
767
747
  accountId: account.accountId,
768
748
  });
769
- const mediaPayload = buildFeishuMediaPayload(mediaList);
749
+ const mediaPayload = buildAgentMediaPayload(mediaList);
770
750
 
771
751
  // Fetch quoted/replied message content if parentId exists
772
752
  let quotedContent: string | undefined;
package/src/channel.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
2
+ import {
3
+ buildBaseChannelStatusSummary,
4
+ createDefaultChannelRuntimeState,
5
+ DEFAULT_ACCOUNT_ID,
6
+ PAIRING_APPROVED_MESSAGE,
7
+ } from "openclaw/plugin-sdk";
3
8
  import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
4
9
  import {
5
10
  resolveFeishuAccount,
@@ -303,20 +308,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
303
308
  },
304
309
  outbound: feishuOutbound,
305
310
  status: {
306
- defaultRuntime: {
307
- accountId: DEFAULT_ACCOUNT_ID,
308
- running: false,
309
- lastStartAt: null,
310
- lastStopAt: null,
311
- lastError: null,
312
- port: null,
313
- },
311
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
314
312
  buildChannelSummary: ({ snapshot }) => ({
315
- configured: snapshot.configured ?? false,
316
- running: snapshot.running ?? false,
317
- lastStartAt: snapshot.lastStartAt ?? null,
318
- lastStopAt: snapshot.lastStopAt ?? null,
319
- lastError: snapshot.lastError ?? null,
313
+ ...buildBaseChannelStatusSummary(snapshot),
320
314
  port: snapshot.port ?? null,
321
315
  probe: snapshot.probe,
322
316
  lastProbeAt: snapshot.lastProbeAt ?? null,
@@ -0,0 +1,123 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
4
+ const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock("./client.js", () => ({
7
+ createFeishuClient: createFeishuClientMock,
8
+ }));
9
+
10
+ vi.mock("./runtime.js", () => ({
11
+ getFeishuRuntime: () => ({
12
+ channel: {
13
+ media: {
14
+ fetchRemoteMedia: fetchRemoteMediaMock,
15
+ },
16
+ },
17
+ }),
18
+ }));
19
+
20
+ import { registerFeishuDocTools } from "./docx.js";
21
+
22
+ describe("feishu_doc image fetch hardening", () => {
23
+ const convertMock = vi.hoisted(() => vi.fn());
24
+ const blockListMock = vi.hoisted(() => vi.fn());
25
+ const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
26
+ const driveUploadAllMock = vi.hoisted(() => vi.fn());
27
+ const blockPatchMock = vi.hoisted(() => vi.fn());
28
+ const scopeListMock = vi.hoisted(() => vi.fn());
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks();
32
+
33
+ createFeishuClientMock.mockReturnValue({
34
+ docx: {
35
+ document: {
36
+ convert: convertMock,
37
+ },
38
+ documentBlock: {
39
+ list: blockListMock,
40
+ patch: blockPatchMock,
41
+ },
42
+ documentBlockChildren: {
43
+ create: blockChildrenCreateMock,
44
+ },
45
+ },
46
+ drive: {
47
+ media: {
48
+ uploadAll: driveUploadAllMock,
49
+ },
50
+ },
51
+ application: {
52
+ scope: {
53
+ list: scopeListMock,
54
+ },
55
+ },
56
+ });
57
+
58
+ convertMock.mockResolvedValue({
59
+ code: 0,
60
+ data: {
61
+ blocks: [{ block_type: 27 }],
62
+ first_level_block_ids: [],
63
+ },
64
+ });
65
+
66
+ blockListMock.mockResolvedValue({
67
+ code: 0,
68
+ data: {
69
+ items: [],
70
+ },
71
+ });
72
+
73
+ blockChildrenCreateMock.mockResolvedValue({
74
+ code: 0,
75
+ data: {
76
+ children: [{ block_type: 27, block_id: "img_block_1" }],
77
+ },
78
+ });
79
+
80
+ driveUploadAllMock.mockResolvedValue({ file_token: "token_1" });
81
+ blockPatchMock.mockResolvedValue({ code: 0 });
82
+ scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
83
+ });
84
+
85
+ it("skips image upload when markdown image URL is blocked", async () => {
86
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
87
+ fetchRemoteMediaMock.mockRejectedValueOnce(
88
+ new Error("Blocked: resolves to private/internal IP address"),
89
+ );
90
+
91
+ const registerTool = vi.fn();
92
+ registerFeishuDocTools({
93
+ config: {
94
+ channels: {
95
+ feishu: {
96
+ appId: "app_id",
97
+ appSecret: "app_secret",
98
+ },
99
+ },
100
+ } as any,
101
+ logger: { debug: vi.fn(), info: vi.fn() } as any,
102
+ registerTool,
103
+ } as any);
104
+
105
+ const feishuDocTool = registerTool.mock.calls
106
+ .map((call) => call[0])
107
+ .find((tool) => tool.name === "feishu_doc");
108
+ expect(feishuDocTool).toBeDefined();
109
+
110
+ const result = await feishuDocTool.execute("tool-call", {
111
+ action: "write",
112
+ doc_token: "doc_1",
113
+ content: "![x](https://x.test/image.png)",
114
+ });
115
+
116
+ expect(fetchRemoteMediaMock).toHaveBeenCalled();
117
+ expect(driveUploadAllMock).not.toHaveBeenCalled();
118
+ expect(blockPatchMock).not.toHaveBeenCalled();
119
+ expect(result.details.images_processed).toBe(0);
120
+ expect(consoleErrorSpy).toHaveBeenCalled();
121
+ consoleErrorSpy.mockRestore();
122
+ });
123
+ });
package/src/docx.ts CHANGED
@@ -5,6 +5,7 @@ import { Readable } from "stream";
5
5
  import { listEnabledFeishuAccounts } from "./accounts.js";
6
6
  import { createFeishuClient } from "./client.js";
7
7
  import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
8
+ import { getFeishuRuntime } from "./runtime.js";
8
9
  import { resolveToolsConfig } from "./tools-config.js";
9
10
 
10
11
  // ============ Helpers ============
@@ -175,12 +176,9 @@ async function uploadImageToDocx(
175
176
  return fileToken;
176
177
  }
177
178
 
178
- async function downloadImage(url: string): Promise<Buffer> {
179
- const response = await fetch(url);
180
- if (!response.ok) {
181
- throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
182
- }
183
- return Buffer.from(await response.arrayBuffer());
179
+ async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
180
+ const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
181
+ return fetched.buffer;
184
182
  }
185
183
 
186
184
  /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
@@ -189,6 +187,7 @@ async function processImages(
189
187
  docToken: string,
190
188
  markdown: string,
191
189
  insertedBlocks: any[],
190
+ maxBytes: number,
192
191
  ): Promise<number> {
193
192
  /* eslint-enable @typescript-eslint/no-explicit-any */
194
193
  const imageUrls = extractImageUrls(markdown);
@@ -204,7 +203,7 @@ async function processImages(
204
203
  const blockId = imageBlocks[i].block_id;
205
204
 
206
205
  try {
207
- const buffer = await downloadImage(url);
206
+ const buffer = await downloadImage(url, maxBytes);
208
207
  const urlPath = new URL(url).pathname;
209
208
  const fileName = urlPath.split("/").pop() || `image_${i}.png`;
210
209
  const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
@@ -284,7 +283,7 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin
284
283
  };
285
284
  }
286
285
 
287
- async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
286
+ async function writeDoc(client: Lark.Client, docToken: string, markdown: string, maxBytes: number) {
288
287
  const deleted = await clearDocumentContent(client, docToken);
289
288
 
290
289
  const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
@@ -294,7 +293,7 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
294
293
  const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
295
294
 
296
295
  const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
297
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
296
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
298
297
 
299
298
  return {
300
299
  success: true,
@@ -307,7 +306,12 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
307
306
  };
308
307
  }
309
308
 
310
- async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
309
+ async function appendDoc(
310
+ client: Lark.Client,
311
+ docToken: string,
312
+ markdown: string,
313
+ maxBytes: number,
314
+ ) {
311
315
  const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
312
316
  if (blocks.length === 0) {
313
317
  throw new Error("Content is empty");
@@ -315,7 +319,7 @@ async function appendDoc(client: Lark.Client, docToken: string, markdown: string
315
319
  const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
316
320
 
317
321
  const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
318
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
322
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
319
323
 
320
324
  return {
321
325
  success: true,
@@ -453,6 +457,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
453
457
  // Use first account's config for tools configuration
454
458
  const firstAccount = accounts[0];
455
459
  const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
460
+ const mediaMaxBytes = (firstAccount.config?.mediaMaxMb ?? 30) * 1024 * 1024;
456
461
 
457
462
  // Helper to get client for the default account
458
463
  const getClient = () => createFeishuClient(firstAccount);
@@ -475,9 +480,9 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
475
480
  case "read":
476
481
  return json(await readDoc(client, p.doc_token));
477
482
  case "write":
478
- return json(await writeDoc(client, p.doc_token, p.content));
483
+ return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes));
479
484
  case "append":
480
- return json(await appendDoc(client, p.doc_token, p.content));
485
+ return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes));
481
486
  case "create":
482
487
  return json(await createDoc(client, p.title, p.folder_token));
483
488
  case "list_blocks":
package/src/media.test.ts CHANGED
@@ -4,6 +4,7 @@ const createFeishuClientMock = vi.hoisted(() => vi.fn());
4
4
  const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
5
5
  const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
6
6
  const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
7
+ const loadWebMediaMock = vi.hoisted(() => vi.fn());
7
8
 
8
9
  const fileCreateMock = vi.hoisted(() => vi.fn());
9
10
  const messageCreateMock = vi.hoisted(() => vi.fn());
@@ -22,6 +23,14 @@ vi.mock("./targets.js", () => ({
22
23
  resolveReceiveIdType: resolveReceiveIdTypeMock,
23
24
  }));
24
25
 
26
+ vi.mock("./runtime.js", () => ({
27
+ getFeishuRuntime: () => ({
28
+ media: {
29
+ loadWebMedia: loadWebMediaMock,
30
+ },
31
+ }),
32
+ }));
33
+
25
34
  import { sendMediaFeishu } from "./media.js";
26
35
 
27
36
  describe("sendMediaFeishu msg_type routing", () => {
@@ -31,6 +40,7 @@ describe("sendMediaFeishu msg_type routing", () => {
31
40
  resolveFeishuAccountMock.mockReturnValue({
32
41
  configured: true,
33
42
  accountId: "main",
43
+ config: {},
34
44
  appId: "app_id",
35
45
  appSecret: "app_secret",
36
46
  domain: "feishu",
@@ -65,6 +75,13 @@ describe("sendMediaFeishu msg_type routing", () => {
65
75
  code: 0,
66
76
  data: { message_id: "reply_1" },
67
77
  });
78
+
79
+ loadWebMediaMock.mockResolvedValue({
80
+ buffer: Buffer.from("remote-audio"),
81
+ fileName: "remote.opus",
82
+ kind: "audio",
83
+ contentType: "audio/ogg",
84
+ });
68
85
  });
69
86
 
70
87
  it("uses msg_type=media for mp4", async () => {
@@ -148,4 +165,23 @@ describe("sendMediaFeishu msg_type routing", () => {
148
165
 
149
166
  expect(messageCreateMock).not.toHaveBeenCalled();
150
167
  });
168
+
169
+ it("fails closed when media URL fetch is blocked", async () => {
170
+ loadWebMediaMock.mockRejectedValueOnce(
171
+ new Error("Blocked: resolves to private/internal IP address"),
172
+ );
173
+
174
+ await expect(
175
+ sendMediaFeishu({
176
+ cfg: {} as any,
177
+ to: "user:ou_target",
178
+ mediaUrl: "https://x/img",
179
+ fileName: "voice.opus",
180
+ }),
181
+ ).rejects.toThrow(/private\/internal/i);
182
+
183
+ expect(fileCreateMock).not.toHaveBeenCalled();
184
+ expect(messageCreateMock).not.toHaveBeenCalled();
185
+ expect(messageReplyMock).not.toHaveBeenCalled();
186
+ });
151
187
  });
package/src/media.ts CHANGED
@@ -5,6 +5,7 @@ import path from "path";
5
5
  import { Readable } from "stream";
6
6
  import { resolveFeishuAccount } from "./accounts.js";
7
7
  import { createFeishuClient } from "./client.js";
8
+ import { getFeishuRuntime } from "./runtime.js";
8
9
  import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
9
10
 
10
11
  export type DownloadImageResult = {
@@ -18,81 +19,91 @@ export type DownloadMessageResourceResult = {
18
19
  fileName?: string;
19
20
  };
20
21
 
21
- /**
22
- * Download an image from Feishu using image_key.
23
- * Used for downloading images sent in messages.
24
- */
25
- export async function downloadImageFeishu(params: {
26
- cfg: ClawdbotConfig;
27
- imageKey: string;
28
- accountId?: string;
29
- }): Promise<DownloadImageResult> {
30
- const { cfg, imageKey, accountId } = params;
31
- const account = resolveFeishuAccount({ cfg, accountId });
32
- if (!account.configured) {
33
- throw new Error(`Feishu account "${account.accountId}" not configured`);
34
- }
35
-
36
- const client = createFeishuClient(account);
37
-
38
- const response = await client.im.image.get({
39
- path: { image_key: imageKey },
40
- });
41
-
22
+ async function readFeishuResponseBuffer(params: {
23
+ response: unknown;
24
+ tmpPath: string;
25
+ errorPrefix: string;
26
+ }): Promise<Buffer> {
27
+ const { response } = params;
42
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
43
29
  const responseAny = response as any;
44
30
  if (responseAny.code !== undefined && responseAny.code !== 0) {
45
- throw new Error(
46
- `Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`,
47
- );
31
+ throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
48
32
  }
49
33
 
50
- // Handle various response formats from Feishu SDK
51
- let buffer: Buffer;
52
-
53
34
  if (Buffer.isBuffer(response)) {
54
- buffer = response;
55
- } else if (response instanceof ArrayBuffer) {
56
- buffer = Buffer.from(response);
57
- } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
58
- buffer = responseAny.data;
59
- } else if (responseAny.data instanceof ArrayBuffer) {
60
- buffer = Buffer.from(responseAny.data);
61
- } else if (typeof responseAny.getReadableStream === "function") {
62
- // SDK provides getReadableStream method
35
+ return response;
36
+ }
37
+ if (response instanceof ArrayBuffer) {
38
+ return Buffer.from(response);
39
+ }
40
+ if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
41
+ return responseAny.data;
42
+ }
43
+ if (responseAny.data instanceof ArrayBuffer) {
44
+ return Buffer.from(responseAny.data);
45
+ }
46
+ if (typeof responseAny.getReadableStream === "function") {
63
47
  const stream = responseAny.getReadableStream();
64
48
  const chunks: Buffer[] = [];
65
49
  for await (const chunk of stream) {
66
50
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
67
51
  }
68
- buffer = Buffer.concat(chunks);
69
- } else if (typeof responseAny.writeFile === "function") {
70
- // SDK provides writeFile method - use a temp file
71
- const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);
72
- await responseAny.writeFile(tmpPath);
73
- buffer = await fs.promises.readFile(tmpPath);
74
- await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
75
- } else if (typeof responseAny[Symbol.asyncIterator] === "function") {
76
- // Response is an async iterable
52
+ return Buffer.concat(chunks);
53
+ }
54
+ if (typeof responseAny.writeFile === "function") {
55
+ await responseAny.writeFile(params.tmpPath);
56
+ const buffer = await fs.promises.readFile(params.tmpPath);
57
+ await fs.promises.unlink(params.tmpPath).catch(() => {});
58
+ return buffer;
59
+ }
60
+ if (typeof responseAny[Symbol.asyncIterator] === "function") {
77
61
  const chunks: Buffer[] = [];
78
62
  for await (const chunk of responseAny) {
79
63
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
80
64
  }
81
- buffer = Buffer.concat(chunks);
82
- } else if (typeof responseAny.read === "function") {
83
- // Response is a Readable stream
65
+ return Buffer.concat(chunks);
66
+ }
67
+ if (typeof responseAny.read === "function") {
84
68
  const chunks: Buffer[] = [];
85
69
  for await (const chunk of responseAny as Readable) {
86
70
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
87
71
  }
88
- buffer = Buffer.concat(chunks);
89
- } else {
90
- // Debug: log what we actually received
91
- const keys = Object.keys(responseAny);
92
- const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
93
- throw new Error(`Feishu image download failed: unexpected response format. Keys: [${types}]`);
72
+ return Buffer.concat(chunks);
73
+ }
74
+
75
+ const keys = Object.keys(responseAny);
76
+ const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
77
+ throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${types}]`);
78
+ }
79
+
80
+ /**
81
+ * Download an image from Feishu using image_key.
82
+ * Used for downloading images sent in messages.
83
+ */
84
+ export async function downloadImageFeishu(params: {
85
+ cfg: ClawdbotConfig;
86
+ imageKey: string;
87
+ accountId?: string;
88
+ }): Promise<DownloadImageResult> {
89
+ const { cfg, imageKey, accountId } = params;
90
+ const account = resolveFeishuAccount({ cfg, accountId });
91
+ if (!account.configured) {
92
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
94
93
  }
95
94
 
95
+ const client = createFeishuClient(account);
96
+
97
+ const response = await client.im.image.get({
98
+ path: { image_key: imageKey },
99
+ });
100
+
101
+ const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);
102
+ const buffer = await readFeishuResponseBuffer({
103
+ response,
104
+ tmpPath,
105
+ errorPrefix: "Feishu image download failed",
106
+ });
96
107
  return { buffer };
97
108
  }
98
109
 
@@ -120,62 +131,12 @@ export async function downloadMessageResourceFeishu(params: {
120
131
  params: { type },
121
132
  });
122
133
 
123
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
124
- const responseAny = response as any;
125
- if (responseAny.code !== undefined && responseAny.code !== 0) {
126
- throw new Error(
127
- `Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`,
128
- );
129
- }
130
-
131
- // Handle various response formats from Feishu SDK
132
- let buffer: Buffer;
133
-
134
- if (Buffer.isBuffer(response)) {
135
- buffer = response;
136
- } else if (response instanceof ArrayBuffer) {
137
- buffer = Buffer.from(response);
138
- } else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
139
- buffer = responseAny.data;
140
- } else if (responseAny.data instanceof ArrayBuffer) {
141
- buffer = Buffer.from(responseAny.data);
142
- } else if (typeof responseAny.getReadableStream === "function") {
143
- // SDK provides getReadableStream method
144
- const stream = responseAny.getReadableStream();
145
- const chunks: Buffer[] = [];
146
- for await (const chunk of stream) {
147
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
148
- }
149
- buffer = Buffer.concat(chunks);
150
- } else if (typeof responseAny.writeFile === "function") {
151
- // SDK provides writeFile method - use a temp file
152
- const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);
153
- await responseAny.writeFile(tmpPath);
154
- buffer = await fs.promises.readFile(tmpPath);
155
- await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
156
- } else if (typeof responseAny[Symbol.asyncIterator] === "function") {
157
- // Response is an async iterable
158
- const chunks: Buffer[] = [];
159
- for await (const chunk of responseAny) {
160
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
161
- }
162
- buffer = Buffer.concat(chunks);
163
- } else if (typeof responseAny.read === "function") {
164
- // Response is a Readable stream
165
- const chunks: Buffer[] = [];
166
- for await (const chunk of responseAny as Readable) {
167
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
168
- }
169
- buffer = Buffer.concat(chunks);
170
- } else {
171
- // Debug: log what we actually received
172
- const keys = Object.keys(responseAny);
173
- const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
174
- throw new Error(
175
- `Feishu message resource download failed: unexpected response format. Keys: [${types}]`,
176
- );
177
- }
178
-
134
+ const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);
135
+ const buffer = await readFeishuResponseBuffer({
136
+ response,
137
+ tmpPath,
138
+ errorPrefix: "Feishu message resource download failed",
139
+ });
179
140
  return { buffer };
180
141
  }
181
142
 
@@ -449,23 +410,6 @@ export function detectFileType(
449
410
  }
450
411
  }
451
412
 
452
- /**
453
- * Check if a string is a local file path (not a URL)
454
- */
455
- function isLocalPath(urlOrPath: string): boolean {
456
- // Starts with / or ~ or drive letter (Windows)
457
- if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
458
- return true;
459
- }
460
- // Try to parse as URL - if it fails or has no protocol, it's likely a local path
461
- try {
462
- const url = new URL(urlOrPath);
463
- return url.protocol === "file:";
464
- } catch {
465
- return true; // Not a valid URL, treat as local path
466
- }
467
- }
468
-
469
413
  /**
470
414
  * Upload and send media (image or file) from URL, local path, or buffer
471
415
  */
@@ -479,6 +423,11 @@ export async function sendMediaFeishu(params: {
479
423
  accountId?: string;
480
424
  }): Promise<SendMediaResult> {
481
425
  const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
426
+ const account = resolveFeishuAccount({ cfg, accountId });
427
+ if (!account.configured) {
428
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
429
+ }
430
+ const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
482
431
 
483
432
  let buffer: Buffer;
484
433
  let name: string;
@@ -487,26 +436,12 @@ export async function sendMediaFeishu(params: {
487
436
  buffer = mediaBuffer;
488
437
  name = fileName ?? "file";
489
438
  } else if (mediaUrl) {
490
- if (isLocalPath(mediaUrl)) {
491
- // Local file path - read directly
492
- const filePath = mediaUrl.startsWith("~")
493
- ? mediaUrl.replace("~", process.env.HOME ?? "")
494
- : mediaUrl.replace("file://", "");
495
-
496
- if (!fs.existsSync(filePath)) {
497
- throw new Error(`Local file not found: ${filePath}`);
498
- }
499
- buffer = fs.readFileSync(filePath);
500
- name = fileName ?? path.basename(filePath);
501
- } else {
502
- // Remote URL - fetch
503
- const response = await fetch(mediaUrl);
504
- if (!response.ok) {
505
- throw new Error(`Failed to fetch media from URL: ${response.status}`);
506
- }
507
- buffer = Buffer.from(await response.arrayBuffer());
508
- name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
509
- }
439
+ const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
440
+ maxBytes: mediaMaxBytes,
441
+ optimizeImages: false,
442
+ });
443
+ buffer = loaded.buffer;
444
+ name = fileName ?? loaded.fileName ?? "file";
510
445
  } else {
511
446
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
512
447
  }
package/src/policy.ts CHANGED
@@ -1,39 +1,19 @@
1
- import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
1
+ import type {
2
+ AllowlistMatch,
3
+ ChannelGroupContext,
4
+ GroupToolPolicyConfig,
5
+ } from "openclaw/plugin-sdk";
6
+ import { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk";
2
7
  import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
3
8
 
4
- export type FeishuAllowlistMatch = {
5
- allowed: boolean;
6
- matchKey?: string;
7
- matchSource?: "wildcard" | "id" | "name";
8
- };
9
+ export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">;
9
10
 
10
11
  export function resolveFeishuAllowlistMatch(params: {
11
12
  allowFrom: Array<string | number>;
12
13
  senderId: string;
13
14
  senderName?: string | null;
14
15
  }): FeishuAllowlistMatch {
15
- const allowFrom = params.allowFrom
16
- .map((entry) => String(entry).trim().toLowerCase())
17
- .filter(Boolean);
18
-
19
- if (allowFrom.length === 0) {
20
- return { allowed: false };
21
- }
22
- if (allowFrom.includes("*")) {
23
- return { allowed: true, matchKey: "*", matchSource: "wildcard" };
24
- }
25
-
26
- const senderId = params.senderId.toLowerCase();
27
- if (allowFrom.includes(senderId)) {
28
- return { allowed: true, matchKey: senderId, matchSource: "id" };
29
- }
30
-
31
- const senderName = params.senderName?.toLowerCase();
32
- if (senderName && allowFrom.includes(senderName)) {
33
- return { allowed: true, matchKey: senderName, matchSource: "name" };
34
- }
35
-
36
- return { allowed: false };
16
+ return resolveAllowlistMatchSimple(params);
37
17
  }
38
18
 
39
19
  export function resolveFeishuGroupConfig(params: {
@@ -206,6 +206,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
206
206
  await closeStreaming();
207
207
  typingCallbacks.onIdle?.();
208
208
  },
209
+ onCleanup: () => {
210
+ typingCallbacks.onCleanup?.();
211
+ },
209
212
  });
210
213
 
211
214
  return {
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BaseProbeResult } from "openclaw/plugin-sdk";
1
2
  import type {
2
3
  FeishuConfigSchema,
3
4
  FeishuGroupSchema,
@@ -52,9 +53,7 @@ export type FeishuSendResult = {
52
53
  chatId: string;
53
54
  };
54
55
 
55
- export type FeishuProbeResult = {
56
- ok: boolean;
57
- error?: string;
56
+ export type FeishuProbeResult = BaseProbeResult<string> & {
58
57
  appId?: string;
59
58
  botName?: string;
60
59
  botOpenId?: string;