@m1heng-clawd/feishu 0.1.8 → 0.1.10

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/drive.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { createFeishuClient } from "./client.js";
3
- import { listEnabledFeishuAccounts } from "./accounts.js";
4
2
  import type * as Lark from "@larksuiteoapi/node-sdk";
5
3
  import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
6
- import { resolveToolsConfig } from "./tools-config.js";
4
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "./tools-common/tool-exec.js";
5
+ import { writeDoc } from "./docx.js";
7
6
 
8
7
  // ============ Helpers ============
9
8
 
@@ -147,6 +146,51 @@ async function deleteFile(client: Lark.Client, fileToken: string, type: string)
147
146
  };
148
147
  }
149
148
 
149
+ // ============ Import Document Functions ============
150
+
151
+ /**
152
+ * Import markdown content as a new Feishu document
153
+ * Uses create + write approach for reliable content import.
154
+ * Note: docType parameter is accepted for API compatibility but docx is always used.
155
+ */
156
+ async function importDocument(
157
+ client: Lark.Client,
158
+ title: string,
159
+ content: string,
160
+ mediaMaxBytes: number,
161
+ folderToken?: string,
162
+ _docType?: "docx" | "doc",
163
+ ) {
164
+ // Step 1: Create empty document
165
+ const createRes = await client.docx.document.create({
166
+ data: { title, folder_token: folderToken },
167
+ });
168
+
169
+ if (createRes.code !== 0) {
170
+ throw new Error(`Failed to create document: ${createRes.msg}`);
171
+ }
172
+
173
+ const docId = createRes.data?.document?.document_id;
174
+ if (!docId) {
175
+ throw new Error("Document created but no document_id returned");
176
+ }
177
+
178
+ // Step 2: Write markdown content to the document
179
+ // This ensures proper structure preservation using the writeDoc function
180
+ const writeResult = await writeDoc(client, docId, content, mediaMaxBytes);
181
+
182
+ return {
183
+ success: true,
184
+ document_id: docId,
185
+ title: title,
186
+ url: `https://feishu.cn/docx/${docId}`,
187
+ import_method: "create_and_write",
188
+ blocks_added: writeResult.blocks_added,
189
+ images_processed: writeResult.images_processed,
190
+ ...("warning" in writeResult && { warning: writeResult.warning }),
191
+ };
192
+ }
193
+
150
194
  // ============ Tool Registration ============
151
195
 
152
196
  export function registerFeishuDriveTools(api: OpenClawPluginApi) {
@@ -155,46 +199,59 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
155
199
  return;
156
200
  }
157
201
 
158
- const accounts = listEnabledFeishuAccounts(api.config);
159
- if (accounts.length === 0) {
202
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
160
203
  api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
161
204
  return;
162
205
  }
163
206
 
164
- const firstAccount = accounts[0];
165
- const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
166
- if (!toolsCfg.drive) {
207
+ if (!hasFeishuToolEnabledForAnyAccount(api.config, "drive")) {
167
208
  api.logger.debug?.("feishu_drive: drive tool disabled in config");
168
209
  return;
169
210
  }
170
211
 
171
- const getClient = () => createFeishuClient(firstAccount);
172
-
173
212
  api.registerTool(
174
213
  {
175
214
  name: "feishu_drive",
176
215
  label: "Feishu Drive",
177
216
  description:
178
- "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
217
+ "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete, import_document. Use 'import_document' to create documents from Markdown with better structure preservation than block-by-block writing.",
179
218
  parameters: FeishuDriveSchema,
180
219
  async execute(_toolCallId, params) {
181
220
  const p = params as FeishuDriveParams;
182
221
  try {
183
- const client = getClient();
184
- switch (p.action) {
185
- case "list":
186
- return json(await listFolder(client, p.folder_token));
187
- case "info":
188
- return json(await getFileInfo(client, p.file_token));
189
- case "create_folder":
190
- return json(await createFolder(client, p.name, p.folder_token));
191
- case "move":
192
- return json(await moveFile(client, p.file_token, p.type, p.folder_token));
193
- case "delete":
194
- return json(await deleteFile(client, p.file_token, p.type));
195
- default:
196
- return json({ error: `Unknown action: ${(p as any).action}` });
197
- }
222
+ return await withFeishuToolClient({
223
+ api,
224
+ toolName: "feishu_drive",
225
+ requiredTool: "drive",
226
+ run: async ({ client, account }) => {
227
+ const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
228
+ switch (p.action) {
229
+ case "list":
230
+ return json(await listFolder(client, p.folder_token));
231
+ case "info":
232
+ return json(await getFileInfo(client, p.file_token));
233
+ case "create_folder":
234
+ return json(await createFolder(client, p.name, p.folder_token));
235
+ case "move":
236
+ return json(await moveFile(client, p.file_token, p.type, p.folder_token));
237
+ case "delete":
238
+ return json(await deleteFile(client, p.file_token, p.type));
239
+ case "import_document":
240
+ return json(
241
+ await importDocument(
242
+ client,
243
+ p.title,
244
+ p.content,
245
+ mediaMaxBytes,
246
+ p.folder_token,
247
+ (p as any).doc_type || "docx",
248
+ ),
249
+ );
250
+ default:
251
+ return json({ error: `Unknown action: ${(p as any).action}` });
252
+ }
253
+ },
254
+ });
198
255
  } catch (err) {
199
256
  return json({ error: err instanceof Error ? err.message : String(err) });
200
257
  }
@@ -203,5 +260,5 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
203
260
  { name: "feishu_drive" },
204
261
  );
205
262
 
206
- api.logger.info?.(`feishu_drive: Registered feishu_drive tool`);
263
+ api.logger.debug?.("feishu_drive: Registered feishu_drive tool");
207
264
  }
@@ -19,16 +19,18 @@ export async function maybeCreateDynamicAgent(params: {
19
19
  runtime: PluginRuntime;
20
20
  senderOpenId: string;
21
21
  dynamicCfg: DynamicAgentCreationConfig;
22
+ accountId?: string;
22
23
  log: (msg: string) => void;
23
24
  }): Promise<MaybeCreateDynamicAgentResult> {
24
- const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
25
+ const { cfg, runtime, senderOpenId, dynamicCfg, accountId, log } = params;
25
26
 
26
27
  // Check if there's already a binding for this user
27
28
  const existingBindings = cfg.bindings ?? [];
28
29
  const hasBinding = existingBindings.some(
29
30
  (b) =>
30
31
  b.match?.channel === "feishu" &&
31
- b.match?.peer?.kind === "dm" &&
32
+ (!accountId || b.match?.accountId === accountId) &&
33
+ b.match?.peer?.kind === "direct" &&
32
34
  b.match?.peer?.id === senderOpenId,
33
35
  );
34
36
 
@@ -66,7 +68,8 @@ export async function maybeCreateDynamicAgent(params: {
66
68
  agentId,
67
69
  match: {
68
70
  channel: "feishu",
69
- peer: { kind: "dm", id: senderOpenId },
71
+ ...(accountId ? { accountId } : {}),
72
+ peer: { kind: "direct", id: senderOpenId },
70
73
  },
71
74
  },
72
75
  ],
@@ -108,7 +111,8 @@ export async function maybeCreateDynamicAgent(params: {
108
111
  agentId,
109
112
  match: {
110
113
  channel: "feishu",
111
- peer: { kind: "dm", id: senderOpenId },
114
+ ...(accountId ? { accountId } : {}),
115
+ peer: { kind: "direct", id: senderOpenId },
112
116
  },
113
117
  },
114
118
  ],
package/src/media.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { createFeishuClient } from "./client.js";
3
3
  import { resolveFeishuAccount } from "./accounts.js";
4
+ import { getFeishuRuntime } from "./runtime.js";
4
5
  import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
5
6
  import fs from "fs";
6
7
  import path from "path";
@@ -353,10 +354,12 @@ export async function sendFileFeishu(params: {
353
354
  cfg: ClawdbotConfig;
354
355
  to: string;
355
356
  fileKey: string;
357
+ msgType?: "file" | "media";
356
358
  replyToMessageId?: string;
357
359
  accountId?: string;
358
360
  }): Promise<SendMediaResult> {
359
361
  const { cfg, to, fileKey, replyToMessageId, accountId } = params;
362
+ const msgType = params.msgType ?? "file";
360
363
  const account = resolveFeishuAccount({ cfg, accountId });
361
364
  if (!account.configured) {
362
365
  throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -376,7 +379,7 @@ export async function sendFileFeishu(params: {
376
379
  path: { message_id: replyToMessageId },
377
380
  data: {
378
381
  content,
379
- msg_type: "file",
382
+ msg_type: msgType,
380
383
  },
381
384
  });
382
385
 
@@ -395,7 +398,7 @@ export async function sendFileFeishu(params: {
395
398
  data: {
396
399
  receive_id: receiveId,
397
400
  content,
398
- msg_type: "file",
401
+ msg_type: msgType,
399
402
  },
400
403
  });
401
404
 
@@ -440,23 +443,6 @@ export function detectFileType(
440
443
  }
441
444
  }
442
445
 
443
- /**
444
- * Check if a string is a local file path (not a URL)
445
- */
446
- function isLocalPath(urlOrPath: string): boolean {
447
- // Starts with / or ~ or drive letter (Windows)
448
- if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
449
- return true;
450
- }
451
- // Try to parse as URL - if it fails or has no protocol, it's likely a local path
452
- try {
453
- const url = new URL(urlOrPath);
454
- return url.protocol === "file:";
455
- } catch {
456
- return true; // Not a valid URL, treat as local path
457
- }
458
- }
459
-
460
446
  /**
461
447
  * Upload and send media (image or file) from URL, local path, or buffer
462
448
  */
@@ -470,6 +456,11 @@ export async function sendMediaFeishu(params: {
470
456
  accountId?: string;
471
457
  }): Promise<SendMediaResult> {
472
458
  const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
459
+ const account = resolveFeishuAccount({ cfg, accountId });
460
+ if (!account.configured) {
461
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
462
+ }
463
+ const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
473
464
 
474
465
  let buffer: Buffer;
475
466
  let name: string;
@@ -478,26 +469,12 @@ export async function sendMediaFeishu(params: {
478
469
  buffer = mediaBuffer;
479
470
  name = fileName ?? "file";
480
471
  } else if (mediaUrl) {
481
- if (isLocalPath(mediaUrl)) {
482
- // Local file path - read directly
483
- const filePath = mediaUrl.startsWith("~")
484
- ? mediaUrl.replace("~", process.env.HOME ?? "")
485
- : mediaUrl.replace("file://", "");
486
-
487
- if (!fs.existsSync(filePath)) {
488
- throw new Error(`Local file not found: ${filePath}`);
489
- }
490
- buffer = fs.readFileSync(filePath);
491
- name = fileName ?? path.basename(filePath);
492
- } else {
493
- // Remote URL - fetch
494
- const response = await fetch(mediaUrl);
495
- if (!response.ok) {
496
- throw new Error(`Failed to fetch media from URL: ${response.status}`);
497
- }
498
- buffer = Buffer.from(await response.arrayBuffer());
499
- name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
500
- }
472
+ const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
473
+ maxBytes: mediaMaxBytes,
474
+ optimizeImages: false,
475
+ });
476
+ buffer = loaded.buffer;
477
+ name = fileName ?? loaded.fileName ?? "file";
501
478
  } else {
502
479
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
503
480
  }
@@ -518,6 +495,14 @@ export async function sendMediaFeishu(params: {
518
495
  fileType,
519
496
  accountId,
520
497
  });
521
- return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId });
498
+ const isMedia = fileType === "mp4" || fileType === "opus";
499
+ return sendFileFeishu({
500
+ cfg,
501
+ to,
502
+ fileKey,
503
+ msgType: isMedia ? "media" : "file",
504
+ replyToMessageId,
505
+ accountId,
506
+ });
522
507
  }
523
508
  }
package/src/monitor.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import * as http from "http";
3
- import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
3
+ import {
4
+ type ClawdbotConfig,
5
+ type RuntimeEnv,
6
+ type HistoryEntry,
7
+ installRequestBodyLimitGuard,
8
+ } from "openclaw/plugin-sdk";
4
9
  import type { ResolvedFeishuAccount } from "./types.js";
5
10
  import { createFeishuWSClient, createEventDispatcher } from "./client.js";
6
11
  import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
@@ -18,6 +23,8 @@ export type MonitorFeishuOpts = {
18
23
  const wsClients = new Map<string, Lark.WSClient>();
19
24
  const httpServers = new Map<string, http.Server>();
20
25
  const botOpenIds = new Map<string, string>();
26
+ const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
27
+ const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
21
28
 
22
29
  async function fetchBotOpenId(
23
30
  account: ResolvedFeishuAccount,
@@ -191,7 +198,27 @@ async function monitorWebhook({ params, accountId, eventDispatcher }: Connection
191
198
  log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
192
199
 
193
200
  const server = http.createServer();
194
- server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
201
+ const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
202
+ server.on("request", (req, res) => {
203
+ const guard = installRequestBodyLimitGuard(req, res, {
204
+ maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
205
+ timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
206
+ responseFormat: "text",
207
+ });
208
+ if (guard.isTripped()) {
209
+ return;
210
+ }
211
+
212
+ void Promise.resolve(webhookHandler(req, res))
213
+ .catch((err) => {
214
+ if (!guard.isTripped()) {
215
+ error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
216
+ }
217
+ })
218
+ .finally(() => {
219
+ guard.dispose();
220
+ });
221
+ });
195
222
  httpServers.set(accountId, server);
196
223
 
197
224
  return new Promise((resolve, reject) => {