@tobeyoureyes/feishu 1.1.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tobeyoureyes/feishu",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "OpenClaw Feishu (Lark) channel plugin",
5
5
  "type": "module",
6
6
  "files": [
@@ -8,17 +8,11 @@
8
8
  "src",
9
9
  "openclaw.plugin.json"
10
10
  ],
11
- "devDependencies": {
12
- "@larksuiteoapi/node-sdk": "^1.58.0",
13
- "openclaw": "workspace:*"
14
- },
15
- "peerDependencies": {
11
+ "dependencies": {
16
12
  "@larksuiteoapi/node-sdk": "^1.58.0"
17
13
  },
18
- "peerDependenciesMeta": {
19
- "@larksuiteoapi/node-sdk": {
20
- "optional": true
21
- }
14
+ "devDependencies": {
15
+ "openclaw": "workspace:*"
22
16
  },
23
17
  "openclaw": {
24
18
  "extensions": [
package/src/channel.ts CHANGED
@@ -37,6 +37,11 @@ import { createGroupHistoryManager, type HistoryEntry } from "./history.js";
37
37
  import { buildFeishuMessageContext } from "./context.js";
38
38
  import { dispatchFeishuMessage, createDefaultReplySender, createDefaultMediaSender } from "./dispatch.js";
39
39
 
40
+ // Node.js imports for media handling
41
+ import { writeFile, mkdir } from "node:fs/promises";
42
+ import { tmpdir } from "node:os";
43
+ import { join } from "node:path";
44
+
40
45
  // ============ Helper Functions ============
41
46
 
42
47
  /**
@@ -68,6 +73,66 @@ async function fetchReplyContext(
68
73
  return inbound;
69
74
  }
70
75
 
76
+ /**
77
+ * Save media data to a temporary file
78
+ */
79
+ async function saveTempMedia(
80
+ data: ArrayBuffer,
81
+ key: string,
82
+ fileName?: string,
83
+ defaultExt: string = "bin",
84
+ ): Promise<string> {
85
+ const dir = join(tmpdir(), "openclaw-feishu-media");
86
+ await mkdir(dir, { recursive: true });
87
+ const ext = fileName?.split(".").pop() ?? defaultExt;
88
+ const path = join(dir, `${key}.${ext}`);
89
+ await writeFile(path, Buffer.from(data));
90
+ return path;
91
+ }
92
+
93
+ /**
94
+ * Download media from Feishu message (image/file/audio/video)
95
+ */
96
+ async function downloadMessageMedia(
97
+ account: ResolvedFeishuAccount,
98
+ message: FeishuInboundMessage,
99
+ log?: { warn?: (msg: string) => void },
100
+ ): Promise<FeishuInboundMessage> {
101
+ // Image message
102
+ if (message.imageKey) {
103
+ try {
104
+ const result = await api.downloadImage(account, message.imageKey);
105
+ if (result.ok && result.data) {
106
+ const tempPath = await saveTempMedia(result.data, message.imageKey, undefined, "png");
107
+ return { ...message, mediaPath: tempPath, mediaType: "image/png" };
108
+ }
109
+ log?.warn?.(`failed to download image ${message.imageKey}: ${result.error ?? "unknown error"}`);
110
+ } catch (err) {
111
+ log?.warn?.(`failed to download image ${message.imageKey}: ${String(err)}`);
112
+ }
113
+ }
114
+
115
+ // File/audio/video message
116
+ if (message.fileKey) {
117
+ try {
118
+ const result = await api.downloadFile(account, message.fileKey);
119
+ if (result.ok && result.data) {
120
+ const ext = message.messageType === "audio" ? "ogg" : message.messageType === "media" ? "mp4" : undefined;
121
+ const tempPath = await saveTempMedia(result.data, message.fileKey, message.fileName, ext);
122
+ const mimeType = message.messageType === "audio" ? "audio/ogg"
123
+ : message.messageType === "media" ? "video/mp4"
124
+ : "application/octet-stream";
125
+ return { ...message, mediaPath: tempPath, mediaType: mimeType };
126
+ }
127
+ log?.warn?.(`failed to download file ${message.fileKey}: ${result.error ?? "unknown error"}`);
128
+ } catch (err) {
129
+ log?.warn?.(`failed to download file ${message.fileKey}: ${String(err)}`);
130
+ }
131
+ }
132
+
133
+ return message;
134
+ }
135
+
71
136
  // Channel metadata
72
137
  const meta: ChannelMeta = {
73
138
  id: "feishu",
@@ -748,6 +813,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
748
813
  // Fetch reply context if this is a reply
749
814
  const messageWithContext = await fetchReplyContext(account, inbound, ctx.log);
750
815
 
816
+ // Download media (image/file/audio/video) if present
817
+ const messageWithMedia = await downloadMessageMedia(account, messageWithContext, ctx.log);
818
+
751
819
  // Prepare group history
752
820
  const isGroup = inbound.chatType === "group";
753
821
  const historyEntry: HistoryEntry | undefined = isGroup
@@ -765,7 +833,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
765
833
  try {
766
834
  // Build message context using local module
767
835
  const context = await buildFeishuMessageContext({
768
- message: messageWithContext,
836
+ message: messageWithMedia,
769
837
  account,
770
838
  cfg: ctx.cfg,
771
839
  botOpenId,
package/src/webhook.ts CHANGED
@@ -341,6 +341,10 @@ function handleBotRemoved(event: FeishuBotRemovedEvent): FeishuWebhookResult {
341
341
 
342
342
  /**
343
343
  * Extract plain text from post content
344
+ * Supports multiple formats:
345
+ * - { zh_cn: { title, content } } - with language tag
346
+ * - { title, content } - direct format (common in received messages)
347
+ * - { post: { zh_cn: { ... } } } - nested post field
344
348
  */
345
349
  function extractTextFromPost(content: unknown): string {
346
350
  const texts: string[] = [];
@@ -351,30 +355,58 @@ function extractTextFromPost(content: unknown): string {
351
355
  continue;
352
356
  }
353
357
 
354
- const el = element as { tag?: string; text?: string; content?: unknown[][] };
358
+ const el = element as { tag?: string; text?: string; user_name?: string };
355
359
 
356
- if (el.tag === "text" && el.text) {
357
- texts.push(el.text);
358
- } else if (el.tag === "a" && el.text) {
360
+ if ((el.tag === "text" || el.tag === "a") && el.text) {
359
361
  texts.push(el.text);
362
+ } else if (el.tag === "at" && el.user_name) {
363
+ texts.push(`@${el.user_name}`);
360
364
  }
361
365
  }
362
366
  }
363
367
 
364
- if (typeof content === "object" && content !== null) {
365
- const post = content as {
366
- zh_cn?: { content?: unknown[][] };
367
- en_us?: { content?: unknown[][] };
368
- };
368
+ if (typeof content !== "object" || content === null) {
369
+ return "";
370
+ }
371
+
372
+ const obj = content as Record<string, unknown>;
373
+
374
+ let postContent: unknown[][] | undefined;
375
+ let title: string | undefined;
376
+
377
+ // Format 1: { zh_cn/en_us: { title, content } }
378
+ const langPost = (obj.zh_cn || obj.en_us) as { title?: string; content?: unknown[][] } | undefined;
379
+ if (langPost?.content) {
380
+ postContent = langPost.content;
381
+ title = langPost.title;
382
+ }
383
+
384
+ // Format 2: { title, content } - direct format
385
+ if (!postContent && Array.isArray(obj.content)) {
386
+ postContent = obj.content as unknown[][];
387
+ title = obj.title as string | undefined;
388
+ }
369
389
 
370
- // Try zh_cn first, then en_us
371
- const postContent = post.zh_cn?.content || post.en_us?.content;
390
+ // Format 3: { post: { zh_cn: { ... } } }
391
+ if (!postContent && obj.post) {
392
+ const nested = obj.post as Record<string, unknown>;
393
+ const nestedLang = (nested.zh_cn || nested.en_us) as { title?: string; content?: unknown[][] } | undefined;
394
+ if (nestedLang?.content) {
395
+ postContent = nestedLang.content;
396
+ title = nestedLang.title;
397
+ }
398
+ }
399
+
400
+ // Extract title
401
+ if (title) {
402
+ texts.push(title);
403
+ }
372
404
 
373
- if (Array.isArray(postContent)) {
374
- for (const line of postContent) {
375
- if (Array.isArray(line)) {
376
- extractFromElements(line);
377
- }
405
+ // Extract content
406
+ if (Array.isArray(postContent)) {
407
+ for (const line of postContent) {
408
+ if (Array.isArray(line)) {
409
+ extractFromElements(line);
378
410
  }
379
411
  }
380
412
  }