openclaw-lark-multi-agent 0.1.5 → 0.1.6

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.
@@ -74,6 +74,12 @@ export declare class FeishuBot {
74
74
  private cleanMentions;
75
75
  private stripLeadingCommandMentions;
76
76
  private replyMessage;
77
+ private extractBridgeAttachments;
78
+ private validateBridgeAttachmentPath;
79
+ private inferFeishuFileType;
80
+ private isImagePath;
81
+ private createFeishuDocFromMarkdown;
82
+ private sendBridgeAttachment;
77
83
  /**
78
84
  * Send a proactive message to a chat (not a reply).
79
85
  */
@@ -1,5 +1,9 @@
1
1
  import * as lark from "@larksuiteoapi/node-sdk";
2
+ import { existsSync, readFileSync, statSync } from "fs";
3
+ import { basename, extname, resolve } from "path";
4
+ import { getBridgeAttachmentsDir } from "./paths.js";
2
5
  const MAX_BOT_STREAK = 10;
6
+ const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
3
7
  /**
4
8
  * Manages a single Feishu bot instance.
5
9
  *
@@ -107,7 +111,12 @@ export class FeishuBot {
107
111
  await this.openclawClient.subscribeSession(sessionKey, async (text) => {
108
112
  try {
109
113
  console.log(`[${this.config.name}] Proactive message for ${chatId.slice(-8)}`);
110
- await this.sendMessage(chatId, text);
114
+ const parsed = this.extractBridgeAttachments(text);
115
+ if (parsed.text.trim())
116
+ await this.sendMessage(chatId, parsed.text);
117
+ for (const attachment of parsed.attachments) {
118
+ await this.sendBridgeAttachment(chatId, attachment);
119
+ }
111
120
  }
112
121
  catch (err) {
113
122
  console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
@@ -550,16 +559,22 @@ export class FeishuBot {
550
559
  const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
551
560
  this.store.markSynced(this.config.name, chatId, maxId);
552
561
  this.store.clearPendingTriggers(this.config.name, chatId, maxId);
553
- const trimmedReply = reply.trim();
562
+ const parsedReply = this.extractBridgeAttachments(reply);
563
+ const visibleReply = parsedReply.text;
564
+ const trimmedReply = visibleReply.trim();
554
565
  const shouldReply = trimmedReply.length > 0 && trimmedReply.toUpperCase() !== "NO_REPLY";
566
+ const hasAttachments = parsedReply.attachments.length > 0;
555
567
  // Record bot reply only if it is user-visible/context-worthy.
556
- if (shouldReply) {
568
+ if (shouldReply || hasAttachments) {
569
+ const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
570
+ .filter(Boolean)
571
+ .join("\n");
557
572
  const replyId = this.store.insert({
558
573
  chatId,
559
574
  messageId: `self-${this.config.name}-${Date.now()}`,
560
575
  senderType: "bot",
561
576
  senderName: this.config.name,
562
- content: reply,
577
+ content: storedContent,
563
578
  timestamp: Date.now(),
564
579
  });
565
580
  if (replyId > 0)
@@ -573,28 +588,33 @@ export class FeishuBot {
573
588
  }
574
589
  // Reply to the last human message on Feishu (ordered after tool msgs)
575
590
  // Skip empty replies and explicit NO_REPLY responses
576
- if (shouldReply && lastHuman.messageId) {
591
+ if ((shouldReply || hasAttachments) && lastHuman.messageId) {
577
592
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
578
593
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
579
594
  }
580
595
  else {
581
596
  await this.sendOrdered(chatId, async () => {
582
- try {
583
- await this.replyMessage(lastHuman.messageId, reply);
597
+ if (shouldReply) {
598
+ try {
599
+ await this.replyMessage(lastHuman.messageId, visibleReply);
600
+ }
601
+ catch (err) {
602
+ // Reply can fail for historical messages sent before this bot joined the chat
603
+ // (Feishu 230002: Bot/User can NOT be out of the chat). Fall back to a normal
604
+ // chat message so queue processing still completes.
605
+ console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
606
+ await this.sendMessage(chatId, visibleReply);
607
+ }
584
608
  }
585
- catch (err) {
586
- // Reply can fail for historical messages sent before this bot joined the chat
587
- // (Feishu 230002: Bot/User can NOT be out of the chat). Fall back to a normal
588
- // chat message so queue processing still completes.
589
- console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
590
- await this.sendMessage(chatId, reply);
609
+ for (const attachment of parsedReply.attachments) {
610
+ await this.sendBridgeAttachment(chatId, attachment);
591
611
  }
592
612
  if (triggerId)
593
613
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
594
614
  });
595
615
  }
596
616
  }
597
- console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars)`);
617
+ console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
598
618
  // Replace ack reactions with DONE for all pending messages in this chat
599
619
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
600
620
  for (const ack of pendingAcks) {
@@ -734,6 +754,137 @@ export class FeishuBot {
734
754
  });
735
755
  }
736
756
  }
757
+ extractBridgeAttachments(reply) {
758
+ const attachments = [];
759
+ const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)<\/LMA_BRIDGE_ATTACHMENTS>/g;
760
+ let text = reply.replace(markerPattern, (_match, jsonText) => {
761
+ try {
762
+ const parsed = JSON.parse(String(jsonText).trim());
763
+ const rawAttachments = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
764
+ for (const item of rawAttachments) {
765
+ if (!item || typeof item.path !== "string")
766
+ continue;
767
+ attachments.push({
768
+ type: item.type === "image" || item.type === "document" || item.type === "file" ? item.type : undefined,
769
+ path: item.path,
770
+ caption: typeof item.caption === "string" ? item.caption : undefined,
771
+ });
772
+ }
773
+ }
774
+ catch (err) {
775
+ console.warn(`[${this.config.name}] Failed to parse bridge attachment marker:`, err.message);
776
+ }
777
+ return "";
778
+ }).trim();
779
+ return { text, attachments };
780
+ }
781
+ validateBridgeAttachmentPath(filePath) {
782
+ const resolvedPath = resolve(filePath);
783
+ const allowedRoot = resolve(BRIDGE_ATTACHMENTS_DIR);
784
+ if (!(resolvedPath === allowedRoot || resolvedPath.startsWith(allowedRoot + "/"))) {
785
+ throw new Error(`Attachment path outside allowed directory: ${resolvedPath}`);
786
+ }
787
+ if (!existsSync(resolvedPath))
788
+ throw new Error(`Attachment file not found: ${resolvedPath}`);
789
+ const stats = statSync(resolvedPath);
790
+ if (!stats.isFile())
791
+ throw new Error(`Attachment path is not a file: ${resolvedPath}`);
792
+ if (stats.size <= 0)
793
+ throw new Error(`Attachment file is empty: ${resolvedPath}`);
794
+ if (stats.size > 30 * 1024 * 1024)
795
+ throw new Error(`Attachment file too large (>30MB): ${resolvedPath}`);
796
+ return resolvedPath;
797
+ }
798
+ inferFeishuFileType(filePath) {
799
+ const ext = extname(filePath).toLowerCase();
800
+ if (ext === ".pdf")
801
+ return "pdf";
802
+ if ([".doc", ".docx"].includes(ext))
803
+ return "doc";
804
+ if ([".xls", ".xlsx", ".csv"].includes(ext))
805
+ return "xls";
806
+ if ([".ppt", ".pptx"].includes(ext))
807
+ return "ppt";
808
+ return "stream";
809
+ }
810
+ isImagePath(filePath) {
811
+ return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".bmp", ".ico"].includes(extname(filePath).toLowerCase());
812
+ }
813
+ async createFeishuDocFromMarkdown(filePath) {
814
+ const rawTitle = basename(filePath).replace(/\.[^.]+$/, "").trim() || "Markdown Document";
815
+ const markdown = readFileSync(filePath, "utf8");
816
+ const docx = this.client.docx;
817
+ const created = await docx.document.create({ data: { title: rawTitle } });
818
+ const documentId = created?.data?.document?.document_id || created?.document?.document_id;
819
+ const revisionId = created?.data?.document?.revision_id || created?.document?.revision_id;
820
+ if (!documentId)
821
+ throw new Error(`Feishu doc create returned no document_id for ${filePath}`);
822
+ const converted = await docx.document.convert({ data: { content_type: "markdown", content: markdown } });
823
+ const convertedData = converted?.data || converted;
824
+ const blocks = convertedData?.blocks || [];
825
+ const firstLevelBlockIds = convertedData?.first_level_block_ids || [];
826
+ if (Array.isArray(blocks) && blocks.length > 0) {
827
+ await docx.documentBlockDescendant.create({
828
+ path: { document_id: documentId, block_id: documentId },
829
+ data: {
830
+ children_id: firstLevelBlockIds,
831
+ descendants: blocks,
832
+ document_revision_id: revisionId,
833
+ },
834
+ });
835
+ }
836
+ return { title: rawTitle, url: `https://www.feishu.cn/docx/${documentId}` };
837
+ }
838
+ async sendBridgeAttachment(chatId, attachment) {
839
+ const filePath = this.validateBridgeAttachmentPath(attachment.path);
840
+ const type = attachment.type || (this.isImagePath(filePath) ? "image" : "file");
841
+ if (type === "document" && extname(filePath).toLowerCase() === ".md") {
842
+ const doc = await this.createFeishuDocFromMarkdown(filePath);
843
+ const caption = attachment.caption?.trim() || `飞书文档:${doc.title}`;
844
+ await this.sendMessage(chatId, `${caption}
845
+ ${doc.url}`);
846
+ return;
847
+ }
848
+ if (attachment.caption?.trim())
849
+ await this.sendMessage(chatId, attachment.caption.trim());
850
+ if (type === "image") {
851
+ if (statSync(filePath).size > 10 * 1024 * 1024)
852
+ throw new Error(`Image too large (>10MB): ${filePath}`);
853
+ const uploaded = await this.client.im.image.create({
854
+ data: { image_type: "message", image: readFileSync(filePath) },
855
+ });
856
+ const imageKey = uploaded?.image_key || uploaded?.data?.image_key;
857
+ if (!imageKey)
858
+ throw new Error(`Feishu image upload returned no image_key for ${filePath}`);
859
+ await this.client.im.message.create({
860
+ params: { receive_id_type: "chat_id" },
861
+ data: {
862
+ receive_id: chatId,
863
+ content: JSON.stringify({ image_key: imageKey }),
864
+ msg_type: "image",
865
+ },
866
+ });
867
+ return;
868
+ }
869
+ const uploaded = await this.client.im.file.create({
870
+ data: {
871
+ file_type: this.inferFeishuFileType(filePath),
872
+ file_name: basename(filePath),
873
+ file: readFileSync(filePath),
874
+ },
875
+ });
876
+ const fileKey = uploaded?.file_key || uploaded?.data?.file_key;
877
+ if (!fileKey)
878
+ throw new Error(`Feishu file upload returned no file_key for ${filePath}`);
879
+ await this.client.im.message.create({
880
+ params: { receive_id_type: "chat_id" },
881
+ data: {
882
+ receive_id: chatId,
883
+ content: JSON.stringify({ file_key: fileKey }),
884
+ msg_type: "file",
885
+ },
886
+ });
887
+ }
737
888
  /**
738
889
  * Send a proactive message to a chat (not a reply).
739
890
  */
@@ -78,6 +78,8 @@ export declare class OpenClawClient {
78
78
  deliver?: boolean;
79
79
  timeoutMs?: number;
80
80
  }): Promise<string>;
81
+ private shouldInjectBridgeAttachmentHint;
82
+ private bridgeAttachmentHint;
81
83
  /**
82
84
  * Build and send a context catch-up message followed by the actual message.
83
85
  *
@@ -2,6 +2,8 @@ import WebSocket from "ws";
2
2
  import { randomUUID } from "crypto";
3
3
  import { readFileSync } from "fs";
4
4
  import { basename, extname } from "path";
5
+ import { getBridgeAttachmentsDir } from "./paths.js";
6
+ const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
5
7
  /**
6
8
  * OpenClaw Gateway WebSocket client.
7
9
  * Full agent pipeline — tools, memory, skills, context management by OpenClaw.
@@ -502,6 +504,16 @@ export class OpenClawClient {
502
504
  this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
503
505
  }
504
506
  }
507
+ shouldInjectBridgeAttachmentHint(text) {
508
+ return /发送|发到|发给|传|上传|附件|文件|文档|图片|图像|照片|生成图|做张图|画张图|导出|保存|pdf|docx?|xlsx?|pptx?|markdown|\bmd\b/i.test(text);
509
+ }
510
+ bridgeAttachmentHint(text) {
511
+ if (!this.shouldInjectBridgeAttachmentHint(text))
512
+ return "";
513
+ return `
514
+
515
+ [Bridge attachment capability hint: This is an OpenClaw Lark Multi-Agent bridge session. You cannot send Feishu files/images directly from inside OpenClaw. Do NOT call message, sessions_send, Feishu tools, or proactive send tools for this request. If the user asks you to send an image/file/document to Feishu, only create the local file under ${BRIDGE_ATTACHMENTS_DIR}/ and include this exact marker at the very end of your final reply (do not explain or expose the marker as normal text): <LMA_BRIDGE_ATTACHMENTS>{"attachments":[{"type":"image|file|document","path":"/absolute/path","caption":"optional"}]}</LMA_BRIDGE_ATTACHMENTS>. The bridge layer will parse this marker and send the attachment. Use type=image for images; use type=document for Markdown documents (.md) so the bridge creates a Feishu cloud document and sends its link; use type=file for other ordinary files.]`;
516
+ }
505
517
  /**
506
518
  * Build and send a context catch-up message followed by the actual message.
507
519
  *
@@ -523,11 +535,12 @@ export class OpenClawClient {
523
535
  const mediaInstruction = attachments.length > 0
524
536
  ? "\n\n[Media note: Image attachments are included with this message. If your model can inspect images directly, use the attached image input. If it cannot, use the image tool on the provided media/attachment path; do not try unrelated network or model-provider workarounds.]"
525
537
  : "";
538
+ const bridgeAttachmentHint = this.bridgeAttachmentHint(params.currentMessage);
526
539
  if (params.unsyncedMessages.length === 0) {
527
540
  // No context to catch up, send directly
528
541
  return this.chatSend({
529
542
  sessionKey: params.sessionKey,
530
- message: params.currentMessage + mediaInstruction,
543
+ message: params.currentMessage + mediaInstruction + bridgeAttachmentHint,
531
544
  attachments,
532
545
  deliver: params.deliver,
533
546
  timeoutMs: params.timeoutMs,
@@ -549,7 +562,7 @@ export class OpenClawClient {
549
562
  `[${params.currentSenderName}]: ${params.currentMessage}`;
550
563
  return this.chatSend({
551
564
  sessionKey: params.sessionKey,
552
- message: combined + mediaInstruction,
565
+ message: combined + mediaInstruction + bridgeAttachmentHint,
553
566
  attachments,
554
567
  deliver: params.deliver,
555
568
  timeoutMs: params.timeoutMs,
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Runtime state defaults to the user's home directory but can be overridden
3
+ * for packaged deployments, tests, containers, or systemd installations.
4
+ */
5
+ export declare function getStateDir(): string;
6
+ export declare function getBridgeAttachmentsDir(): string;
package/dist/paths.js ADDED
@@ -0,0 +1,12 @@
1
+ import { homedir } from "os";
2
+ import { resolve } from "path";
3
+ /**
4
+ * Runtime state defaults to the user's home directory but can be overridden
5
+ * for packaged deployments, tests, containers, or systemd installations.
6
+ */
7
+ export function getStateDir() {
8
+ return resolve(process.env.OPENCLAW_LARK_MULTI_AGENT_STATE_DIR || resolve(homedir(), ".openclaw/openclaw-lark-multi-agent"));
9
+ }
10
+ export function getBridgeAttachmentsDir() {
11
+ return resolve(process.env.OPENCLAW_LARK_MULTI_AGENT_ATTACHMENTS_DIR || resolve(getStateDir(), "attachments"));
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {