openclaw-lark-multi-agent 0.1.10 → 0.1.12

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
@@ -25,11 +25,16 @@ All of them connect to the same OpenClaw Gateway while keeping sessions, queues,
25
25
  - Group chat routing:
26
26
  - reply when directly mentioned
27
27
  - reply to `@all` / `@_all`
28
- - optional Free Discussion mode
28
+ - optional per-bot Free mode for plain human messages
29
+ - targeted mentions are exclusive, so Free-mode bots do not steal messages addressed to another person or bot
30
+ - mention-only messages can trigger a bot with the previous unsynced context
31
+ - Per-bot anti-loop guard, so one bot's free-discussion budget is not consumed by other bots
29
32
  - Local SQLite message store for context, trigger tracking, and duplicate prevention
30
33
  - `pending_triggers` queue so restart recovery does not replay every context message
31
34
  - `delivered_replies` table so one trigger message gets at most one delivered reply per bot
32
35
  - Feishu image download and OpenClaw multimodal attachment forwarding
36
+ - Bridge attachment marker protocol for generated files/images/documents
37
+ - Feishu CardKit v2 Markdown rendering, including native table elements for pipe tables
33
38
  - Bridge-level slash commands and escaped OpenClaw slash commands
34
39
  - Linux systemd installer with separate runtime and state directories
35
40
 
@@ -220,7 +225,9 @@ Bridge-level commands use a single slash and are handled by this project:
220
225
  - `/compact` — compact the OpenClaw session
221
226
  - `/reset` — reset the OpenClaw session
222
227
  - `/verbose` — toggle tool-call messages for this bot in this chat
223
- - `/free` — toggle Free Discussion mode in group chats
228
+ - `/free` — toggle this bot's Free mode in the current group chat
229
+ - `/mute` — toggle this bot's mute mode in the current group chat
230
+ - `/mode` — show this bot's current mode in the current chat
224
231
 
225
232
  OpenClaw-level slash commands can be sent by escaping with a double slash:
226
233
 
@@ -267,9 +274,39 @@ By default, a bot responds when:
267
274
 
268
275
  - it is directly mentioned;
269
276
  - `@all` / `@_all` appears in the message;
270
- - Free Discussion is enabled for that group.
277
+ - this bot's Free mode is enabled and the message is a plain human message with no targeted mentions.
271
278
 
272
- Bot messages do not trigger other bots unless they mention them. A bot-streak guard prevents infinite bot-to-bot loops.
279
+ Free mode is intentionally per-bot and conservative:
280
+
281
+ - Free mode lets a bot reply to ordinary human messages without being mentioned.
282
+ - If a human message mentions another bot, only that bot may respond. Other Free-mode bots stay silent.
283
+ - If a human message mentions a regular person, Free-mode bots stay silent.
284
+ - `@all` remains a broadcast trigger and may activate every eligible bot.
285
+
286
+ Mention-only routing is supported. If a user first sends content and then sends only a bot mention, for example:
287
+
288
+ ```text
289
+ Please analyze the contract risk.
290
+ @Claude
291
+ ```
292
+
293
+ the mention-only message is treated as a trigger and is combined with the previous unsynced context before being sent to the mentioned bot.
294
+
295
+ 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
+
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).
298
+
299
+
300
+ ## Markdown, tables, and attachments
301
+
302
+ Assistant replies are sent as Feishu CardKit v2 cards. Markdown is preprocessed for Feishu rendering:
303
+
304
+ - headings are downgraded to Feishu-friendly heading levels;
305
+ - fenced code blocks are preserved;
306
+ - unsupported external Markdown image URLs are stripped unless already resolved to Feishu `img_` keys;
307
+ - GitHub-style pipe tables are converted into native CardKit `table` elements.
308
+
309
+ For generated files/images/documents, agents should use the bridge attachment marker protocol instead of calling Feishu messaging tools directly. The bridge strips the marker from the visible reply, validates the file path under the configured attachment directory, uploads/sends the attachment, and records it in local context. Markdown documents can be converted into Feishu cloud documents through this path.
273
310
 
274
311
  ## Data model
275
312
 
package/README.zh-CN.md CHANGED
@@ -25,11 +25,16 @@ Lark/飞书里每个机器人都有自己的 App 身份,但 OpenClaw 通常在
25
25
  - 群聊路由:
26
26
  - 直接 @ 某个 bot 时回复
27
27
  - `@all` / `@_all` 时回复
28
- - 可选 Free Discussion 模式
28
+ - 可选的 per-bot Free 模式,用于普通人类消息
29
+ - 定向 @ 具有排他性,Free 模式 bot 不会抢答发给其他人或其他 bot 的消息
30
+ - 纯 @ 某个 bot 的消息可以结合前文未同步上下文触发回复
31
+ - per-bot anti-loop 防护,其他 bot 的发言不会消耗当前 bot 的发言额度
29
32
  - 本地 SQLite 消息存储,用于上下文、触发队列和重复投递防护
30
33
  - `pending_triggers` 队列,避免重启后把所有历史上下文都重新发给 OpenClaw
31
34
  - `delivered_replies` 表,保证同一个触发消息每个 bot 最多投递一次回复
32
35
  - 支持飞书图片下载,并以 OpenClaw multimodal attachment 形式转发
36
+ - Bridge attachment marker 协议,用于发送生成的文件、图片和文档
37
+ - Feishu CardKit v2 Markdown 渲染,并把 pipe table 转成原生 table 组件
33
38
  - 桥接层 slash command + 转义后的 OpenClaw slash command
34
39
  - Linux systemd 安装脚本,运行产物和状态目录分离
35
40
 
@@ -220,7 +225,9 @@ openclaw-lark-multi-agent install-windows-service
220
225
  - `/compact` — 压缩 OpenClaw session
221
226
  - `/reset` — 重置 OpenClaw session
222
227
  - `/verbose` — 开关当前 bot 在当前聊天里的 tool-call 展示
223
- - `/free` — 开关群聊 Free Discussion 模式
228
+ - `/free` — 开关当前 bot 在当前群聊里的 Free 模式
229
+ - `/mute` — 开关当前 bot 在当前群聊里的 mute 模式
230
+ - `/mode` — 查看当前 bot 在当前聊天里的模式
224
231
 
225
232
  如果你想把 slash command 直接发给 OpenClaw,可以用双斜杠转义:
226
233
 
@@ -267,9 +274,39 @@ openclaw-lark-multi-agent install-windows-service
267
274
 
268
275
  - 被直接 @;
269
276
  - 消息里出现 `@all` / `@_all`;
270
- - 当前群开启了 Free Discussion 模式。
277
+ - 当前 bot 开启了 Free 模式,并且这是一条没有定向 @ 的普通人类消息。
271
278
 
272
- bot 发出的消息默认不会触发其他 bot,除非明确 @。同时有 bot-streak 防护,避免 bot 之间无限互相回复。
279
+ Free 模式是 per-bot 且保守的:
280
+
281
+ - Free 模式允许 bot 在没有被 @ 时回复普通人类消息。
282
+ - 如果人类消息 @ 了另一个 bot,只有被 @ 的 bot 可以回复,其他 Free 模式 bot 保持静默。
283
+ - 如果人类消息 @ 了普通人,Free 模式 bot 保持静默。
284
+ - `@all` 仍然是广播触发,可激活所有符合条件的 bot。
285
+
286
+ 支持“纯 @ 触发”。例如用户先发:
287
+
288
+ ```text
289
+ 帮我分析一下这个合同风险。
290
+ @Claude
291
+ ```
292
+
293
+ 第二条纯 @ 消息会被当作触发器,并和前面未同步的上下文一起发给被 @ 的 bot。
294
+
295
+ bot 发出的消息默认不会触发其他 bot,除非明确 @。anti-loop 防护按 bot + chat 单独计算:其他 bot 的发言不会消耗当前 bot 的额度,人类发言会重置计数。
296
+
297
+ `/free` 不是完整的多 Agent 讨论调度器。未来独立 `/discuss` 模式的设计草案在 [`docs/ideas/discussion-mode.md`](docs/ideas/discussion-mode.md)。
298
+
299
+
300
+ ## Markdown、表格和附件
301
+
302
+ 助手回复会以 Feishu CardKit v2 卡片发送。Markdown 会先做飞书兼容预处理:
303
+
304
+ - 标题会降级到飞书更稳定的标题层级;
305
+ - fenced code block 会被保护;
306
+ - 未解析成飞书 `img_` key 的外部 Markdown 图片会被剥离,避免卡片发送失败;
307
+ - GitHub 风格 pipe table 会转换成 CardKit 原生 `table` 组件。
308
+
309
+ 如果 Agent 需要发送生成的文件、图片或文档,应使用 bridge attachment marker 协议,而不是直接调用飞书消息工具。桥接层会从可见回复中剥离 marker,校验文件路径位于配置的附件目录下,然后上传/发送附件,并写入本地上下文。Markdown 文档也可以通过这个路径转换成飞书云文档。
273
310
 
274
311
  ## 数据模型
275
312
 
@@ -0,0 +1,45 @@
1
+ export type ReplyResult = {
2
+ botName: string;
3
+ text: string;
4
+ visible: boolean;
5
+ error?: string;
6
+ };
7
+ type DiscussionSession = {
8
+ id: string;
9
+ chatId: string;
10
+ rootMessageId: string;
11
+ topic: string;
12
+ participants: string[];
13
+ currentRound: number;
14
+ maxRounds: number;
15
+ completedRounds: Array<{
16
+ round: number;
17
+ replies: Record<string, string>;
18
+ }>;
19
+ status: "running" | "stopped" | "completed";
20
+ };
21
+ export type DiscussionParticipant = {
22
+ name: string;
23
+ runDiscussionTurn(chatId: string, prompt: string): Promise<ReplyResult>;
24
+ };
25
+ export declare class DiscussionManager {
26
+ private sessions;
27
+ private seenRoots;
28
+ private readonly seenRootTtlMs;
29
+ isActive(chatId: string): boolean;
30
+ stop(chatId: string): boolean;
31
+ status(chatId: string): DiscussionSession | null;
32
+ startIfAbsent(params: {
33
+ chatId: string;
34
+ rootMessageId: string;
35
+ topic: string;
36
+ maxRounds: number;
37
+ participants: DiscussionParticipant[];
38
+ sendSystemMessage?: (text: string) => Promise<void>;
39
+ }): boolean;
40
+ private pruneSeenRoots;
41
+ private runLoop;
42
+ private buildPrompt;
43
+ }
44
+ export declare const discussionManager: DiscussionManager;
45
+ export {};
@@ -0,0 +1,144 @@
1
+ import { randomUUID } from "crypto";
2
+ export class DiscussionManager {
3
+ sessions = new Map();
4
+ seenRoots = new Map();
5
+ seenRootTtlMs = 6 * 60 * 60 * 1000;
6
+ isActive(chatId) {
7
+ return this.sessions.get(chatId)?.status === "running";
8
+ }
9
+ stop(chatId) {
10
+ const session = this.sessions.get(chatId);
11
+ if (!session)
12
+ return false;
13
+ session.status = "stopped";
14
+ this.sessions.delete(chatId);
15
+ return true;
16
+ }
17
+ status(chatId) {
18
+ const session = this.sessions.get(chatId);
19
+ return session ? { ...session, completedRounds: [...session.completedRounds] } : null;
20
+ }
21
+ startIfAbsent(params) {
22
+ this.pruneSeenRoots();
23
+ const key = `${params.chatId}:${params.rootMessageId}`;
24
+ if (this.seenRoots.has(key))
25
+ return false;
26
+ this.seenRoots.set(key, Date.now());
27
+ if (this.isActive(params.chatId))
28
+ this.stop(params.chatId);
29
+ const participants = params.participants.filter((p, index, arr) => arr.findIndex((x) => x.name === p.name) === index);
30
+ if (participants.length === 0) {
31
+ this.seenRoots.delete(key);
32
+ return false;
33
+ }
34
+ const session = {
35
+ id: randomUUID(),
36
+ chatId: params.chatId,
37
+ rootMessageId: params.rootMessageId,
38
+ topic: params.topic,
39
+ participants: participants.map((p) => p.name),
40
+ currentRound: 1,
41
+ maxRounds: params.maxRounds,
42
+ completedRounds: [],
43
+ status: "running",
44
+ };
45
+ this.sessions.set(params.chatId, session);
46
+ void this.runLoop(session.id, participants, params.sendSystemMessage).catch((err) => {
47
+ console.warn(`[Discussion] loop failed for ${params.chatId}:`, err instanceof Error ? err.message : String(err));
48
+ const current = this.sessions.get(params.chatId);
49
+ if (current?.id === session.id)
50
+ this.sessions.delete(params.chatId);
51
+ });
52
+ return true;
53
+ }
54
+ pruneSeenRoots(now = Date.now()) {
55
+ for (const [key, ts] of this.seenRoots) {
56
+ if (now - ts > this.seenRootTtlMs)
57
+ this.seenRoots.delete(key);
58
+ }
59
+ }
60
+ async runLoop(sessionId, participants, sendSystemMessage) {
61
+ while (true) {
62
+ const session = Array.from(this.sessions.values()).find((s) => s.id === sessionId);
63
+ if (!session || session.status !== "running")
64
+ return;
65
+ if (session.currentRound > session.maxRounds) {
66
+ session.status = "completed";
67
+ this.sessions.delete(session.chatId);
68
+ return;
69
+ }
70
+ const prompt = this.buildPrompt(session);
71
+ const results = await Promise.allSettled(participants.map(async (participant) => {
72
+ try {
73
+ return await participant.runDiscussionTurn(session.chatId, prompt);
74
+ }
75
+ catch (err) {
76
+ return {
77
+ botName: participant.name,
78
+ text: "",
79
+ visible: false,
80
+ error: err instanceof Error ? err.message : String(err),
81
+ };
82
+ }
83
+ }));
84
+ const current = this.sessions.get(session.chatId);
85
+ if (!current || current.id !== sessionId || current.status !== "running")
86
+ return;
87
+ const replies = {};
88
+ for (const result of results) {
89
+ const value = result.status === "fulfilled" ? result.value : undefined;
90
+ if (!value)
91
+ continue;
92
+ replies[value.botName] = value.error ? `[ERROR] ${value.error}` : value.text.trim();
93
+ }
94
+ current.completedRounds.push({ round: current.currentRound, replies });
95
+ const allNoReply = participants.every((participant) => {
96
+ const text = (replies[participant.name] || "").trim();
97
+ return !text || text.toUpperCase() === "NO_REPLY" || text.startsWith("[ERROR]");
98
+ });
99
+ if (allNoReply) {
100
+ current.status = "completed";
101
+ this.sessions.delete(current.chatId);
102
+ if (sendSystemMessage)
103
+ await sendSystemMessage(`💬 Discuss 已结束:第 ${current.currentRound} 轮没有新的有效补充。`).catch(() => { });
104
+ return;
105
+ }
106
+ if (current.currentRound >= current.maxRounds) {
107
+ current.status = "completed";
108
+ this.sessions.delete(current.chatId);
109
+ if (sendSystemMessage)
110
+ await sendSystemMessage(`💬 Discuss 已完成:已达到 ${current.maxRounds} 轮。`).catch(() => { });
111
+ return;
112
+ }
113
+ current.currentRound += 1;
114
+ }
115
+ }
116
+ buildPrompt(session) {
117
+ const previous = session.completedRounds.length === 0
118
+ ? "(暂无,当前是第一轮)"
119
+ : session.completedRounds.map((round) => {
120
+ const lines = Object.entries(round.replies).map(([bot, text]) => `- ${bot}: ${text || "NO_REPLY"}`);
121
+ return `Round ${round.round}:\n${lines.join("\n")}`;
122
+ }).join("\n\n");
123
+ return [
124
+ "这是一个多智能体结构化讨论。",
125
+ "",
126
+ "话题:",
127
+ session.topic,
128
+ "",
129
+ `当前轮次:${session.currentRound}`,
130
+ "",
131
+ "已完成的轮次:",
132
+ previous,
133
+ "",
134
+ "本轮其他 bot 的回复你暂时看不到,请基于同一份上下文独立给出观点。",
135
+ "",
136
+ "要求:",
137
+ "1. 不要重复前几轮已经说过的观点。",
138
+ "2. 只补充新的、有价值的信息。",
139
+ "3. 如果没有新东西,回复 NO_REPLY。",
140
+ "4. 简洁作答。",
141
+ ].join("\n");
142
+ }
143
+ }
144
+ export const discussionManager = new DiscussionManager();
@@ -21,7 +21,7 @@ export declare class FeishuBot {
21
21
  private initializedSessions;
22
22
  /** Per-chat busy lock: timestamp when became busy (0 = not busy) */
23
23
  private busyChats;
24
- /** Per-chat pending reply message IDs (to ack with DONE when reply arrives) */
24
+ /** Per-chat pending reply message IDs (to ack with DONE when their trigger is processed) */
25
25
  private pendingAckMessages;
26
26
  /** Per-chat pending tool message sends (to await before final reply) */
27
27
  private pendingToolSends;
@@ -56,6 +56,7 @@ export declare class FeishuBot {
56
56
  */
57
57
  private drainOnStartup;
58
58
  static getAllBots(): Map<string, FeishuBot>;
59
+ static getByName(name: string): FeishuBot | undefined;
59
60
  static findByOpenId(openId: string): FeishuBot | undefined;
60
61
  private handleMessage;
61
62
  /**
@@ -75,6 +76,10 @@ export declare class FeishuBot {
75
76
  private cleanMentions;
76
77
  private stripLeadingCommandMentions;
77
78
  private buildMarkdownCard;
79
+ private formatUserVisibleError;
80
+ private isDiscussionCoordinator;
81
+ private getDiscussionParticipants;
82
+ private runDiscussionTurn;
78
83
  private replyMessage;
79
84
  private extractBridgeAttachments;
80
85
  private validateBridgeAttachmentPath;
@@ -100,6 +105,7 @@ export declare class FeishuBot {
100
105
  * Finds the bot's own reaction of that type and deletes it.
101
106
  */
102
107
  private removeReaction;
108
+ private handleDiscussCommand;
103
109
  /**
104
110
  * Handle /status command: show current session info.
105
111
  */
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, statSync } from "fs";
3
3
  import { basename, extname, resolve } from "path";
4
4
  import { getBridgeAttachmentsDir } from "./paths.js";
5
5
  import { buildFeishuCardElements } from "./markdown.js";
6
+ import { discussionManager } from "./discussion-manager.js";
6
7
  const MAX_BOT_STREAK = 10;
7
8
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
8
9
  /**
@@ -25,7 +26,7 @@ export class FeishuBot {
25
26
  initializedSessions = new Set();
26
27
  /** Per-chat busy lock: timestamp when became busy (0 = not busy) */
27
28
  busyChats = new Map();
28
- /** Per-chat pending reply message IDs (to ack with DONE when reply arrives) */
29
+ /** Per-chat pending reply message IDs (to ack with DONE when their trigger is processed) */
29
30
  pendingAckMessages = new Map();
30
31
  /** Per-chat pending tool message sends (to await before final reply) */
31
32
  pendingToolSends = new Map();
@@ -211,6 +212,13 @@ export class FeishuBot {
211
212
  static getAllBots() {
212
213
  return FeishuBot.allBots;
213
214
  }
215
+ static getByName(name) {
216
+ for (const bot of FeishuBot.allBots.values()) {
217
+ if (bot.config.name === name)
218
+ return bot;
219
+ }
220
+ return undefined;
221
+ }
214
222
  static findByOpenId(openId) {
215
223
  for (const bot of FeishuBot.allBots.values()) {
216
224
  if (bot.botOpenId === openId)
@@ -351,17 +359,23 @@ export class FeishuBot {
351
359
  // Single slash commands are handled by the bridge. Double slash commands were
352
360
  // already unescaped above and should pass through to OpenClaw instead.
353
361
  const isBridgeCommand = !commandText.startsWith("//");
354
- const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode)/.test(cleanText.trim());
362
+ const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode|discuss)/.test(cleanText.trim());
355
363
  if (isCommand) {
356
- // In group chats, bridge commands must be explicitly routed to this bot
357
- // or @all. Do not let Free Discussion make a bot execute commands meant
358
- // for another bot.
359
- if (chatType !== "p2p" && !this.shouldHandleBridgeCommand(chatType, message, isBot, message.content))
360
- return;
364
+ // In group chats, most bridge commands must be explicitly routed to this
365
+ // bot or @all. /discuss is a group-level command, so an unmentioned
366
+ // /discuss command is handled by one coordinator bot to avoid N replies.
367
+ const isDiscussCommand = cleanText.trim().startsWith("/discuss");
368
+ if (chatType !== "p2p" && !this.shouldHandleBridgeCommand(chatType, message, isBot, message.content)) {
369
+ if (!(isDiscussCommand && this.isDiscussionCoordinator()))
370
+ return;
371
+ }
361
372
  const markCommandSynced = () => {
362
373
  if (insertedId > 0) {
363
374
  this.store.markSynced(this.config.name, chatId, insertedId);
364
- this.store.clearPendingTriggers(this.config.name, chatId, insertedId);
375
+ // Bridge commands should only clear their own pending row. They
376
+ // must not clear earlier human triggers that are waiting after a
377
+ // failed/empty run; /status must be safe to use for debugging.
378
+ this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
365
379
  }
366
380
  };
367
381
  if (cleanText.trim().startsWith("/help")) {
@@ -376,6 +390,7 @@ export class FeishuBot {
376
390
  `🔓 /free — 切换当前 bot 的 free 模式(不 @ 也可回复)`,
377
391
  `🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
378
392
  `🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
393
+ `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
379
394
  `❓ /help — 显示此帮助信息`,
380
395
  ``,
381
396
  `OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
@@ -442,7 +457,7 @@ export class FeishuBot {
442
457
  const next = current === "free" ? "normal" : "free";
443
458
  this.store.setBotMode(this.config.name, chatId, next);
444
459
  if (next === "free") {
445
- await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式\n不需要 @ 也可以参与回复(连续 Bot 回复超过 ${MAX_BOT_STREAK} 轮将暂停,等待人类发言)`);
460
+ await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式\n不需要 @ 也可以回复普通人类消息;如果消息明确 @ 了其他 bot 或普通人,我不会抢答。\n如需多轮自动讨论,请使用群级命令 /discuss on。`);
446
461
  }
447
462
  else {
448
463
  await this.replyMessage(messageId, `🔒 ${this.config.name} 已切换到 normal 模式\n只有明确 @ 我才会回复`);
@@ -480,6 +495,33 @@ export class FeishuBot {
480
495
  markCommandSynced();
481
496
  return;
482
497
  }
498
+ if (cleanText.trim().startsWith("/discuss")) {
499
+ await this.handleDiscussCommand(chatId, chatType, messageId, cleanText.trim());
500
+ markCommandSynced();
501
+ return;
502
+ }
503
+ }
504
+ // --- Discuss mode: group-level multi-bot round scheduler. It takes over
505
+ // plain human messages so normal Free mode does not duplicate Round 1.
506
+ if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
507
+ const mentions = message.mentions || [];
508
+ const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
509
+ if (!hasTargetedMention) {
510
+ const participants = this.getDiscussionParticipants(chatId);
511
+ if (participants.length > 0) {
512
+ discussionManager.startIfAbsent({
513
+ chatId,
514
+ rootMessageId: messageId,
515
+ topic: cleanText,
516
+ maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
517
+ participants,
518
+ sendSystemMessage: async (text) => { await this.sendMessage(chatId, text); },
519
+ });
520
+ }
521
+ if (insertedId > 0)
522
+ this.store.markSynced(this.config.name, chatId, insertedId);
523
+ return;
524
+ }
483
525
  }
484
526
  // --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
485
527
  if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
@@ -487,7 +529,7 @@ export class FeishuBot {
487
529
  await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
488
530
  if (insertedId > 0) {
489
531
  this.store.markSynced(this.config.name, chatId, insertedId);
490
- this.store.clearPendingTriggers(this.config.name, chatId, insertedId);
532
+ this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
491
533
  }
492
534
  }
493
535
  return;
@@ -513,7 +555,7 @@ export class FeishuBot {
513
555
  if (isBusy) {
514
556
  // Queued: show waiting reaction
515
557
  await this.addReaction(messageId, "Typing").catch(() => { });
516
- pending.push({ messageId, emoji: "Typing" });
558
+ pending.push({ messageId, emoji: "Typing", rowId: insertedId });
517
559
  this.pendingAckMessages.set(chatId, pending);
518
560
  console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, queuing: "${cleanText.substring(0, 50)}..."`);
519
561
  return; // Message is in DB, will be picked up when agent finishes
@@ -528,7 +570,7 @@ export class FeishuBot {
528
570
  // --- Not busy, send now (with any accumulated messages) ---
529
571
  // Acknowledge receipt: sent to OpenClaw (GET/了解)
530
572
  await this.addReaction(messageId, "Get").catch(() => { });
531
- pending.push({ messageId, emoji: "Get" });
573
+ pending.push({ messageId, emoji: "Get", rowId: insertedId });
532
574
  this.pendingAckMessages.set(chatId, pending);
533
575
  await this.processQueue(chatId);
534
576
  }
@@ -593,18 +635,32 @@ export class FeishuBot {
593
635
  deliver: false,
594
636
  // Keep bridge UX responsive; long agent/tool loops should surface a clear failure
595
637
  // instead of leaving reactions stuck forever.
596
- timeoutMs: 600000,
638
+ timeoutMs: 1_800_000,
597
639
  });
598
640
  console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
599
- // Mark everything up to now as synced
600
- const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
601
- this.store.markSynced(this.config.name, chatId, maxId);
602
- this.store.clearPendingTriggers(this.config.name, chatId, maxId);
603
641
  const parsedReply = this.extractBridgeAttachments(reply);
604
642
  const visibleReply = parsedReply.text;
605
643
  const trimmedReply = visibleReply.trim();
606
- const shouldReply = trimmedReply.length > 0 && trimmedReply.toUpperCase() !== "NO_REPLY";
607
644
  const hasAttachments = parsedReply.attachments.length > 0;
645
+ const explicitNoReply = trimmedReply.toUpperCase() === "NO_REPLY";
646
+ const trulyEmptyReply = trimmedReply.length === 0 && !hasAttachments;
647
+ if (trulyEmptyReply) {
648
+ // Empty final text is not the same as an explicit NO_REPLY. It often
649
+ // means the upstream session/run was interrupted, raced, or collected
650
+ // incorrectly. Do not mark sync, clear pending triggers, or mark DONE.
651
+ // Leave the trigger pending for a later retry/new message.
652
+ console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; keeping pending for retry`);
653
+ break;
654
+ }
655
+ // Mark only the snapshot processed in this run as synced. Messages that
656
+ // arrive while the agent is busy may already be in pendingAckMessages,
657
+ // but they are not part of allUnsynced/humanUnsynced for this run and
658
+ // must remain pending for the next loop.
659
+ const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
660
+ const processedTriggerIds = new Set(humanUnsynced.map((m) => m.id || 0).filter(Boolean));
661
+ this.store.markSynced(this.config.name, chatId, maxId);
662
+ this.store.clearPendingTriggers(this.config.name, chatId, maxId);
663
+ const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
608
664
  // Record bot reply only if it is user-visible/context-worthy.
609
665
  if (shouldReply || hasAttachments) {
610
666
  const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
@@ -618,8 +674,15 @@ export class FeishuBot {
618
674
  content: storedContent,
619
675
  timestamp: Date.now(),
620
676
  });
621
- if (replyId > 0)
622
- this.store.markSynced(this.config.name, chatId, replyId);
677
+ if (replyId > 0) {
678
+ const remainingPending = this.store.getPendingTriggerIds(this.config.name, chatId);
679
+ const hasEarlierPending = Array.from(remainingPending).some((id) => id <= replyId);
680
+ // Do not advance sync past a human message that arrived while this
681
+ // run was busy. Otherwise the pending trigger remains in the table
682
+ // but getUnsyncedMessages() can no longer see it.
683
+ if (!hasEarlierPending)
684
+ this.store.markSynced(this.config.name, chatId, replyId);
685
+ }
623
686
  }
624
687
  // Wait for all pending tool event messages to be delivered first
625
688
  const toolSends = this.pendingToolSends.get(chatId) || [];
@@ -656,16 +719,55 @@ export class FeishuBot {
656
719
  }
657
720
  }
658
721
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
659
- // Replace ack reactions with DONE for all pending messages in this chat
722
+ // Replace ack reactions with DONE only for trigger messages actually
723
+ // processed in this run. Queued messages that arrived mid-run keep their
724
+ // Typing/Get reaction and will be acknowledged by the next loop.
660
725
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
726
+ const remainingAcks = [];
661
727
  for (const ack of pendingAcks) {
662
- await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
663
- await this.addReaction(ack.messageId, "DONE").catch(() => { });
728
+ if (processedTriggerIds.has(ack.rowId)) {
729
+ await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
730
+ await this.addReaction(ack.messageId, "DONE").catch(() => { });
731
+ }
732
+ else {
733
+ remainingAcks.push(ack);
734
+ }
664
735
  }
665
- this.pendingAckMessages.set(chatId, []);
736
+ this.pendingAckMessages.set(chatId, remainingAcks);
666
737
  }
667
738
  catch (err) {
668
739
  console.error(`[${this.config.name}] processQueue error:`, err);
740
+ const errorText = this.formatUserVisibleError(err);
741
+ if (lastHuman.messageId) {
742
+ await this.sendOrdered(chatId, async () => {
743
+ try {
744
+ await this.replyMessage(lastHuman.messageId, errorText);
745
+ }
746
+ catch {
747
+ await this.sendMessage(chatId, errorText);
748
+ }
749
+ if (triggerId)
750
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
751
+ }).catch(() => { });
752
+ }
753
+ if (triggerId) {
754
+ // The run failed after OpenClaw/provider rejected it. Notify the user
755
+ // and clear only this failed trigger so later messages can continue.
756
+ this.store.clearPendingTrigger(this.config.name, chatId, triggerId);
757
+ this.store.markSynced(this.config.name, chatId, triggerId);
758
+ }
759
+ const pendingAcks = this.pendingAckMessages.get(chatId) || [];
760
+ const remainingAcks = [];
761
+ for (const ack of pendingAcks) {
762
+ if (ack.rowId === triggerId) {
763
+ await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
764
+ await this.addReaction(ack.messageId, "FAIL").catch(() => { });
765
+ }
766
+ else {
767
+ remainingAcks.push(ack);
768
+ }
769
+ }
770
+ this.pendingAckMessages.set(chatId, remainingAcks);
669
771
  break;
670
772
  }
671
773
  finally {
@@ -779,6 +881,72 @@ export class FeishuBot {
779
881
  },
780
882
  };
781
883
  }
884
+ formatUserVisibleError(err) {
885
+ const raw = err instanceof Error ? err.message : String(err);
886
+ let reason = raw.replace(/\s+/g, " ").trim();
887
+ if (/quota/i.test(reason) || /exceeded/i.test(reason)) {
888
+ const reset = reason.match(/reset at ([^.;]+)/i)?.[1];
889
+ reason = reset ? `模型/供应商额度已用尽,重置时间:${reset}` : "模型/供应商额度已用尽,请稍后重试或切换模型";
890
+ }
891
+ else if (/timeout/i.test(reason)) {
892
+ reason = "模型响应超时,请稍后重试";
893
+ }
894
+ else if (/schema|tool payload|rejected/i.test(reason)) {
895
+ reason = "模型供应商拒绝了请求格式或工具参数,需要调整模型/工具调用";
896
+ }
897
+ else if (reason.length > 220) {
898
+ reason = reason.slice(0, 220) + "...";
899
+ }
900
+ return `⚠️ ${this.config.name} 这次没有完成回复。\n原因:${reason}`;
901
+ }
902
+ isDiscussionCoordinator() {
903
+ const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
904
+ if (bots.length === 0)
905
+ return true;
906
+ return bots[0] === this;
907
+ }
908
+ getDiscussionParticipants(chatId) {
909
+ return Array.from(FeishuBot.allBots.values())
910
+ .filter((bot) => bot.store === this.store && bot.store.getBotMode(bot.config.name, chatId) === "free")
911
+ .map((bot) => ({
912
+ name: bot.config.name,
913
+ runDiscussionTurn: async (_chatId, prompt) => bot.runDiscussionTurn(chatId, prompt),
914
+ }));
915
+ }
916
+ async runDiscussionTurn(chatId, prompt) {
917
+ const sessionKey = await this.ensureSession(chatId);
918
+ const reply = await this.openclawClient.chatSendWithContext({
919
+ sessionKey,
920
+ unsyncedMessages: [],
921
+ currentMessage: prompt,
922
+ currentSenderName: "Discussion Scheduler",
923
+ deliver: false,
924
+ timeoutMs: 1_800_000,
925
+ });
926
+ const parsedReply = this.extractBridgeAttachments(reply);
927
+ const visibleReply = parsedReply.text.trim();
928
+ const isVisible = visibleReply.length > 0 && visibleReply.toUpperCase() !== "NO_REPLY";
929
+ if (isVisible || parsedReply.attachments.length > 0) {
930
+ const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
931
+ .filter(Boolean)
932
+ .join("\n");
933
+ this.store.insert({
934
+ chatId,
935
+ messageId: `self-${this.config.name}-discuss-${Date.now()}-${Math.random().toString(36).slice(2)}`,
936
+ senderType: "bot",
937
+ senderName: this.config.name,
938
+ content: storedContent,
939
+ timestamp: Date.now(),
940
+ });
941
+ await this.sendOrdered(chatId, async () => {
942
+ if (isVisible)
943
+ await this.sendMessage(chatId, visibleReply);
944
+ for (const attachment of parsedReply.attachments)
945
+ await this.sendBridgeAttachment(chatId, attachment);
946
+ });
947
+ }
948
+ return { botName: this.config.name, text: visibleReply, visible: isVisible };
949
+ }
782
950
  async replyMessage(messageId, text) {
783
951
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
784
952
  const card = this.buildMarkdownCard(text);
@@ -1013,6 +1181,49 @@ ${doc.url}`);
1013
1181
  // ignore
1014
1182
  }
1015
1183
  }
1184
+ async handleDiscussCommand(chatId, chatType, messageId, text) {
1185
+ if (chatType === "p2p") {
1186
+ await this.replyMessage(messageId, "❌ Discuss 模式只在群聊中可用");
1187
+ return;
1188
+ }
1189
+ const parts = text.split(/\s+/).filter(Boolean);
1190
+ const action = parts[1] || "status";
1191
+ if (action === "on") {
1192
+ this.store.setDiscussMode(chatId, true);
1193
+ await this.replyMessage(messageId, `💬 Discuss 已开启\n参与者:当前群所有 free 模式 bot\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 3}`);
1194
+ return;
1195
+ }
1196
+ if (action === "off") {
1197
+ this.store.setDiscussMode(chatId, false);
1198
+ discussionManager.stop(chatId);
1199
+ await this.replyMessage(messageId, "💬 Discuss 已关闭");
1200
+ return;
1201
+ }
1202
+ if (action === "stop") {
1203
+ const stopped = discussionManager.stop(chatId);
1204
+ await this.replyMessage(messageId, stopped ? "💬 当前 discuss 已停止" : "💬 当前没有运行中的 discuss");
1205
+ return;
1206
+ }
1207
+ if (action === "rounds") {
1208
+ const n = Number.parseInt(parts[2] || "", 10);
1209
+ if (!Number.isFinite(n)) {
1210
+ await this.replyMessage(messageId, "❌ 用法:/discuss rounds <1-10>");
1211
+ return;
1212
+ }
1213
+ this.store.setDiscussMaxRounds(chatId, n);
1214
+ await this.replyMessage(messageId, `💬 Discuss 轮数已设置为 ${this.store.getChatInfo(chatId)?.discussMaxRounds || n}`);
1215
+ return;
1216
+ }
1217
+ const info = this.store.getChatInfo(chatId);
1218
+ const active = discussionManager.status(chatId);
1219
+ const participants = this.getDiscussionParticipants(chatId).map((p) => p.name);
1220
+ await this.replyMessage(messageId, [
1221
+ `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
1222
+ `轮数:${info?.discussMaxRounds || 3}`,
1223
+ `参与者:${participants.length ? participants.join(", ") : "(无 free bot)"}`,
1224
+ active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
1225
+ ].join("\n"));
1226
+ }
1016
1227
  /**
1017
1228
  * Handle /status command: show current session info.
1018
1229
  */
@@ -1113,6 +1324,8 @@ ${doc.url}`);
1113
1324
  ownerBot: this.config.name,
1114
1325
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
1115
1326
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
1327
+ discuss: this.store.getChatInfo(chatId)?.discuss || false,
1328
+ discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
1116
1329
  updatedAt: Date.now(),
1117
1330
  });
1118
1331
  return;
@@ -1150,6 +1363,8 @@ ${doc.url}`);
1150
1363
  ownerBot: "", // group chats are shared, no owner
1151
1364
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
1152
1365
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
1366
+ discuss: this.store.getChatInfo(chatId)?.discuss || false,
1367
+ discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
1153
1368
  updatedAt: Date.now(),
1154
1369
  });
1155
1370
  console.log(`[${this.config.name}] Cached chat info: ${chatName} (${chatId.slice(-8)})`);
@@ -12,6 +12,8 @@ export interface ChatInfo {
12
12
  /** Legacy chat-level free discussion flag; per-bot mode is authoritative. */
13
13
  freeDiscussion: boolean;
14
14
  verbose: boolean;
15
+ discuss: boolean;
16
+ discussMaxRounds: number;
15
17
  updatedAt: number;
16
18
  }
17
19
  export interface ChatMessage {
@@ -35,6 +37,7 @@ export declare class MessageStore {
35
37
  markPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
36
38
  getPendingTriggerIds(botName: string, chatId: string): Set<number>;
37
39
  clearPendingTriggers(botName: string, chatId: string, upToId: number): void;
40
+ clearPendingTrigger(botName: string, chatId: string, messageRowId: number): void;
38
41
  hasDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number): boolean;
39
42
  markDeliveredReply(botName: string, chatId: string, triggerMessageRowId: number, replyMessageId?: string): void;
40
43
  /**
@@ -61,6 +64,8 @@ export declare class MessageStore {
61
64
  upsertChatInfo(info: ChatInfo): void;
62
65
  setFreeDiscussion(chatId: string, on: boolean): void;
63
66
  setVerbose(chatId: string, verbose: boolean): void;
67
+ setDiscussMode(chatId: string, on: boolean): void;
68
+ setDiscussMaxRounds(chatId: string, rounds: number): void;
64
69
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
65
70
  getBotVerbose(botName: string, chatId: string): boolean;
66
71
  setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
@@ -31,6 +31,8 @@ export class MessageStore {
31
31
  members TEXT NOT NULL DEFAULT '',
32
32
  member_names TEXT NOT NULL DEFAULT '',
33
33
  verbose INTEGER NOT NULL DEFAULT 0,
34
+ discuss INTEGER NOT NULL DEFAULT 0,
35
+ discuss_max_rounds INTEGER NOT NULL DEFAULT 3,
34
36
  updated_at INTEGER NOT NULL DEFAULT 0
35
37
  );
36
38
 
@@ -88,6 +90,19 @@ export class MessageStore {
88
90
  catch {
89
91
  // Column already exists
90
92
  }
93
+ // Migration: add discuss columns if missing
94
+ try {
95
+ this.db.exec(`ALTER TABLE chat_info ADD COLUMN discuss INTEGER NOT NULL DEFAULT 0`);
96
+ }
97
+ catch {
98
+ // Column already exists
99
+ }
100
+ try {
101
+ this.db.exec(`ALTER TABLE chat_info ADD COLUMN discuss_max_rounds INTEGER NOT NULL DEFAULT 3`);
102
+ }
103
+ catch {
104
+ // Column already exists
105
+ }
91
106
  // Migration: add owner_bot column if missing
92
107
  try {
93
108
  this.db.exec(`ALTER TABLE chat_info ADD COLUMN owner_bot TEXT NOT NULL DEFAULT ''`);
@@ -163,6 +178,12 @@ export class MessageStore {
163
178
  WHERE bot_name = ? AND chat_id = ? AND message_row_id <= ?
164
179
  `).run(botName, chatId, upToId);
165
180
  }
181
+ clearPendingTrigger(botName, chatId, messageRowId) {
182
+ this.db.prepare(`
183
+ DELETE FROM pending_triggers
184
+ WHERE bot_name = ? AND chat_id = ? AND message_row_id = ?
185
+ `).run(botName, chatId, messageRowId);
186
+ }
166
187
  hasDeliveredReply(botName, chatId, triggerMessageRowId) {
167
188
  const row = this.db.prepare(`
168
189
  SELECT 1 FROM delivered_replies
@@ -258,8 +279,8 @@ export class MessageStore {
258
279
  // --- Chat info ---
259
280
  upsertChatInfo(info) {
260
281
  this.db.prepare(`
261
- INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, updated_at)
262
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
282
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, discuss, discuss_max_rounds, updated_at)
283
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
263
284
  ON CONFLICT (chat_id) DO UPDATE SET
264
285
  chat_type = excluded.chat_type,
265
286
  chat_name = excluded.chat_name,
@@ -268,8 +289,10 @@ export class MessageStore {
268
289
  verbose = excluded.verbose,
269
290
  free_discussion = excluded.free_discussion,
270
291
  owner_bot = CASE WHEN excluded.owner_bot != '' THEN excluded.owner_bot ELSE chat_info.owner_bot END,
292
+ discuss = CASE WHEN excluded.discuss != 0 THEN excluded.discuss ELSE chat_info.discuss END,
293
+ discuss_max_rounds = CASE WHEN excluded.discuss_max_rounds != 3 THEN excluded.discuss_max_rounds ELSE chat_info.discuss_max_rounds END,
271
294
  updated_at = excluded.updated_at
272
- `).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.updatedAt);
295
+ `).run(info.chatId, info.chatType, info.chatName, info.members, info.memberNames, info.verbose ? 1 : 0, info.freeDiscussion ? 1 : 0, info.ownerBot || '', info.discuss ? 1 : 0, info.discussMaxRounds || 3, info.updatedAt);
273
296
  }
274
297
  setFreeDiscussion(chatId, on) {
275
298
  this.db.prepare(`UPDATE chat_info SET free_discussion = ? WHERE chat_id = ?`).run(on ? 1 : 0, chatId);
@@ -279,6 +302,21 @@ export class MessageStore {
279
302
  UPDATE chat_info SET verbose = ? WHERE chat_id = ?
280
303
  `).run(verbose ? 1 : 0, chatId);
281
304
  }
305
+ setDiscussMode(chatId, on) {
306
+ this.db.prepare(`
307
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, discuss, discuss_max_rounds, updated_at)
308
+ VALUES (?, 'group', '', ?, 3, ?)
309
+ ON CONFLICT (chat_id) DO UPDATE SET discuss = excluded.discuss, updated_at = excluded.updated_at
310
+ `).run(chatId, on ? 1 : 0, Date.now());
311
+ }
312
+ setDiscussMaxRounds(chatId, rounds) {
313
+ const normalized = Math.max(1, Math.min(10, Math.round(rounds)));
314
+ this.db.prepare(`
315
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, discuss, discuss_max_rounds, updated_at)
316
+ VALUES (?, 'group', '', 0, ?, ?)
317
+ ON CONFLICT (chat_id) DO UPDATE SET discuss_max_rounds = excluded.discuss_max_rounds, updated_at = excluded.updated_at
318
+ `).run(chatId, normalized, Date.now());
319
+ }
282
320
  setBotVerbose(botName, chatId, verbose) {
283
321
  this.db.prepare(`
284
322
  INSERT INTO bot_chat_settings (bot_name, chat_id, verbose, updated_at)
@@ -334,6 +372,8 @@ export class MessageStore {
334
372
  ownerBot: row.owner_bot || '',
335
373
  freeDiscussion: !!row.free_discussion,
336
374
  verbose: !!row.verbose,
375
+ discuss: !!row.discuss,
376
+ discussMaxRounds: row.discuss_max_rounds || 3,
337
377
  updatedAt: row.updated_at,
338
378
  };
339
379
  }
@@ -348,6 +388,8 @@ export class MessageStore {
348
388
  ownerBot: r.owner_bot || '',
349
389
  freeDiscussion: !!r.free_discussion,
350
390
  verbose: !!r.verbose,
391
+ discuss: !!r.discuss,
392
+ discussMaxRounds: r.discuss_max_rounds || 3,
351
393
  updatedAt: r.updated_at,
352
394
  }));
353
395
  }
@@ -266,22 +266,28 @@ export class OpenClawClient {
266
266
  let replayInvalidTimer = null;
267
267
  const collectStartedAt = Date.now();
268
268
  let lifecycleStartedLogged = false;
269
- const timer = setTimeout(() => {
270
- clearInterval(poller);
271
- if (chatFinalTimer)
272
- clearTimeout(chatFinalTimer);
273
- if (lifecycleEndTimer)
274
- clearTimeout(lifecycleEndTimer);
275
- if (replayInvalidTimer)
276
- clearTimeout(replayInvalidTimer);
277
- console.warn(`[OpenClaw] collectReply timeout for runId=${runId} sessionKey=${sessionKey}`);
278
- this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
279
- console.warn(`[OpenClaw] abort after collectReply timeout failed:`, err.message);
280
- });
281
- resolve(text || chatFinalText || "(timeout: no reply received)");
282
- }, timeoutMs);
269
+ let idleTimer;
270
+ const resetIdleTimer = () => {
271
+ if (idleTimer)
272
+ clearTimeout(idleTimer);
273
+ idleTimer = setTimeout(() => {
274
+ clearInterval(poller);
275
+ if (chatFinalTimer)
276
+ clearTimeout(chatFinalTimer);
277
+ if (lifecycleEndTimer)
278
+ clearTimeout(lifecycleEndTimer);
279
+ if (replayInvalidTimer)
280
+ clearTimeout(replayInvalidTimer);
281
+ console.warn(`[OpenClaw] collectReply idle timeout for runId=${runId} sessionKey=${sessionKey}`);
282
+ this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
283
+ console.warn(`[OpenClaw] abort after collectReply idle timeout failed:`, err.message);
284
+ });
285
+ resolve(text || chatFinalText || "(timeout: no reply received)");
286
+ }, timeoutMs);
287
+ };
288
+ resetIdleTimer();
283
289
  const finish = (finalText) => {
284
- clearTimeout(timer);
290
+ clearTimeout(idleTimer);
285
291
  clearInterval(poller);
286
292
  if (chatFinalTimer)
287
293
  clearTimeout(chatFinalTimer);
@@ -318,6 +324,10 @@ export class OpenClawClient {
318
324
  continue;
319
325
  }
320
326
  bucket.splice(i, 1);
327
+ // Any matching event — including toolCall/toolResult/item/lifecycle —
328
+ // means the agent is still alive. Use an idle timeout, not an absolute
329
+ // wall-clock timeout, so long tool-heavy tasks are not killed while active.
330
+ resetIdleTimer();
321
331
  // If more events arrive after a replay-invalid lifecycle end, that lifecycle
322
332
  // was not terminal for the user-visible run. Keep waiting for the real final.
323
333
  if (replayInvalidTimer) {
@@ -386,7 +396,7 @@ export class OpenClawClient {
386
396
  return;
387
397
  }
388
398
  if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
389
- clearTimeout(timer);
399
+ clearTimeout(idleTimer);
390
400
  clearInterval(poller);
391
401
  if (chatFinalTimer)
392
402
  clearTimeout(chatFinalTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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": {