openclaw-lark-multi-agent 1.0.0 → 1.0.1
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 +4 -0
- package/dist/feishu-bot.js +64 -14
- package/dist/message-store.d.ts +1 -0
- package/dist/message-store.js +18 -0
- package/dist/openclaw-client.js +1 -1
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +13 -0
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export declare class FeishuBot {
|
|
|
31
31
|
private sendQueue;
|
|
32
32
|
/** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
|
|
33
33
|
private delayedFailureTimers;
|
|
34
|
+
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
35
|
+
private lastRealDeliveryAt;
|
|
34
36
|
private adminOpenId;
|
|
35
37
|
private static allBots;
|
|
36
38
|
constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string);
|
|
@@ -96,8 +98,10 @@ export declare class FeishuBot {
|
|
|
96
98
|
private validateBridgeAttachmentPath;
|
|
97
99
|
private inferFeishuFileType;
|
|
98
100
|
private isImagePath;
|
|
101
|
+
private errorSummary;
|
|
99
102
|
private createFeishuDocFromMarkdown;
|
|
100
103
|
private sendBridgeAttachment;
|
|
104
|
+
private sendBridgeFileAttachment;
|
|
101
105
|
/**
|
|
102
106
|
* Send a proactive message to a chat (not a reply).
|
|
103
107
|
*/
|
package/dist/feishu-bot.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as lark from "@larksuiteoapi/node-sdk";
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "fs";
|
|
3
3
|
import { basename, extname, resolve } from "path";
|
|
4
|
-
import { getBridgeAttachmentsDir } from "./paths.js";
|
|
4
|
+
import { getBridgeAttachmentAllowedRoots, getBridgeAttachmentsDir } from "./paths.js";
|
|
5
5
|
import { buildFeishuCardElements } from "./markdown.js";
|
|
6
6
|
import { discussionManager } from "./discussion-manager.js";
|
|
7
7
|
const MAX_BOT_STREAK = 10;
|
|
8
8
|
const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
|
|
9
|
+
const BRIDGE_ATTACHMENT_ALLOWED_ROOTS = getBridgeAttachmentAllowedRoots();
|
|
9
10
|
/**
|
|
10
11
|
* Manages a single Feishu bot instance.
|
|
11
12
|
*
|
|
@@ -36,6 +37,8 @@ export class FeishuBot {
|
|
|
36
37
|
sendQueue = new Map();
|
|
37
38
|
/** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
|
|
38
39
|
delayedFailureTimers = new Map();
|
|
40
|
+
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
41
|
+
lastRealDeliveryAt = new Map();
|
|
39
42
|
adminOpenId;
|
|
40
43
|
static allBots = new Map();
|
|
41
44
|
constructor(config, openclawClient, store, adminOpenId) {
|
|
@@ -213,22 +216,26 @@ export class FeishuBot {
|
|
|
213
216
|
async drainOnStartup() {
|
|
214
217
|
try {
|
|
215
218
|
const chats = this.store.getAllChatInfo();
|
|
219
|
+
const drainTasks = [];
|
|
216
220
|
for (const chat of chats) {
|
|
217
221
|
// Skip p2p chats that belong to other bots
|
|
218
222
|
if (chat.chatType === "p2p" && chat.ownerBot && chat.ownerBot !== this.config.name) {
|
|
219
223
|
continue;
|
|
220
224
|
}
|
|
221
225
|
// Re-subscribe to existing sessions
|
|
222
|
-
const sessionKey = this.getSessionKey(chat.chatId);
|
|
223
226
|
await this.ensureSession(chat.chatId);
|
|
224
227
|
// Drain only messages that were explicitly marked as reply triggers.
|
|
225
228
|
// Context-only messages should not start an OpenClaw run after restart.
|
|
226
229
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
|
|
227
230
|
if (pendingTriggerIds.size > 0) {
|
|
228
231
|
console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
|
|
229
|
-
|
|
232
|
+
// Do not let one slow/stuck chat block startup drain for all other chats.
|
|
233
|
+
drainTasks.push(this.processQueue(chat.chatId).catch((err) => {
|
|
234
|
+
console.warn(`[${this.config.name}] Startup drain failed for ${chat.chatId.slice(-8)}:`, err.message);
|
|
235
|
+
}));
|
|
230
236
|
}
|
|
231
237
|
}
|
|
238
|
+
await Promise.allSettled(drainTasks);
|
|
232
239
|
}
|
|
233
240
|
catch (err) {
|
|
234
241
|
console.warn(`[${this.config.name}] Startup drain failed:`, err.message);
|
|
@@ -623,10 +630,17 @@ export class FeishuBot {
|
|
|
623
630
|
}
|
|
624
631
|
async processQueueInner(chatId) {
|
|
625
632
|
while (true) {
|
|
626
|
-
const
|
|
633
|
+
const unsyncedMessages = this.store.getUnsyncedMessages(this.config.name, chatId);
|
|
634
|
+
const pendingMessages = this.store.getPendingTriggerMessages(this.config.name, chatId);
|
|
635
|
+
const messageById = new Map();
|
|
636
|
+
for (const msg of [...unsyncedMessages, ...pendingMessages]) {
|
|
637
|
+
if (msg.id)
|
|
638
|
+
messageById.set(msg.id, msg);
|
|
639
|
+
}
|
|
640
|
+
const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
627
641
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
|
|
628
|
-
// Only proceed if there are
|
|
629
|
-
//
|
|
642
|
+
// Only proceed if there are pending human messages that should actively trigger this bot.
|
|
643
|
+
// Pending triggers are included even if a later bridge command has advanced sync_state.
|
|
630
644
|
const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
|
|
631
645
|
if (humanUnsynced.length === 0) {
|
|
632
646
|
break;
|
|
@@ -920,6 +934,11 @@ export class FeishuBot {
|
|
|
920
934
|
this.delayedFailureTimers.delete(chatId);
|
|
921
935
|
}
|
|
922
936
|
scheduleDelayedFailure(chatId, replyToMessageId, text, triggerId) {
|
|
937
|
+
const lastRealDelivery = this.lastRealDeliveryAt.get(chatId) || 0;
|
|
938
|
+
if (Date.now() - lastRealDelivery < 90_000) {
|
|
939
|
+
console.log(`[${this.config.name}] Suppressed delayed runtime failure for ${chatId.slice(-8)} because a real reply was delivered recently`);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
923
942
|
this.cancelDelayedFailure(chatId);
|
|
924
943
|
const timer = setTimeout(() => {
|
|
925
944
|
this.delayedFailureTimers.delete(chatId);
|
|
@@ -976,6 +995,9 @@ export class FeishuBot {
|
|
|
976
995
|
if (deliveryId === null)
|
|
977
996
|
return;
|
|
978
997
|
await this.dispatchPendingDeliveries(chatId, replyToMessageId);
|
|
998
|
+
if (sourceType === "assistant_visible" && (text.trim() || attachments.length > 0) && text.trim().toUpperCase() !== "NO_REPLY" && !this.isRuntimeFailureText(text.trim())) {
|
|
999
|
+
this.lastRealDeliveryAt.set(chatId, Date.now());
|
|
1000
|
+
}
|
|
979
1001
|
}
|
|
980
1002
|
async dispatchPendingDeliveries(chatId, replyToMessageId) {
|
|
981
1003
|
const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
|
|
@@ -1009,6 +1031,17 @@ export class FeishuBot {
|
|
|
1009
1031
|
catch (err) {
|
|
1010
1032
|
if (item.id)
|
|
1011
1033
|
this.store.markDeliveryFailed(item.id);
|
|
1034
|
+
const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
|
|
1035
|
+
const replyTarget = item.replyToMessageId || replyToMessageId;
|
|
1036
|
+
try {
|
|
1037
|
+
if (replyTarget)
|
|
1038
|
+
await this.replyMessage(replyTarget, errorText);
|
|
1039
|
+
else
|
|
1040
|
+
await this.sendMessage(chatId, errorText);
|
|
1041
|
+
}
|
|
1042
|
+
catch (notifyErr) {
|
|
1043
|
+
console.warn(`[${this.config.name}] Failed to notify attachment delivery error:`, this.errorSummary(notifyErr));
|
|
1044
|
+
}
|
|
1012
1045
|
throw err;
|
|
1013
1046
|
}
|
|
1014
1047
|
});
|
|
@@ -1123,9 +1156,9 @@ export class FeishuBot {
|
|
|
1123
1156
|
}
|
|
1124
1157
|
validateBridgeAttachmentPath(filePath) {
|
|
1125
1158
|
const resolvedPath = resolve(filePath);
|
|
1126
|
-
const
|
|
1127
|
-
if (!
|
|
1128
|
-
throw new Error(`Attachment path outside allowed
|
|
1159
|
+
const isAllowed = BRIDGE_ATTACHMENT_ALLOWED_ROOTS.some((root) => resolvedPath === root || resolvedPath.startsWith(root + "/"));
|
|
1160
|
+
if (!isAllowed) {
|
|
1161
|
+
throw new Error(`Attachment path outside allowed directories (${BRIDGE_ATTACHMENT_ALLOWED_ROOTS.join(", ")}): ${resolvedPath}`);
|
|
1129
1162
|
}
|
|
1130
1163
|
if (!existsSync(resolvedPath))
|
|
1131
1164
|
throw new Error(`Attachment file not found: ${resolvedPath}`);
|
|
@@ -1153,6 +1186,12 @@ export class FeishuBot {
|
|
|
1153
1186
|
isImagePath(filePath) {
|
|
1154
1187
|
return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".bmp", ".ico"].includes(extname(filePath).toLowerCase());
|
|
1155
1188
|
}
|
|
1189
|
+
errorSummary(err) {
|
|
1190
|
+
const data = err?.response?.data || err?.data;
|
|
1191
|
+
const code = data?.code ? `code=${data.code} ` : "";
|
|
1192
|
+
const msg = data?.msg || data?.message || err?.message || String(err);
|
|
1193
|
+
return `${code}${msg}`.slice(0, 800);
|
|
1194
|
+
}
|
|
1156
1195
|
async createFeishuDocFromMarkdown(filePath) {
|
|
1157
1196
|
const rawTitle = basename(filePath).replace(/\.[^.]+$/, "").trim() || "Markdown Document";
|
|
1158
1197
|
const markdown = readFileSync(filePath, "utf8");
|
|
@@ -1182,11 +1221,19 @@ export class FeishuBot {
|
|
|
1182
1221
|
const filePath = this.validateBridgeAttachmentPath(attachment.path);
|
|
1183
1222
|
const type = attachment.type || (this.isImagePath(filePath) ? "image" : "file");
|
|
1184
1223
|
if (type === "document" && extname(filePath).toLowerCase() === ".md") {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
${doc.url}`);
|
|
1189
|
-
|
|
1224
|
+
try {
|
|
1225
|
+
const doc = await this.createFeishuDocFromMarkdown(filePath);
|
|
1226
|
+
const caption = attachment.caption?.trim() || `飞书文档:${doc.title}`;
|
|
1227
|
+
await this.sendMessage(chatId, `${caption}\n${doc.url}`);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
catch (err) {
|
|
1231
|
+
console.warn(`[${this.config.name}] Feishu doc conversion failed, falling back to file attachment:`, this.errorSummary(err));
|
|
1232
|
+
const caption = attachment.caption?.trim();
|
|
1233
|
+
await this.sendMessage(chatId, `${caption ? `${caption}\n` : ""}飞书文档创建失败,已改为 Markdown 文件附件发送。`);
|
|
1234
|
+
await this.sendBridgeFileAttachment(chatId, filePath);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1190
1237
|
}
|
|
1191
1238
|
if (attachment.caption?.trim())
|
|
1192
1239
|
await this.sendMessage(chatId, attachment.caption.trim());
|
|
@@ -1209,6 +1256,9 @@ ${doc.url}`);
|
|
|
1209
1256
|
});
|
|
1210
1257
|
return;
|
|
1211
1258
|
}
|
|
1259
|
+
await this.sendBridgeFileAttachment(chatId, filePath);
|
|
1260
|
+
}
|
|
1261
|
+
async sendBridgeFileAttachment(chatId, filePath) {
|
|
1212
1262
|
const uploaded = await this.client.im.file.create({
|
|
1213
1263
|
data: {
|
|
1214
1264
|
file_type: this.inferFeishuFileType(filePath),
|
package/dist/message-store.d.ts
CHANGED
|
@@ -55,6 +55,7 @@ export declare class MessageStore {
|
|
|
55
55
|
isMessageRecalled(messageId: string): boolean;
|
|
56
56
|
markPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
|
|
57
57
|
getPendingTriggerIds(botName: string, chatId: string): Set<number>;
|
|
58
|
+
getPendingTriggerMessages(botName: string, chatId: string): ChatMessage[];
|
|
58
59
|
clearPendingTriggers(botName: string, chatId: string, upToId: number): void;
|
|
59
60
|
clearPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
|
|
60
61
|
hasDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number): boolean;
|
package/dist/message-store.js
CHANGED
|
@@ -260,6 +260,24 @@ export class MessageStore {
|
|
|
260
260
|
`).all(botName, chatId);
|
|
261
261
|
return new Set(rows.map((r) => Number(r.message_row_id)));
|
|
262
262
|
}
|
|
263
|
+
getPendingTriggerMessages(botName, chatId) {
|
|
264
|
+
const rows = this.db.prepare(`
|
|
265
|
+
SELECT messages.* FROM pending_triggers
|
|
266
|
+
JOIN messages ON messages.id = pending_triggers.message_row_id
|
|
267
|
+
LEFT JOIN recalled_messages ON recalled_messages.message_id = messages.message_id
|
|
268
|
+
WHERE pending_triggers.bot_name = ? AND pending_triggers.chat_id = ? AND recalled_messages.message_id IS NULL
|
|
269
|
+
ORDER BY messages.timestamp ASC
|
|
270
|
+
`).all(botName, chatId);
|
|
271
|
+
return rows.map((r) => ({
|
|
272
|
+
id: r.id,
|
|
273
|
+
chatId: r.chat_id,
|
|
274
|
+
messageId: r.message_id,
|
|
275
|
+
senderType: r.sender_type,
|
|
276
|
+
senderName: r.sender_name,
|
|
277
|
+
content: r.content,
|
|
278
|
+
timestamp: r.timestamp,
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
263
281
|
clearPendingTriggers(botName, chatId, upToId) {
|
|
264
282
|
this.db.prepare(`
|
|
265
283
|
DELETE FROM pending_triggers
|
package/dist/openclaw-client.js
CHANGED
|
@@ -578,7 +578,7 @@ export class OpenClawClient {
|
|
|
578
578
|
return "";
|
|
579
579
|
return `
|
|
580
580
|
|
|
581
|
-
[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,
|
|
581
|
+
[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, prefer creating new files under ${BRIDGE_ATTACHMENTS_DIR}/; existing files under the OpenClaw workspace are also allowed. 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.]`;
|
|
582
582
|
}
|
|
583
583
|
/**
|
|
584
584
|
* Build and send a context catch-up message followed by the actual message.
|
package/dist/paths.d.ts
CHANGED
package/dist/paths.js
CHANGED
|
@@ -10,3 +10,16 @@ export function getStateDir() {
|
|
|
10
10
|
export function getBridgeAttachmentsDir() {
|
|
11
11
|
return resolve(process.env.OPENCLAW_LARK_MULTI_AGENT_ATTACHMENTS_DIR || resolve(getStateDir(), "attachments"));
|
|
12
12
|
}
|
|
13
|
+
export function getOpenClawWorkspaceDir() {
|
|
14
|
+
return resolve(process.env.OPENCLAW_WORKSPACE_DIR || resolve(homedir(), ".openclaw/workspace"));
|
|
15
|
+
}
|
|
16
|
+
export function getBridgeAttachmentAllowedRoots() {
|
|
17
|
+
const roots = [getBridgeAttachmentsDir(), getOpenClawWorkspaceDir()];
|
|
18
|
+
const extra = process.env.OPENCLAW_LARK_MULTI_AGENT_ATTACHMENT_ALLOW_ROOTS || "";
|
|
19
|
+
for (const part of extra.split(",")) {
|
|
20
|
+
const trimmed = part.trim();
|
|
21
|
+
if (trimmed)
|
|
22
|
+
roots.push(resolve(trimmed));
|
|
23
|
+
}
|
|
24
|
+
return Array.from(new Set(roots.map((root) => resolve(root))));
|
|
25
|
+
}
|