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.
- package/dist/feishu-bot.d.ts +6 -0
- package/dist/feishu-bot.js +165 -14
- 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);
|
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
*
|
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
|
+
}
|