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.
@@ -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
  */
@@ -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
- await this.processQueue(chat.chatId);
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: skip if this bot already processed this message ---
276
- if (this.store.hasBotProcessed(this.config.name, messageId))
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 allUnsynced = this.store.getUnsyncedMessages(this.config.name, chatId);
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 unsynced human messages that should actively trigger this bot.
629
- // Other unsynced messages remain as context for the next trigger.
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 allowedRoot = resolve(BRIDGE_ATTACHMENTS_DIR);
1127
- if (!(resolvedPath === allowedRoot || resolvedPath.startsWith(allowedRoot + "/"))) {
1128
- throw new Error(`Attachment path outside allowed directory: ${resolvedPath}`);
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
- const doc = await this.createFeishuDocFromMarkdown(filePath);
1186
- const caption = attachment.caption?.trim() || `飞书文档:${doc.title}`;
1187
- await this.sendMessage(chatId, `${caption}
1188
- ${doc.url}`);
1189
- return;
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),
@@ -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
  }
@@ -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
  }
@@ -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, 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.]`;
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
@@ -4,3 +4,5 @@
4
4
  */
5
5
  export declare function getStateDir(): string;
6
6
  export declare function getBridgeAttachmentsDir(): string;
7
+ export declare function getOpenClawWorkspaceDir(): string;
8
+ export declare function getBridgeAttachmentAllowedRoots(): string[];
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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": {