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.
- package/dist/feishu-bot.d.ts +6 -0
- package/dist/feishu-bot.js +221 -36
- package/dist/message-store.d.ts +4 -1
- package/dist/message-store.js +30 -7
- package/dist/openclaw-client.d.ts +2 -0
- package/dist/openclaw-client.js +15 -2
- package/dist/paths.d.ts +6 -0
- package/dist/paths.js +12 -0
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/feishu-bot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
430
|
+
await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
|
|
420
431
|
markCommandSynced();
|
|
421
432
|
return;
|
|
422
433
|
}
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
583
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
|
700
|
+
// No bot mentioned: check current per-bot mode
|
|
647
701
|
if (chatId) {
|
|
648
|
-
if (this.store.
|
|
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
|
|
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
|
-
|
|
1050
|
+
`🎛️ Mode: ${mode}`,
|
|
866
1051
|
].join("\n");
|
|
867
1052
|
await this.replyMessage(messageId, statusText);
|
|
868
1053
|
}
|
package/dist/message-store.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/message-store.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
303
|
+
`).run(botName, chatId, mode, freeDiscussion, Date.now());
|
|
289
304
|
}
|
|
290
|
-
|
|
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
|
-
|
|
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
|
*
|
package/dist/openclaw-client.js
CHANGED
|
@@ -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,
|
package/dist/paths.d.ts
ADDED
|
@@ -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
|
+
}
|