openclaw-lark-multi-agent 1.0.0 → 1.0.2
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 +69 -18
- package/dist/message-store.d.ts +2 -0
- package/dist/message-store.js +22 -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);
|
|
@@ -272,8 +279,11 @@ export class FeishuBot {
|
|
|
272
279
|
}
|
|
273
280
|
if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post")
|
|
274
281
|
return;
|
|
275
|
-
// --- Dedup:
|
|
276
|
-
|
|
282
|
+
// --- Dedup: atomically claim this message for this bot before any await.
|
|
283
|
+
// Feishu/WebSocket can deliver the same event more than once; a separate
|
|
284
|
+
// has-then-mark sequence races and can send the same user message into
|
|
285
|
+
// OpenClaw twice.
|
|
286
|
+
if (!this.store.tryMarkBotProcessed(this.config.name, messageId))
|
|
277
287
|
return;
|
|
278
288
|
// --- Cache chat info (lazy, at most once per hour) ---
|
|
279
289
|
await this.fetchAndCacheChatInfo(chatId, chatType);
|
|
@@ -378,8 +388,6 @@ export class FeishuBot {
|
|
|
378
388
|
});
|
|
379
389
|
if (insertedId < 0)
|
|
380
390
|
insertedId = this.store.getMessageId(messageId) || -1;
|
|
381
|
-
// Mark as processed only after successful parse + insert
|
|
382
|
-
this.store.markBotProcessed(this.config.name, messageId);
|
|
383
391
|
// --- Commands: in p2p always respond; in group, check shouldRespond first ---
|
|
384
392
|
// Single slash commands are handled by the bridge. Double slash commands were
|
|
385
393
|
// already unescaped above and should pass through to OpenClaw instead.
|
|
@@ -623,10 +631,17 @@ export class FeishuBot {
|
|
|
623
631
|
}
|
|
624
632
|
async processQueueInner(chatId) {
|
|
625
633
|
while (true) {
|
|
626
|
-
const
|
|
634
|
+
const unsyncedMessages = this.store.getUnsyncedMessages(this.config.name, chatId);
|
|
635
|
+
const pendingMessages = this.store.getPendingTriggerMessages(this.config.name, chatId);
|
|
636
|
+
const messageById = new Map();
|
|
637
|
+
for (const msg of [...unsyncedMessages, ...pendingMessages]) {
|
|
638
|
+
if (msg.id)
|
|
639
|
+
messageById.set(msg.id, msg);
|
|
640
|
+
}
|
|
641
|
+
const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
627
642
|
const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
|
|
628
|
-
// Only proceed if there are
|
|
629
|
-
//
|
|
643
|
+
// Only proceed if there are pending human messages that should actively trigger this bot.
|
|
644
|
+
// Pending triggers are included even if a later bridge command has advanced sync_state.
|
|
630
645
|
const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
|
|
631
646
|
if (humanUnsynced.length === 0) {
|
|
632
647
|
break;
|
|
@@ -920,6 +935,11 @@ export class FeishuBot {
|
|
|
920
935
|
this.delayedFailureTimers.delete(chatId);
|
|
921
936
|
}
|
|
922
937
|
scheduleDelayedFailure(chatId, replyToMessageId, text, triggerId) {
|
|
938
|
+
const lastRealDelivery = this.lastRealDeliveryAt.get(chatId) || 0;
|
|
939
|
+
if (Date.now() - lastRealDelivery < 90_000) {
|
|
940
|
+
console.log(`[${this.config.name}] Suppressed delayed runtime failure for ${chatId.slice(-8)} because a real reply was delivered recently`);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
923
943
|
this.cancelDelayedFailure(chatId);
|
|
924
944
|
const timer = setTimeout(() => {
|
|
925
945
|
this.delayedFailureTimers.delete(chatId);
|
|
@@ -976,6 +996,9 @@ export class FeishuBot {
|
|
|
976
996
|
if (deliveryId === null)
|
|
977
997
|
return;
|
|
978
998
|
await this.dispatchPendingDeliveries(chatId, replyToMessageId);
|
|
999
|
+
if (sourceType === "assistant_visible" && (text.trim() || attachments.length > 0) && text.trim().toUpperCase() !== "NO_REPLY" && !this.isRuntimeFailureText(text.trim())) {
|
|
1000
|
+
this.lastRealDeliveryAt.set(chatId, Date.now());
|
|
1001
|
+
}
|
|
979
1002
|
}
|
|
980
1003
|
async dispatchPendingDeliveries(chatId, replyToMessageId) {
|
|
981
1004
|
const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
|
|
@@ -1009,6 +1032,17 @@ export class FeishuBot {
|
|
|
1009
1032
|
catch (err) {
|
|
1010
1033
|
if (item.id)
|
|
1011
1034
|
this.store.markDeliveryFailed(item.id);
|
|
1035
|
+
const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
|
|
1036
|
+
const replyTarget = item.replyToMessageId || replyToMessageId;
|
|
1037
|
+
try {
|
|
1038
|
+
if (replyTarget)
|
|
1039
|
+
await this.replyMessage(replyTarget, errorText);
|
|
1040
|
+
else
|
|
1041
|
+
await this.sendMessage(chatId, errorText);
|
|
1042
|
+
}
|
|
1043
|
+
catch (notifyErr) {
|
|
1044
|
+
console.warn(`[${this.config.name}] Failed to notify attachment delivery error:`, this.errorSummary(notifyErr));
|
|
1045
|
+
}
|
|
1012
1046
|
throw err;
|
|
1013
1047
|
}
|
|
1014
1048
|
});
|
|
@@ -1123,9 +1157,9 @@ export class FeishuBot {
|
|
|
1123
1157
|
}
|
|
1124
1158
|
validateBridgeAttachmentPath(filePath) {
|
|
1125
1159
|
const resolvedPath = resolve(filePath);
|
|
1126
|
-
const
|
|
1127
|
-
if (!
|
|
1128
|
-
throw new Error(`Attachment path outside allowed
|
|
1160
|
+
const isAllowed = BRIDGE_ATTACHMENT_ALLOWED_ROOTS.some((root) => resolvedPath === root || resolvedPath.startsWith(root + "/"));
|
|
1161
|
+
if (!isAllowed) {
|
|
1162
|
+
throw new Error(`Attachment path outside allowed directories (${BRIDGE_ATTACHMENT_ALLOWED_ROOTS.join(", ")}): ${resolvedPath}`);
|
|
1129
1163
|
}
|
|
1130
1164
|
if (!existsSync(resolvedPath))
|
|
1131
1165
|
throw new Error(`Attachment file not found: ${resolvedPath}`);
|
|
@@ -1153,6 +1187,12 @@ export class FeishuBot {
|
|
|
1153
1187
|
isImagePath(filePath) {
|
|
1154
1188
|
return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".bmp", ".ico"].includes(extname(filePath).toLowerCase());
|
|
1155
1189
|
}
|
|
1190
|
+
errorSummary(err) {
|
|
1191
|
+
const data = err?.response?.data || err?.data;
|
|
1192
|
+
const code = data?.code ? `code=${data.code} ` : "";
|
|
1193
|
+
const msg = data?.msg || data?.message || err?.message || String(err);
|
|
1194
|
+
return `${code}${msg}`.slice(0, 800);
|
|
1195
|
+
}
|
|
1156
1196
|
async createFeishuDocFromMarkdown(filePath) {
|
|
1157
1197
|
const rawTitle = basename(filePath).replace(/\.[^.]+$/, "").trim() || "Markdown Document";
|
|
1158
1198
|
const markdown = readFileSync(filePath, "utf8");
|
|
@@ -1182,11 +1222,19 @@ export class FeishuBot {
|
|
|
1182
1222
|
const filePath = this.validateBridgeAttachmentPath(attachment.path);
|
|
1183
1223
|
const type = attachment.type || (this.isImagePath(filePath) ? "image" : "file");
|
|
1184
1224
|
if (type === "document" && extname(filePath).toLowerCase() === ".md") {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
${doc.url}`);
|
|
1189
|
-
|
|
1225
|
+
try {
|
|
1226
|
+
const doc = await this.createFeishuDocFromMarkdown(filePath);
|
|
1227
|
+
const caption = attachment.caption?.trim() || `飞书文档:${doc.title}`;
|
|
1228
|
+
await this.sendMessage(chatId, `${caption}\n${doc.url}`);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
catch (err) {
|
|
1232
|
+
console.warn(`[${this.config.name}] Feishu doc conversion failed, falling back to file attachment:`, this.errorSummary(err));
|
|
1233
|
+
const caption = attachment.caption?.trim();
|
|
1234
|
+
await this.sendMessage(chatId, `${caption ? `${caption}\n` : ""}飞书文档创建失败,已改为 Markdown 文件附件发送。`);
|
|
1235
|
+
await this.sendBridgeFileAttachment(chatId, filePath);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1190
1238
|
}
|
|
1191
1239
|
if (attachment.caption?.trim())
|
|
1192
1240
|
await this.sendMessage(chatId, attachment.caption.trim());
|
|
@@ -1209,6 +1257,9 @@ ${doc.url}`);
|
|
|
1209
1257
|
});
|
|
1210
1258
|
return;
|
|
1211
1259
|
}
|
|
1260
|
+
await this.sendBridgeFileAttachment(chatId, filePath);
|
|
1261
|
+
}
|
|
1262
|
+
async sendBridgeFileAttachment(chatId, filePath) {
|
|
1212
1263
|
const uploaded = await this.client.im.file.create({
|
|
1213
1264
|
data: {
|
|
1214
1265
|
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;
|
|
@@ -118,5 +119,6 @@ export declare class MessageStore {
|
|
|
118
119
|
* Mark a message as processed by a specific bot.
|
|
119
120
|
*/
|
|
120
121
|
markBotProcessed(botName: string, messageId: string): void;
|
|
122
|
+
tryMarkBotProcessed(botName: string, messageId: string): boolean;
|
|
121
123
|
close(): void;
|
|
122
124
|
}
|
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
|
|
@@ -607,6 +625,10 @@ export class MessageStore {
|
|
|
607
625
|
markBotProcessed(botName, messageId) {
|
|
608
626
|
this.db.prepare(`INSERT OR IGNORE INTO processed_events (bot_name, message_id) VALUES (?, ?)`).run(botName, messageId);
|
|
609
627
|
}
|
|
628
|
+
tryMarkBotProcessed(botName, messageId) {
|
|
629
|
+
const result = this.db.prepare(`INSERT OR IGNORE INTO processed_events (bot_name, message_id) VALUES (?, ?)`).run(botName, messageId);
|
|
630
|
+
return result.changes === 1;
|
|
631
|
+
}
|
|
610
632
|
close() {
|
|
611
633
|
this.db.close();
|
|
612
634
|
}
|
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
|
+
}
|