@openclaw/feishu 2026.2.13 → 2026.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/accounts.ts +1 -1
- package/src/docx.test.ts +123 -0
- package/src/docx.ts +18 -13
- package/src/media.test.ts +36 -0
- package/src/media.ts +82 -147
- package/src/reply-dispatcher.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/feishu",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.14",
|
|
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/docx.test.ts
ADDED
|
@@ -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: "",
|
|
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
|
|
180
|
-
|
|
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(
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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/reply-dispatcher.ts
CHANGED