openclaw-lark-multi-agent 0.1.5 → 0.1.7

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);
@@ -335,7 +344,7 @@ export class FeishuBot {
335
344
  // Single slash commands are handled by the bridge. Double slash commands were
336
345
  // already unescaped above and should pass through to OpenClaw instead.
337
346
  const isBridgeCommand = !commandText.startsWith("//");
338
- const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free)/.test(cleanText.trim());
347
+ const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode)/.test(cleanText.trim());
339
348
  if (isCommand) {
340
349
  // In group chats, bridge commands must be explicitly routed to this bot
341
350
  // or @all. Do not let Free Discussion make a bot execute commands meant
@@ -357,7 +366,9 @@ export class FeishuBot {
357
366
  `🧹 /compact — 压缩当前 bot 的 OpenClaw session`,
358
367
  `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
359
368
  `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
360
- `🔓 /free [on|off|status] 开关当前 bot 在当前群聊的 Free Discussion`,
369
+ `🔓 /free 切换当前 bot free 模式(不 @ 也可回复)`,
370
+ `🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
371
+ `🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
361
372
  `❓ /help — 显示此帮助信息`,
362
373
  ``,
363
374
  `OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
@@ -416,32 +427,64 @@ export class FeishuBot {
416
427
  }
417
428
  if (cleanText.trim().startsWith("/free")) {
418
429
  if (chatType === "p2p") {
419
- await this.replyMessage(messageId, "❌ Free Discussion 只在群聊中可用");
430
+ await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
420
431
  markCommandSynced();
421
432
  return;
422
433
  }
423
- const parts = cleanText.trim().split(/\s+/);
424
- const arg = (parts[1] || "").toLowerCase();
425
- const isOn = this.store.getBotFreeDiscussion(this.config.name, chatId);
426
- const next = arg === "on" || arg === "true" || arg === "1"
427
- ? true
428
- : arg === "off" || arg === "false" || arg === "0"
429
- ? false
430
- : arg === "status"
431
- ? isOn
432
- : !isOn;
433
- if (arg !== "status")
434
- this.store.setBotFreeDiscussion(this.config.name, chatId, next);
435
- if (next) {
436
- await this.replyMessage(messageId, `🔓 ${this.config.name} Free Discussion 已开启\n只影响当前 Bot 在当前群聊的自由发言(连续 Bot 回复超过 ${MAX_BOT_STREAK} 轮将暂停,等待人类发言)`);
434
+ const current = this.store.getBotMode(this.config.name, chatId);
435
+ const next = current === "free" ? "normal" : "free";
436
+ this.store.setBotMode(this.config.name, chatId, next);
437
+ if (next === "free") {
438
+ await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式\n不需要 @ 也可以参与回复(连续 Bot 回复超过 ${MAX_BOT_STREAK} 轮将暂停,等待人类发言)`);
439
+ }
440
+ else {
441
+ await this.replyMessage(messageId, `🔒 ${this.config.name} 已切换到 normal 模式\n只有明确 @ 我才会回复`);
442
+ }
443
+ markCommandSynced();
444
+ return;
445
+ }
446
+ if (cleanText.trim().startsWith("/mute")) {
447
+ if (chatType === "p2p") {
448
+ await this.replyMessage(messageId, "❌ Mute 模式只在群聊中可用");
449
+ markCommandSynced();
450
+ return;
451
+ }
452
+ const current = this.store.getBotMode(this.config.name, chatId);
453
+ const next = current === "mute" ? "normal" : "mute";
454
+ this.store.setBotMode(this.config.name, chatId, next);
455
+ if (next === "mute") {
456
+ await this.replyMessage(messageId, `🤐 ${this.config.name} 已切换到 mute 模式\n普通消息、@所有人 都不会回复;明确 @ 我时只提示禁言中`);
457
+ }
458
+ else {
459
+ await this.replyMessage(messageId, `🔒 ${this.config.name} 已解除 mute,回到 normal 模式\n只有明确 @ 我才会回复`);
460
+ }
461
+ markCommandSynced();
462
+ return;
463
+ }
464
+ if (cleanText.trim().startsWith("/mode")) {
465
+ if (chatType === "p2p") {
466
+ await this.replyMessage(messageId, `🎛️ ${this.config.name} 当前模式:normal(私聊总是响应)`);
437
467
  }
438
468
  else {
439
- await this.replyMessage(messageId, `🔒 ${this.config.name} Free Discussion 已关闭\n只影响当前 Bot;群聊中需要 @ 指定 Bot 才会回复`);
469
+ const mode = this.store.getBotMode(this.config.name, chatId);
470
+ const desc = mode === "free" ? "不需要 @ 也可以参与回复" : mode === "mute" ? "禁言中;明确 @ 我时只提示禁言中" : "只有明确 @ 我才会回复";
471
+ await this.replyMessage(messageId, `🎛️ ${this.config.name} 当前模式:${mode}\n${desc}`);
440
472
  }
441
473
  markCommandSynced();
442
474
  return;
443
475
  }
444
476
  }
477
+ // --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
478
+ if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
479
+ if (this.isMentioned(message.mentions || [])) {
480
+ await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
481
+ if (insertedId > 0) {
482
+ this.store.markSynced(this.config.name, chatId, insertedId);
483
+ this.store.clearPendingTriggers(this.config.name, chatId, insertedId);
484
+ }
485
+ }
486
+ return;
487
+ }
445
488
  // --- Should this bot respond? ---
446
489
  if (!this.shouldRespond(chatType, message, isBot, chatId, message.content))
447
490
  return;
@@ -550,16 +593,22 @@ export class FeishuBot {
550
593
  const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
551
594
  this.store.markSynced(this.config.name, chatId, maxId);
552
595
  this.store.clearPendingTriggers(this.config.name, chatId, maxId);
553
- const trimmedReply = reply.trim();
596
+ const parsedReply = this.extractBridgeAttachments(reply);
597
+ const visibleReply = parsedReply.text;
598
+ const trimmedReply = visibleReply.trim();
554
599
  const shouldReply = trimmedReply.length > 0 && trimmedReply.toUpperCase() !== "NO_REPLY";
600
+ const hasAttachments = parsedReply.attachments.length > 0;
555
601
  // Record bot reply only if it is user-visible/context-worthy.
556
- if (shouldReply) {
602
+ if (shouldReply || hasAttachments) {
603
+ const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
604
+ .filter(Boolean)
605
+ .join("\n");
557
606
  const replyId = this.store.insert({
558
607
  chatId,
559
608
  messageId: `self-${this.config.name}-${Date.now()}`,
560
609
  senderType: "bot",
561
610
  senderName: this.config.name,
562
- content: reply,
611
+ content: storedContent,
563
612
  timestamp: Date.now(),
564
613
  });
565
614
  if (replyId > 0)
@@ -573,28 +622,33 @@ export class FeishuBot {
573
622
  }
574
623
  // Reply to the last human message on Feishu (ordered after tool msgs)
575
624
  // Skip empty replies and explicit NO_REPLY responses
576
- if (shouldReply && lastHuman.messageId) {
625
+ if ((shouldReply || hasAttachments) && lastHuman.messageId) {
577
626
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
578
627
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
579
628
  }
580
629
  else {
581
630
  await this.sendOrdered(chatId, async () => {
582
- try {
583
- await this.replyMessage(lastHuman.messageId, reply);
631
+ if (shouldReply) {
632
+ try {
633
+ await this.replyMessage(lastHuman.messageId, visibleReply);
634
+ }
635
+ catch (err) {
636
+ // Reply can fail for historical messages sent before this bot joined the chat
637
+ // (Feishu 230002: Bot/User can NOT be out of the chat). Fall back to a normal
638
+ // chat message so queue processing still completes.
639
+ console.warn(`[${this.config.name}] replyMessage failed, fallback to sendMessage:`, err.message);
640
+ await this.sendMessage(chatId, visibleReply);
641
+ }
584
642
  }
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);
643
+ for (const attachment of parsedReply.attachments) {
644
+ await this.sendBridgeAttachment(chatId, attachment);
591
645
  }
592
646
  if (triggerId)
593
647
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
594
648
  });
595
649
  }
596
650
  }
597
- console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars)`);
651
+ console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
598
652
  // Replace ack reactions with DONE for all pending messages in this chat
599
653
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
600
654
  for (const ack of pendingAcks) {
@@ -643,9 +697,9 @@ export class FeishuBot {
643
697
  const anyBotMentioned = mentions.some((m) => this.mentionedBotName(m) !== null);
644
698
  if (anyBotMentioned && !this.isMentioned(mentions))
645
699
  return false;
646
- // No bot mentioned: check free discussion mode
700
+ // No bot mentioned: check current per-bot mode
647
701
  if (chatId) {
648
- if (this.store.getBotFreeDiscussion(this.config.name, chatId))
702
+ if (this.store.getBotMode(this.config.name, chatId) === "free")
649
703
  return true;
650
704
  }
651
705
  // Default: don't respond without @
@@ -734,6 +788,137 @@ export class FeishuBot {
734
788
  });
735
789
  }
736
790
  }
791
+ extractBridgeAttachments(reply) {
792
+ const attachments = [];
793
+ const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)<\/LMA_BRIDGE_ATTACHMENTS>/g;
794
+ let text = reply.replace(markerPattern, (_match, jsonText) => {
795
+ try {
796
+ const parsed = JSON.parse(String(jsonText).trim());
797
+ const rawAttachments = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
798
+ for (const item of rawAttachments) {
799
+ if (!item || typeof item.path !== "string")
800
+ continue;
801
+ attachments.push({
802
+ type: item.type === "image" || item.type === "document" || item.type === "file" ? item.type : undefined,
803
+ path: item.path,
804
+ caption: typeof item.caption === "string" ? item.caption : undefined,
805
+ });
806
+ }
807
+ }
808
+ catch (err) {
809
+ console.warn(`[${this.config.name}] Failed to parse bridge attachment marker:`, err.message);
810
+ }
811
+ return "";
812
+ }).trim();
813
+ return { text, attachments };
814
+ }
815
+ validateBridgeAttachmentPath(filePath) {
816
+ const resolvedPath = resolve(filePath);
817
+ const allowedRoot = resolve(BRIDGE_ATTACHMENTS_DIR);
818
+ if (!(resolvedPath === allowedRoot || resolvedPath.startsWith(allowedRoot + "/"))) {
819
+ throw new Error(`Attachment path outside allowed directory: ${resolvedPath}`);
820
+ }
821
+ if (!existsSync(resolvedPath))
822
+ throw new Error(`Attachment file not found: ${resolvedPath}`);
823
+ const stats = statSync(resolvedPath);
824
+ if (!stats.isFile())
825
+ throw new Error(`Attachment path is not a file: ${resolvedPath}`);
826
+ if (stats.size <= 0)
827
+ throw new Error(`Attachment file is empty: ${resolvedPath}`);
828
+ if (stats.size > 30 * 1024 * 1024)
829
+ throw new Error(`Attachment file too large (>30MB): ${resolvedPath}`);
830
+ return resolvedPath;
831
+ }
832
+ inferFeishuFileType(filePath) {
833
+ const ext = extname(filePath).toLowerCase();
834
+ if (ext === ".pdf")
835
+ return "pdf";
836
+ if ([".doc", ".docx"].includes(ext))
837
+ return "doc";
838
+ if ([".xls", ".xlsx", ".csv"].includes(ext))
839
+ return "xls";
840
+ if ([".ppt", ".pptx"].includes(ext))
841
+ return "ppt";
842
+ return "stream";
843
+ }
844
+ isImagePath(filePath) {
845
+ return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".bmp", ".ico"].includes(extname(filePath).toLowerCase());
846
+ }
847
+ async createFeishuDocFromMarkdown(filePath) {
848
+ const rawTitle = basename(filePath).replace(/\.[^.]+$/, "").trim() || "Markdown Document";
849
+ const markdown = readFileSync(filePath, "utf8");
850
+ const docx = this.client.docx;
851
+ const created = await docx.document.create({ data: { title: rawTitle } });
852
+ const documentId = created?.data?.document?.document_id || created?.document?.document_id;
853
+ const revisionId = created?.data?.document?.revision_id || created?.document?.revision_id;
854
+ if (!documentId)
855
+ throw new Error(`Feishu doc create returned no document_id for ${filePath}`);
856
+ const converted = await docx.document.convert({ data: { content_type: "markdown", content: markdown } });
857
+ const convertedData = converted?.data || converted;
858
+ const blocks = convertedData?.blocks || [];
859
+ const firstLevelBlockIds = convertedData?.first_level_block_ids || [];
860
+ if (Array.isArray(blocks) && blocks.length > 0) {
861
+ await docx.documentBlockDescendant.create({
862
+ path: { document_id: documentId, block_id: documentId },
863
+ data: {
864
+ children_id: firstLevelBlockIds,
865
+ descendants: blocks,
866
+ document_revision_id: revisionId,
867
+ },
868
+ });
869
+ }
870
+ return { title: rawTitle, url: `https://www.feishu.cn/docx/${documentId}` };
871
+ }
872
+ async sendBridgeAttachment(chatId, attachment) {
873
+ const filePath = this.validateBridgeAttachmentPath(attachment.path);
874
+ const type = attachment.type || (this.isImagePath(filePath) ? "image" : "file");
875
+ if (type === "document" && extname(filePath).toLowerCase() === ".md") {
876
+ const doc = await this.createFeishuDocFromMarkdown(filePath);
877
+ const caption = attachment.caption?.trim() || `飞书文档:${doc.title}`;
878
+ await this.sendMessage(chatId, `${caption}
879
+ ${doc.url}`);
880
+ return;
881
+ }
882
+ if (attachment.caption?.trim())
883
+ await this.sendMessage(chatId, attachment.caption.trim());
884
+ if (type === "image") {
885
+ if (statSync(filePath).size > 10 * 1024 * 1024)
886
+ throw new Error(`Image too large (>10MB): ${filePath}`);
887
+ const uploaded = await this.client.im.image.create({
888
+ data: { image_type: "message", image: readFileSync(filePath) },
889
+ });
890
+ const imageKey = uploaded?.image_key || uploaded?.data?.image_key;
891
+ if (!imageKey)
892
+ throw new Error(`Feishu image upload returned no image_key for ${filePath}`);
893
+ await this.client.im.message.create({
894
+ params: { receive_id_type: "chat_id" },
895
+ data: {
896
+ receive_id: chatId,
897
+ content: JSON.stringify({ image_key: imageKey }),
898
+ msg_type: "image",
899
+ },
900
+ });
901
+ return;
902
+ }
903
+ const uploaded = await this.client.im.file.create({
904
+ data: {
905
+ file_type: this.inferFeishuFileType(filePath),
906
+ file_name: basename(filePath),
907
+ file: readFileSync(filePath),
908
+ },
909
+ });
910
+ const fileKey = uploaded?.file_key || uploaded?.data?.file_key;
911
+ if (!fileKey)
912
+ throw new Error(`Feishu file upload returned no file_key for ${filePath}`);
913
+ await this.client.im.message.create({
914
+ params: { receive_id_type: "chat_id" },
915
+ data: {
916
+ receive_id: chatId,
917
+ content: JSON.stringify({ file_key: fileKey }),
918
+ msg_type: "file",
919
+ },
920
+ });
921
+ }
737
922
  /**
738
923
  * Send a proactive message to a chat (not a reply).
739
924
  */
@@ -849,7 +1034,7 @@ export class FeishuBot {
849
1034
  const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
850
1035
  const status = session?.status || "unknown";
851
1036
  const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
852
- const freeStatus = this.store.getBotFreeDiscussion(this.config.name, chatId) ? "🔓 开启" : "🔒 关闭";
1037
+ const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
853
1038
  const statusText = [
854
1039
  `📊 ${this.config.name} Bot Status`,
855
1040
  `━━━━━━━━━━━━━━━━━━`,
@@ -862,7 +1047,7 @@ export class FeishuBot {
862
1047
  `🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
863
1048
  `📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
864
1049
  `🔧 Verbose: ${verboseStatus}`,
865
- `💬 Free Discussion: ${freeStatus}`,
1050
+ `🎛️ Mode: ${mode}`,
866
1051
  ].join("\n");
867
1052
  await this.replyMessage(messageId, statusText);
868
1053
  }
@@ -1,3 +1,4 @@
1
+ export type BotChatMode = "normal" | "free" | "mute";
1
2
  export interface ChatInfo {
2
3
  chatId: string;
3
4
  chatType: "p2p" | "group";
@@ -8,7 +9,7 @@ export interface ChatInfo {
8
9
  memberNames: string;
9
10
  /** Which bot owns this chat (for p2p isolation) */
10
11
  ownerBot: string;
11
- /** Free discussion mode (group chat: all bots respond without @) */
12
+ /** Legacy chat-level free discussion flag; per-bot mode is authoritative. */
12
13
  freeDiscussion: boolean;
13
14
  verbose: boolean;
14
15
  updatedAt: number;
@@ -58,6 +59,8 @@ export declare class MessageStore {
58
59
  setVerbose(chatId: string, verbose: boolean): void;
59
60
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
60
61
  getBotVerbose(botName: string, chatId: string): boolean;
62
+ setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
63
+ getBotMode(botName: string, chatId: string): BotChatMode;
61
64
  setBotFreeDiscussion(botName: string, chatId: string, on: boolean): void;
62
65
  getBotFreeDiscussion(botName: string, chatId: string): boolean;
63
66
  getChatInfo(chatId: string): ChatInfo | null;
@@ -76,6 +76,7 @@ export class MessageStore {
76
76
  chat_id TEXT NOT NULL,
77
77
  verbose INTEGER NOT NULL DEFAULT 0,
78
78
  free_discussion INTEGER NOT NULL DEFAULT 0,
79
+ mode TEXT NOT NULL DEFAULT 'normal',
79
80
  updated_at INTEGER NOT NULL DEFAULT 0,
80
81
  PRIMARY KEY (bot_name, chat_id)
81
82
  );
@@ -108,6 +109,18 @@ export class MessageStore {
108
109
  catch {
109
110
  // Column already exists
110
111
  }
112
+ // Migration: replace independent free/mute booleans with one mutually exclusive mode.
113
+ try {
114
+ this.db.exec(`ALTER TABLE bot_chat_settings ADD COLUMN mode TEXT NOT NULL DEFAULT 'normal'`);
115
+ }
116
+ catch {
117
+ // Column already exists
118
+ }
119
+ this.db.exec(`
120
+ UPDATE bot_chat_settings
121
+ SET mode = 'free'
122
+ WHERE free_discussion = 1 AND (mode IS NULL OR mode = '' OR mode = 'normal')
123
+ `);
111
124
  }
112
125
  /**
113
126
  * Insert a message. Returns the auto-increment id, or -1 if duplicate.
@@ -278,21 +291,31 @@ export class MessageStore {
278
291
  `).get(botName, chatId);
279
292
  return !!row?.verbose;
280
293
  }
281
- setBotFreeDiscussion(botName, chatId, on) {
294
+ setBotMode(botName, chatId, mode) {
295
+ const freeDiscussion = mode === "free" ? 1 : 0;
282
296
  this.db.prepare(`
283
- INSERT INTO bot_chat_settings (bot_name, chat_id, free_discussion, updated_at)
284
- VALUES (?, ?, ?, ?)
297
+ INSERT INTO bot_chat_settings (bot_name, chat_id, mode, free_discussion, updated_at)
298
+ VALUES (?, ?, ?, ?, ?)
285
299
  ON CONFLICT (bot_name, chat_id) DO UPDATE SET
300
+ mode = excluded.mode,
286
301
  free_discussion = excluded.free_discussion,
287
302
  updated_at = excluded.updated_at
288
- `).run(botName, chatId, on ? 1 : 0, Date.now());
303
+ `).run(botName, chatId, mode, freeDiscussion, Date.now());
289
304
  }
290
- getBotFreeDiscussion(botName, chatId) {
305
+ getBotMode(botName, chatId) {
291
306
  const row = this.db.prepare(`
292
- SELECT free_discussion FROM bot_chat_settings
307
+ SELECT mode, free_discussion FROM bot_chat_settings
293
308
  WHERE bot_name = ? AND chat_id = ?
294
309
  `).get(botName, chatId);
295
- return !!row?.free_discussion;
310
+ if (row?.mode === "free" || row?.mode === "mute" || row?.mode === "normal")
311
+ return row.mode;
312
+ return row?.free_discussion ? "free" : "normal";
313
+ }
314
+ setBotFreeDiscussion(botName, chatId, on) {
315
+ this.setBotMode(botName, chatId, on ? "free" : "normal");
316
+ }
317
+ getBotFreeDiscussion(botName, chatId) {
318
+ return this.getBotMode(botName, chatId) === "free";
296
319
  }
297
320
  getChatInfo(chatId) {
298
321
  const row = this.db.prepare(`SELECT * FROM chat_info WHERE chat_id = ?`).get(chatId);
@@ -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.7",
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": {