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.
@@ -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);
@@ -623,10 +630,17 @@ export class FeishuBot {
623
630
  }
624
631
  async processQueueInner(chatId) {
625
632
  while (true) {
626
- const allUnsynced = this.store.getUnsyncedMessages(this.config.name, chatId);
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 unsynced human messages that should actively trigger this bot.
629
- // Other unsynced messages remain as context for the next trigger.
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 allowedRoot = resolve(BRIDGE_ATTACHMENTS_DIR);
1127
- if (!(resolvedPath === allowedRoot || resolvedPath.startsWith(allowedRoot + "/"))) {
1128
- throw new Error(`Attachment path outside allowed directory: ${resolvedPath}`);
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
- 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;
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),
@@ -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;
@@ -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
@@ -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.1",
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": {