openclaw-lark-multi-agent 0.1.15 → 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/README.md CHANGED
@@ -32,10 +32,13 @@ All of them connect to the same OpenClaw Gateway while keeping sessions, queues,
32
32
  - Local SQLite message store for context, trigger tracking, and duplicate prevention
33
33
  - `pending_triggers` queue so restart recovery does not replay every context message
34
34
  - `delivered_replies` table so one trigger message gets at most one delivered reply per bot
35
+ - Durable delivery outbox for assistant-visible output, with stable delivery keys, atomic claim-before-send dispatch, and short-window duplicate suppression
36
+ - Message recall handling: recalled queued/pending user messages are removed before they reach OpenClaw and excluded from future context
35
37
  - Feishu image download and OpenClaw multimodal attachment forwarding
36
38
  - Bridge attachment marker protocol for generated files/images/documents
37
39
  - Feishu CardKit v2 Markdown rendering, including native table elements for pipe tables
38
40
  - Bridge-level slash commands and escaped OpenClaw slash commands
41
+ - `/discuss` mode for barrier-style multi-bot group discussion, including per-round markers and no-reply status notices
39
42
  - Linux systemd installer with separate runtime and state directories
40
43
 
41
44
  ## Architecture
@@ -228,6 +231,7 @@ Bridge-level commands use a single slash and are handled by this project:
228
231
  - `/free` — toggle this bot's Free mode in the current group chat
229
232
  - `/mute` — toggle this bot's mute mode in the current group chat
230
233
  - `/mode` — show this bot's current mode in the current chat
234
+ - `/discuss on|off|status|stop|rounds N` — control group-level multi-bot discussion mode
231
235
 
232
236
  OpenClaw-level slash commands can be sent by escaping with a double slash:
233
237
 
@@ -294,7 +298,54 @@ the mention-only message is treated as a trigger and is combined with the previo
294
298
 
295
299
  Bot messages do not trigger other bots unless they mention them. The anti-loop guard is counted per bot per chat: other bots' replies do not consume the current bot's streak budget, and a human message resets the streak.
296
300
 
297
- `/free` is not a full multi-agent discussion scheduler. Notes for a future explicit `/discuss` mode live in [`docs/ideas/discussion-mode.md`](docs/ideas/discussion-mode.md).
301
+ ### `/discuss` mode
302
+
303
+ `/discuss` is an explicit group-level multi-agent discussion scheduler. It is separate from Free mode:
304
+
305
+ - `/free` controls whether a single bot may answer plain human messages.
306
+ - `/discuss on` lets one coordinator take over plain human messages and run all Free-mode bots in barrier-style rounds.
307
+ - Targeted mentions still fall through to normal routing, so `@GPT hello` works even while discuss mode is enabled.
308
+ - Each participant receives the same round prompt and does not see other participants' replies from the current round until the next round.
309
+ - Each visible discussion reply is annotated with a round marker such as `—— 第 2/3 轮 · Claude`.
310
+ - If some participants return `NO_REPLY` or an empty reply, the coordinator sends a lightweight status notice such as `💬 第 3/3 轮:Qwen、Gemini 无新增回复`.
311
+ - When the configured maximum round count is reached, the coordinator sends a completion notice.
312
+
313
+ Commands:
314
+
315
+ ```text
316
+ /discuss on
317
+ /discuss off
318
+ /discuss status
319
+ /discuss stop
320
+ /discuss rounds 3
321
+ ```
322
+
323
+ ## Delivery outbox and duplicate prevention
324
+
325
+ All user-visible assistant outputs go through the local `delivery_outbox` before being sent to Feishu. This includes normal chat final replies, proactive `session.message` replies, delayed runtime-error notices, provider-error notices, discussion replies, and attachment marker deliveries.
326
+
327
+ The outbox provides several protections:
328
+
329
+ - stable trigger-based delivery keys such as `trigger:<message_row_id>`;
330
+ - `UNIQUE(bot_name, chat_id, delivery_key)` to prevent duplicate deliveries for the same logical output;
331
+ - `pending -> delivering -> delivered/failed` claim-before-send dispatch to avoid concurrent resend races;
332
+ - short-window content-hash dedupe for proactive-only outputs;
333
+ - short-window containment dedupe for cases where `chat final` contains an intermediate note plus final answer while proactive contains only the final answer;
334
+ - attachment-aware dedupe so file/image/document deliveries are not accidentally collapsed with text-only replies.
335
+
336
+ This keeps normal replies, subagent/proactive completions, discussion messages, delayed runtime failures, provider errors, and generated attachments on one consistent delivery path.
337
+
338
+ ## Message recall
339
+
340
+ The bridge subscribes to Feishu `im.message.recalled_v1` events. When a user recalls a message that is still pending or queued:
341
+
342
+ 1. the original message remains in the local `messages` ledger for audit;
343
+ 2. the message is recorded in `recalled_messages`;
344
+ 3. matching `pending_triggers` for each bot are removed;
345
+ 4. pending local reaction acknowledgements are removed;
346
+ 5. future context sync excludes the recalled message.
347
+
348
+ Version 1 behavior intentionally does **not** abort an OpenClaw run that has already started processing the recalled message, and it does not recall bot replies that were already sent. The first goal is to make recall reliable for queued/not-yet-processed messages.
298
349
 
299
350
 
300
351
  ## Markdown, tables, and attachments
@@ -316,6 +367,8 @@ SQLite state lives in the configured data directory. Important tables:
316
367
  - `sync_state` — per-bot/per-chat sync cursor
317
368
  - `pending_triggers` — messages that should actively trigger a bot run
318
369
  - `delivered_replies` — delivered response markers for idempotency
370
+ - `delivery_outbox` — durable user-visible delivery ledger with claim/dedupe state
371
+ - `recalled_messages` — recalled user messages excluded from pending work and future context
319
372
  - `processed_events` — Feishu event de-duplication
320
373
  - `bot_chat_settings` — per-bot/per-chat settings such as verbose mode
321
374
 
package/README.zh-CN.md CHANGED
@@ -32,10 +32,13 @@ Lark/飞书里每个机器人都有自己的 App 身份,但 OpenClaw 通常在
32
32
  - 本地 SQLite 消息存储,用于上下文、触发队列和重复投递防护
33
33
  - `pending_triggers` 队列,避免重启后把所有历史上下文都重新发给 OpenClaw
34
34
  - `delivered_replies` 表,保证同一个触发消息每个 bot 最多投递一次回复
35
+ - 持久化 delivery outbox:所有用户可见输出先入库,再通过稳定 key、原子 claim 和短窗口去重后投递
36
+ - 消息撤回处理:已撤回且仍在排队/待处理的用户消息会从 pending 队列移除,并从后续上下文中排除
35
37
  - 支持飞书图片下载,并以 OpenClaw multimodal attachment 形式转发
36
38
  - Bridge attachment marker 协议,用于发送生成的文件、图片和文档
37
39
  - Feishu CardKit v2 Markdown 渲染,并把 pipe table 转成原生 table 组件
38
40
  - 桥接层 slash command + 转义后的 OpenClaw slash command
41
+ - `/discuss` 多 bot 结构化讨论模式,支持轮次标注和无新增回复提示
39
42
  - Linux systemd 安装脚本,运行产物和状态目录分离
40
43
 
41
44
  ## 架构
@@ -228,6 +231,7 @@ openclaw-lark-multi-agent install-windows-service
228
231
  - `/free` — 开关当前 bot 在当前群聊里的 Free 模式
229
232
  - `/mute` — 开关当前 bot 在当前群聊里的 mute 模式
230
233
  - `/mode` — 查看当前 bot 在当前聊天里的模式
234
+ - `/discuss on|off|status|stop|rounds N` — 控制群级多 bot 讨论模式
231
235
 
232
236
  如果你想把 slash command 直接发给 OpenClaw,可以用双斜杠转义:
233
237
 
@@ -294,8 +298,55 @@ Free 模式是 per-bot 且保守的:
294
298
 
295
299
  bot 发出的消息默认不会触发其他 bot,除非明确 @。anti-loop 防护按 bot + chat 单独计算:其他 bot 的发言不会消耗当前 bot 的额度,人类发言会重置计数。
296
300
 
297
- `/free` 不是完整的多 Agent 讨论调度器。未来独立 `/discuss` 模式的设计草案在 [`docs/ideas/discussion-mode.md`](docs/ideas/discussion-mode.md)。
298
301
 
302
+ ## `/discuss` 讨论模式
303
+
304
+ `/discuss` 是显式的群级多智能体讨论调度器,和 Free 模式分工不同:
305
+
306
+ - `/free` 控制单个 bot 是否可以响应普通人类消息。
307
+ - `/discuss on` 让一个 coordinator 接管普通人类消息,并按 barrier-style round 调度所有 Free 模式 bot。
308
+ - 定向 @ 仍然走普通路由,所以 discuss 开启时 `@GPT hello` 仍会只触发 GPT。
309
+ - 每个参与 bot 在同一轮拿到相同 prompt,本轮内看不到其他 bot 的回复,下一轮才会看到上一轮结果。
310
+ - 每条可见讨论回复会自动追加轮次标注,例如 `—— 第 2/3 轮 · Claude`。
311
+ - 如果某些 bot 返回 `NO_REPLY` 或空回复,coordinator 会发送轻量提示,例如 `💬 第 3/3 轮:Qwen、Gemini 无新增回复`。
312
+ - 达到配置轮数后,coordinator 会发送讨论完成提示。
313
+
314
+ 命令:
315
+
316
+ ```text
317
+ /discuss on
318
+ /discuss off
319
+ /discuss status
320
+ /discuss stop
321
+ /discuss rounds 3
322
+ ```
323
+
324
+ ## Delivery outbox 与重复投递防护
325
+
326
+ 所有用户可见 assistant 输出都会先进入本地 `delivery_outbox`,再统一投递到飞书。覆盖普通 chat final、proactive `session.message`、延迟 runtime error、provider error、discussion 回复和附件 marker。
327
+
328
+ outbox 提供:
329
+
330
+ - 稳定 trigger key,例如 `trigger:<message_row_id>`;
331
+ - `UNIQUE(bot_name, chat_id, delivery_key)` 防止同一逻辑输出重复投递;
332
+ - `pending -> delivering -> delivered/failed` 原子 claim,避免并发重复发送;
333
+ - proactive-only 输出的短窗口 content hash 去重;
334
+ - chat final 包含“中间说明 + 最终答案”、proactive 只包含“最终答案”时的短窗口包含关系去重;
335
+ - 附件感知去重,避免把带文件/图片/文档的输出和纯文本误合并。
336
+
337
+ 这样普通回复、subagent/proactive 回传、讨论消息、延迟错误、provider 错误和生成附件都走同一条可靠投递链路。
338
+
339
+ ## 消息撤回
340
+
341
+ 桥接层订阅飞书 `im.message.recalled_v1` 事件。用户撤回一条仍在 pending/排队中的消息时:
342
+
343
+ 1. 原始消息仍保留在 `messages` 表,便于审计;
344
+ 2. 撤回记录写入 `recalled_messages`;
345
+ 3. 删除各 bot 对应的 `pending_triggers`;
346
+ 4. 移除本地 pending reaction ack;
347
+ 5. 后续同步上下文时排除这条撤回消息。
348
+
349
+ v1 行为边界:如果消息已经进入 OpenClaw 正在处理,暂不 abort;如果 bot 回复已经发出,也不自动撤回 bot 回复。第一版目标是可靠取消“尚未处理/排队中”的撤回消息。
299
350
 
300
351
  ## Markdown、表格和附件
301
352
 
@@ -31,9 +31,12 @@ 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);
39
+ private handleMessageRecalled;
37
40
  register(): void;
38
41
  /**
39
42
  * Get the session key for a specific chat.
@@ -95,8 +98,10 @@ export declare class FeishuBot {
95
98
  private validateBridgeAttachmentPath;
96
99
  private inferFeishuFileType;
97
100
  private isImagePath;
101
+ private errorSummary;
98
102
  private createFeishuDocFromMarkdown;
99
103
  private sendBridgeAttachment;
104
+ private sendBridgeFileAttachment;
100
105
  /**
101
106
  * Send a proactive message to a chat (not a reply).
102
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) {
@@ -56,8 +59,33 @@ export class FeishuBot {
56
59
  });
57
60
  this.eventDispatcher = new lark.EventDispatcher({}).register({
58
61
  "im.message.receive_v1": this.handleMessage.bind(this),
62
+ "im.message.recalled_v1": this.handleMessageRecalled.bind(this),
59
63
  });
60
64
  }
65
+ async handleMessageRecalled(data) {
66
+ console.log(`[${this.config.name}] Message recalled event:`, JSON.stringify(data));
67
+ const messageId = data?.message_id;
68
+ const chatId = data?.chat_id;
69
+ if (!messageId || !chatId)
70
+ return;
71
+ const rowId = this.store.getMessageId(messageId);
72
+ this.store.markMessageRecalled(messageId, chatId, Number(data?.recall_time) || Date.now(), data?.recall_type || '');
73
+ if (!rowId)
74
+ return;
75
+ this.store.clearPendingTrigger(this.config.name, chatId, rowId);
76
+ const pendingAcks = this.pendingAckMessages.get(chatId) || [];
77
+ const remainingAcks = [];
78
+ for (const ack of pendingAcks) {
79
+ if (ack.rowId === rowId) {
80
+ await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
81
+ }
82
+ else {
83
+ remainingAcks.push(ack);
84
+ }
85
+ }
86
+ this.pendingAckMessages.set(chatId, remainingAcks);
87
+ console.log(`[${this.config.name}] Recalled message ${messageId} row=${rowId}; pending trigger canceled if present`);
88
+ }
61
89
  register() {
62
90
  FeishuBot.allBots.set(this.config.appId, this);
63
91
  }
@@ -188,22 +216,26 @@ export class FeishuBot {
188
216
  async drainOnStartup() {
189
217
  try {
190
218
  const chats = this.store.getAllChatInfo();
219
+ const drainTasks = [];
191
220
  for (const chat of chats) {
192
221
  // Skip p2p chats that belong to other bots
193
222
  if (chat.chatType === "p2p" && chat.ownerBot && chat.ownerBot !== this.config.name) {
194
223
  continue;
195
224
  }
196
225
  // Re-subscribe to existing sessions
197
- const sessionKey = this.getSessionKey(chat.chatId);
198
226
  await this.ensureSession(chat.chatId);
199
227
  // Drain only messages that were explicitly marked as reply triggers.
200
228
  // Context-only messages should not start an OpenClaw run after restart.
201
229
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
202
230
  if (pendingTriggerIds.size > 0) {
203
231
  console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
204
- 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
+ }));
205
236
  }
206
237
  }
238
+ await Promise.allSettled(drainTasks);
207
239
  }
208
240
  catch (err) {
209
241
  console.warn(`[${this.config.name}] Startup drain failed:`, err.message);
@@ -598,10 +630,17 @@ export class FeishuBot {
598
630
  }
599
631
  async processQueueInner(chatId) {
600
632
  while (true) {
601
- 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);
602
641
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
603
- // Only proceed if there are unsynced human messages that should actively trigger this bot.
604
- // 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.
605
644
  const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
606
645
  if (humanUnsynced.length === 0) {
607
646
  break;
@@ -895,6 +934,11 @@ export class FeishuBot {
895
934
  this.delayedFailureTimers.delete(chatId);
896
935
  }
897
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
+ }
898
942
  this.cancelDelayedFailure(chatId);
899
943
  const timer = setTimeout(() => {
900
944
  this.delayedFailureTimers.delete(chatId);
@@ -951,6 +995,9 @@ export class FeishuBot {
951
995
  if (deliveryId === null)
952
996
  return;
953
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
+ }
954
1001
  }
955
1002
  async dispatchPendingDeliveries(chatId, replyToMessageId) {
956
1003
  const pending = this.store.getPendingDeliveries(chatId, this.config.name, 50);
@@ -984,6 +1031,17 @@ export class FeishuBot {
984
1031
  catch (err) {
985
1032
  if (item.id)
986
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
+ }
987
1045
  throw err;
988
1046
  }
989
1047
  });
@@ -1098,9 +1156,9 @@ export class FeishuBot {
1098
1156
  }
1099
1157
  validateBridgeAttachmentPath(filePath) {
1100
1158
  const resolvedPath = resolve(filePath);
1101
- const allowedRoot = resolve(BRIDGE_ATTACHMENTS_DIR);
1102
- if (!(resolvedPath === allowedRoot || resolvedPath.startsWith(allowedRoot + "/"))) {
1103
- 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}`);
1104
1162
  }
1105
1163
  if (!existsSync(resolvedPath))
1106
1164
  throw new Error(`Attachment file not found: ${resolvedPath}`);
@@ -1128,6 +1186,12 @@ export class FeishuBot {
1128
1186
  isImagePath(filePath) {
1129
1187
  return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".tiff", ".bmp", ".ico"].includes(extname(filePath).toLowerCase());
1130
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
+ }
1131
1195
  async createFeishuDocFromMarkdown(filePath) {
1132
1196
  const rawTitle = basename(filePath).replace(/\.[^.]+$/, "").trim() || "Markdown Document";
1133
1197
  const markdown = readFileSync(filePath, "utf8");
@@ -1157,11 +1221,19 @@ export class FeishuBot {
1157
1221
  const filePath = this.validateBridgeAttachmentPath(attachment.path);
1158
1222
  const type = attachment.type || (this.isImagePath(filePath) ? "image" : "file");
1159
1223
  if (type === "document" && extname(filePath).toLowerCase() === ".md") {
1160
- const doc = await this.createFeishuDocFromMarkdown(filePath);
1161
- const caption = attachment.caption?.trim() || `飞书文档:${doc.title}`;
1162
- await this.sendMessage(chatId, `${caption}
1163
- ${doc.url}`);
1164
- 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
+ }
1165
1237
  }
1166
1238
  if (attachment.caption?.trim())
1167
1239
  await this.sendMessage(chatId, attachment.caption.trim());
@@ -1184,6 +1256,9 @@ ${doc.url}`);
1184
1256
  });
1185
1257
  return;
1186
1258
  }
1259
+ await this.sendBridgeFileAttachment(chatId, filePath);
1260
+ }
1261
+ async sendBridgeFileAttachment(chatId, filePath) {
1187
1262
  const uploaded = await this.client.im.file.create({
1188
1263
  data: {
1189
1264
  file_type: this.inferFeishuFileType(filePath),
@@ -51,8 +51,11 @@ export declare class MessageStore {
51
51
  */
52
52
  insert(msg: ChatMessage): number;
53
53
  getMessageId(messageId: string): number | null;
54
+ markMessageRecalled(messageId: string, chatId: string, recalledAt?: number, recallType?: string): void;
55
+ isMessageRecalled(messageId: string): boolean;
54
56
  markPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
55
57
  getPendingTriggerIds(botName: string, chatId: string): Set<number>;
58
+ getPendingTriggerMessages(botName: string, chatId: string): ChatMessage[];
56
59
  clearPendingTriggers(botName: string, chatId: string, upToId: number): void;
57
60
  clearPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
58
61
  hasDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number): boolean;
@@ -51,6 +51,16 @@ export class MessageStore {
51
51
  PRIMARY KEY (bot_name, message_id)
52
52
  );
53
53
 
54
+ -- Tracks recalled human messages. Recalled messages are kept in the raw
55
+ -- message ledger for audit, but excluded from future OpenClaw context and
56
+ -- pending trigger processing.
57
+ CREATE TABLE IF NOT EXISTS recalled_messages (
58
+ message_id TEXT PRIMARY KEY,
59
+ chat_id TEXT NOT NULL,
60
+ recalled_at INTEGER NOT NULL,
61
+ recall_type TEXT NOT NULL DEFAULT ''
62
+ );
63
+
54
64
  -- Tracks messages that should actively trigger a bot reply.
55
65
  -- Other unsynced messages remain local context and are sent only when a trigger arrives.
56
66
  CREATE TABLE IF NOT EXISTS pending_triggers (
@@ -225,6 +235,17 @@ export class MessageStore {
225
235
  const row = this.db.prepare(`SELECT id FROM messages WHERE message_id = ?`).get(messageId);
226
236
  return row?.id || null;
227
237
  }
238
+ markMessageRecalled(messageId, chatId, recalledAt = Date.now(), recallType = '') {
239
+ this.db.prepare(`
240
+ INSERT INTO recalled_messages (message_id, chat_id, recalled_at, recall_type)
241
+ VALUES (?, ?, ?, ?)
242
+ ON CONFLICT(message_id) DO UPDATE SET chat_id = excluded.chat_id, recalled_at = excluded.recalled_at, recall_type = excluded.recall_type
243
+ `).run(messageId, chatId, recalledAt, recallType || '');
244
+ }
245
+ isMessageRecalled(messageId) {
246
+ const row = this.db.prepare(`SELECT 1 FROM recalled_messages WHERE message_id = ?`).get(messageId);
247
+ return !!row;
248
+ }
228
249
  markPendingTrigger(botName, chatId, messageRowId) {
229
250
  this.db.prepare(`
230
251
  INSERT OR IGNORE INTO pending_triggers (bot_name, chat_id, message_row_id)
@@ -239,6 +260,24 @@ export class MessageStore {
239
260
  `).all(botName, chatId);
240
261
  return new Set(rows.map((r) => Number(r.message_row_id)));
241
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
+ }
242
281
  clearPendingTriggers(botName, chatId, upToId) {
243
282
  this.db.prepare(`
244
283
  DELETE FROM pending_triggers
@@ -373,9 +412,10 @@ export class MessageStore {
373
412
  `).get(botName, chatId);
374
413
  const lastId = row?.last_synced_msg_id || 0;
375
414
  const rows = this.db.prepare(`
376
- SELECT * FROM messages
377
- WHERE chat_id = ? AND id > ?
378
- ORDER BY timestamp ASC
415
+ SELECT messages.* FROM messages
416
+ LEFT JOIN recalled_messages ON recalled_messages.message_id = messages.message_id
417
+ WHERE messages.chat_id = ? AND messages.id > ? AND recalled_messages.message_id IS NULL
418
+ ORDER BY messages.timestamp ASC
379
419
  LIMIT ?
380
420
  `).all(chatId, lastId, maxCount);
381
421
  return rows.map((r) => ({
@@ -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": "0.1.15",
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": {