openclaw-lark-multi-agent 0.1.9 → 0.1.11

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,43 @@
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
+ isActive(chatId: string): boolean;
29
+ stop(chatId: string): boolean;
30
+ status(chatId: string): DiscussionSession | null;
31
+ startIfAbsent(params: {
32
+ chatId: string;
33
+ rootMessageId: string;
34
+ topic: string;
35
+ maxRounds: number;
36
+ participants: DiscussionParticipant[];
37
+ sendSystemMessage?: (text: string) => Promise<void>;
38
+ }): boolean;
39
+ private runLoop;
40
+ private buildPrompt;
41
+ }
42
+ export declare const discussionManager: DiscussionManager;
43
+ export {};
@@ -0,0 +1,134 @@
1
+ import { randomUUID } from "crypto";
2
+ export class DiscussionManager {
3
+ sessions = new Map();
4
+ seenRoots = new Set();
5
+ isActive(chatId) {
6
+ return this.sessions.get(chatId)?.status === "running";
7
+ }
8
+ stop(chatId) {
9
+ const session = this.sessions.get(chatId);
10
+ if (!session)
11
+ return false;
12
+ session.status = "stopped";
13
+ this.sessions.delete(chatId);
14
+ return true;
15
+ }
16
+ status(chatId) {
17
+ const session = this.sessions.get(chatId);
18
+ return session ? { ...session, completedRounds: [...session.completedRounds] } : null;
19
+ }
20
+ startIfAbsent(params) {
21
+ const key = `${params.chatId}:${params.rootMessageId}`;
22
+ if (this.seenRoots.has(key))
23
+ return false;
24
+ this.seenRoots.add(key);
25
+ if (this.isActive(params.chatId))
26
+ this.stop(params.chatId);
27
+ const participants = params.participants.filter((p, index, arr) => arr.findIndex((x) => x.name === p.name) === index);
28
+ if (participants.length === 0)
29
+ return false;
30
+ const session = {
31
+ id: randomUUID(),
32
+ chatId: params.chatId,
33
+ rootMessageId: params.rootMessageId,
34
+ topic: params.topic,
35
+ participants: participants.map((p) => p.name),
36
+ currentRound: 1,
37
+ maxRounds: params.maxRounds,
38
+ completedRounds: [],
39
+ status: "running",
40
+ };
41
+ this.sessions.set(params.chatId, session);
42
+ void this.runLoop(session.id, participants, params.sendSystemMessage).catch((err) => {
43
+ console.warn(`[Discussion] loop failed for ${params.chatId}:`, err instanceof Error ? err.message : String(err));
44
+ const current = this.sessions.get(params.chatId);
45
+ if (current?.id === session.id)
46
+ this.sessions.delete(params.chatId);
47
+ });
48
+ return true;
49
+ }
50
+ async runLoop(sessionId, participants, sendSystemMessage) {
51
+ while (true) {
52
+ const session = Array.from(this.sessions.values()).find((s) => s.id === sessionId);
53
+ if (!session || session.status !== "running")
54
+ return;
55
+ if (session.currentRound > session.maxRounds) {
56
+ session.status = "completed";
57
+ this.sessions.delete(session.chatId);
58
+ return;
59
+ }
60
+ const prompt = this.buildPrompt(session);
61
+ const results = await Promise.allSettled(participants.map(async (participant) => {
62
+ try {
63
+ return await participant.runDiscussionTurn(session.chatId, prompt);
64
+ }
65
+ catch (err) {
66
+ return {
67
+ botName: participant.name,
68
+ text: "",
69
+ visible: false,
70
+ error: err instanceof Error ? err.message : String(err),
71
+ };
72
+ }
73
+ }));
74
+ const current = this.sessions.get(session.chatId);
75
+ if (!current || current.id !== sessionId || current.status !== "running")
76
+ return;
77
+ const replies = {};
78
+ for (const result of results) {
79
+ const value = result.status === "fulfilled" ? result.value : undefined;
80
+ if (!value)
81
+ continue;
82
+ replies[value.botName] = value.error ? `[ERROR] ${value.error}` : value.text.trim();
83
+ }
84
+ current.completedRounds.push({ round: current.currentRound, replies });
85
+ const allNoReply = participants.every((participant) => {
86
+ const text = (replies[participant.name] || "").trim();
87
+ return !text || text.toUpperCase() === "NO_REPLY" || text.startsWith("[ERROR]");
88
+ });
89
+ if (allNoReply) {
90
+ current.status = "completed";
91
+ this.sessions.delete(current.chatId);
92
+ if (sendSystemMessage)
93
+ await sendSystemMessage(`💬 Discuss 已结束:第 ${current.currentRound} 轮没有新的有效补充。`).catch(() => { });
94
+ return;
95
+ }
96
+ if (current.currentRound >= current.maxRounds) {
97
+ current.status = "completed";
98
+ this.sessions.delete(current.chatId);
99
+ if (sendSystemMessage)
100
+ await sendSystemMessage(`💬 Discuss 已完成:已达到 ${current.maxRounds} 轮。`).catch(() => { });
101
+ return;
102
+ }
103
+ current.currentRound += 1;
104
+ }
105
+ }
106
+ buildPrompt(session) {
107
+ const previous = session.completedRounds.length === 0
108
+ ? "(暂无,当前是第一轮)"
109
+ : session.completedRounds.map((round) => {
110
+ const lines = Object.entries(round.replies).map(([bot, text]) => `- ${bot}: ${text || "NO_REPLY"}`);
111
+ return `Round ${round.round}:\n${lines.join("\n")}`;
112
+ }).join("\n\n");
113
+ return [
114
+ "这是一个多智能体结构化讨论。",
115
+ "",
116
+ "话题:",
117
+ session.topic,
118
+ "",
119
+ `当前轮次:${session.currentRound}`,
120
+ "",
121
+ "已完成的轮次:",
122
+ previous,
123
+ "",
124
+ "本轮其他 bot 的回复你暂时看不到,请基于同一份上下文独立给出观点。",
125
+ "",
126
+ "要求:",
127
+ "1. 不要重复前几轮已经说过的观点。",
128
+ "2. 只补充新的、有价值的信息。",
129
+ "3. 如果没有新东西,回复 NO_REPLY。",
130
+ "4. 简洁作答。",
131
+ ].join("\n");
132
+ }
133
+ }
134
+ 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
  /**
@@ -68,12 +69,17 @@ export declare class FeishuBot {
68
69
  private shouldRespond;
69
70
  private isMentioned;
70
71
  private isAllMention;
72
+ private isAllMentionItem;
71
73
  private mentionedBotName;
72
74
  private resolveBotName;
73
75
  private resolveHumanName;
74
76
  private cleanMentions;
75
77
  private stripLeadingCommandMentions;
76
78
  private buildMarkdownCard;
79
+ private formatUserVisibleError;
80
+ private isDiscussionCoordinator;
81
+ private getDiscussionParticipants;
82
+ private runDiscussionTurn;
77
83
  private replyMessage;
78
84
  private extractBridgeAttachments;
79
85
  private validateBridgeAttachmentPath;
@@ -99,6 +105,7 @@ export declare class FeishuBot {
99
105
  * Finds the bot's own reaction of that type and deletes it.
100
106
  */
101
107
  private removeReaction;
108
+ private handleDiscussCommand;
102
109
  /**
103
110
  * Handle /status command: show current session info.
104
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)
@@ -264,6 +272,12 @@ export class FeishuBot {
264
272
  if (messageType === "text") {
265
273
  const rawText = content.text || "";
266
274
  cleanText = this.cleanMentions(rawText);
275
+ // A mention-only text message is still a valid routing trigger. Feishu may
276
+ // expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
277
+ // so decide emptiness after stripping leading routing mentions.
278
+ if ((this.isMentioned(message.mentions || []) || this.isAllMention(rawText, message.mentions || [])) && !this.stripLeadingCommandMentions(cleanText).trim()) {
279
+ cleanText = "请回复上面最近一条用户消息。";
280
+ }
267
281
  }
268
282
  else if (messageType === "image") {
269
283
  // Download image and pass local path
@@ -345,17 +359,23 @@ export class FeishuBot {
345
359
  // Single slash commands are handled by the bridge. Double slash commands were
346
360
  // already unescaped above and should pass through to OpenClaw instead.
347
361
  const isBridgeCommand = !commandText.startsWith("//");
348
- 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());
349
363
  if (isCommand) {
350
- // In group chats, bridge commands must be explicitly routed to this bot
351
- // or @all. Do not let Free Discussion make a bot execute commands meant
352
- // for another bot.
353
- if (chatType !== "p2p" && !this.shouldHandleBridgeCommand(chatType, message, isBot, message.content))
354
- 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
+ }
355
372
  const markCommandSynced = () => {
356
373
  if (insertedId > 0) {
357
374
  this.store.markSynced(this.config.name, chatId, insertedId);
358
- 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);
359
379
  }
360
380
  };
361
381
  if (cleanText.trim().startsWith("/help")) {
@@ -370,6 +390,7 @@ export class FeishuBot {
370
390
  `🔓 /free — 切换当前 bot 的 free 模式(不 @ 也可回复)`,
371
391
  `🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
372
392
  `🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
393
+ `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
373
394
  `❓ /help — 显示此帮助信息`,
374
395
  ``,
375
396
  `OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
@@ -474,6 +495,33 @@ export class FeishuBot {
474
495
  markCommandSynced();
475
496
  return;
476
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
+ }
477
525
  }
478
526
  // --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
479
527
  if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
@@ -481,7 +529,7 @@ export class FeishuBot {
481
529
  await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
482
530
  if (insertedId > 0) {
483
531
  this.store.markSynced(this.config.name, chatId, insertedId);
484
- this.store.clearPendingTriggers(this.config.name, chatId, insertedId);
532
+ this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
485
533
  }
486
534
  }
487
535
  return;
@@ -495,7 +543,7 @@ export class FeishuBot {
495
543
  // Track this message for reaction status updates
496
544
  const pending = this.pendingAckMessages.get(chatId) || [];
497
545
  // Anti-loop
498
- const streak = this.store.getBotStreak(chatId);
546
+ const streak = this.store.getBotStreak(chatId, this.config.name);
499
547
  if (streak >= MAX_BOT_STREAK) {
500
548
  console.log(`[${this.config.name}] Anti-loop: ${streak} consecutive bot msgs`);
501
549
  return;
@@ -507,7 +555,7 @@ export class FeishuBot {
507
555
  if (isBusy) {
508
556
  // Queued: show waiting reaction
509
557
  await this.addReaction(messageId, "Typing").catch(() => { });
510
- pending.push({ messageId, emoji: "Typing" });
558
+ pending.push({ messageId, emoji: "Typing", rowId: insertedId });
511
559
  this.pendingAckMessages.set(chatId, pending);
512
560
  console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, queuing: "${cleanText.substring(0, 50)}..."`);
513
561
  return; // Message is in DB, will be picked up when agent finishes
@@ -522,7 +570,7 @@ export class FeishuBot {
522
570
  // --- Not busy, send now (with any accumulated messages) ---
523
571
  // Acknowledge receipt: sent to OpenClaw (GET/了解)
524
572
  await this.addReaction(messageId, "Get").catch(() => { });
525
- pending.push({ messageId, emoji: "Get" });
573
+ pending.push({ messageId, emoji: "Get", rowId: insertedId });
526
574
  this.pendingAckMessages.set(chatId, pending);
527
575
  await this.processQueue(chatId);
528
576
  }
@@ -590,15 +638,29 @@ export class FeishuBot {
590
638
  timeoutMs: 600000,
591
639
  });
592
640
  console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
593
- // Mark everything up to now as synced
594
- const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
595
- this.store.markSynced(this.config.name, chatId, maxId);
596
- this.store.clearPendingTriggers(this.config.name, chatId, maxId);
597
641
  const parsedReply = this.extractBridgeAttachments(reply);
598
642
  const visibleReply = parsedReply.text;
599
643
  const trimmedReply = visibleReply.trim();
600
- const shouldReply = trimmedReply.length > 0 && trimmedReply.toUpperCase() !== "NO_REPLY";
601
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;
602
664
  // Record bot reply only if it is user-visible/context-worthy.
603
665
  if (shouldReply || hasAttachments) {
604
666
  const storedContent = [visibleReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
@@ -612,8 +674,15 @@ export class FeishuBot {
612
674
  content: storedContent,
613
675
  timestamp: Date.now(),
614
676
  });
615
- if (replyId > 0)
616
- 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
+ }
617
686
  }
618
687
  // Wait for all pending tool event messages to be delivered first
619
688
  const toolSends = this.pendingToolSends.get(chatId) || [];
@@ -650,16 +719,55 @@ export class FeishuBot {
650
719
  }
651
720
  }
652
721
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
653
- // 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.
654
725
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
726
+ const remainingAcks = [];
655
727
  for (const ack of pendingAcks) {
656
- await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
657
- 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
+ }
658
735
  }
659
- this.pendingAckMessages.set(chatId, []);
736
+ this.pendingAckMessages.set(chatId, remainingAcks);
660
737
  }
661
738
  catch (err) {
662
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);
663
771
  break;
664
772
  }
665
773
  finally {
@@ -694,9 +802,11 @@ export class FeishuBot {
694
802
  // Check if this bot is explicitly mentioned
695
803
  if (this.isMentioned(mentions))
696
804
  return true;
697
- // Check if any other bot is mentioned (not us) don't respond
698
- const anyBotMentioned = mentions.some((m) => this.mentionedBotName(m) !== null);
699
- if (anyBotMentioned && !this.isMentioned(mentions))
805
+ // Targeted mentions are exclusive. If a human mentions another person or
806
+ // another bot, free-mode bots must not steal that message. Free mode only
807
+ // applies to plain human messages with no targeted mentions.
808
+ const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
809
+ if (hasTargetedMention)
700
810
  return false;
701
811
  // No bot mentioned: check current per-bot mode
702
812
  if (chatId) {
@@ -712,10 +822,13 @@ export class FeishuBot {
712
822
  isAllMention(rawText, mentions = []) {
713
823
  if (rawText && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
714
824
  return true;
715
- return mentions.some((m) => m.key === "all" || m.key === "@_all" || m.id?.user_id === "all" || m.id?.open_id === "all" || m.name === "所有人");
825
+ return mentions.some((m) => this.isAllMentionItem(m));
826
+ }
827
+ isAllMentionItem(mention) {
828
+ return mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人";
716
829
  }
717
830
  mentionedBotName(mention) {
718
- if (mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人")
831
+ if (this.isAllMentionItem(mention))
719
832
  return null;
720
833
  const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
721
834
  for (const bot of candidates) {
@@ -768,6 +881,72 @@ export class FeishuBot {
768
881
  },
769
882
  };
770
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: 600000,
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
+ }
771
950
  async replyMessage(messageId, text) {
772
951
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
773
952
  const card = this.buildMarkdownCard(text);
@@ -1002,6 +1181,49 @@ ${doc.url}`);
1002
1181
  // ignore
1003
1182
  }
1004
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
+ }
1005
1227
  /**
1006
1228
  * Handle /status command: show current session info.
1007
1229
  */
@@ -1102,6 +1324,8 @@ ${doc.url}`);
1102
1324
  ownerBot: this.config.name,
1103
1325
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
1104
1326
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
1327
+ discuss: this.store.getChatInfo(chatId)?.discuss || false,
1328
+ discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
1105
1329
  updatedAt: Date.now(),
1106
1330
  });
1107
1331
  return;
@@ -1139,6 +1363,8 @@ ${doc.url}`);
1139
1363
  ownerBot: "", // group chats are shared, no owner
1140
1364
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
1141
1365
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
1366
+ discuss: this.store.getChatInfo(chatId)?.discuss || false,
1367
+ discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
1142
1368
  updatedAt: Date.now(),
1143
1369
  });
1144
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
  /**
@@ -51,12 +54,18 @@ export declare class MessageStore {
51
54
  */
52
55
  getRecent(chatId: string, maxCount?: number): ChatMessage[];
53
56
  /**
54
- * Count consecutive bot messages at the tail of a chat.
57
+ * Count consecutive messages from one bot at the tail of a chat.
58
+ *
59
+ * Other bots do not consume this bot's anti-loop budget. Human messages reset
60
+ * the streak. This lets multiple bots free-discuss without a global bot-streak
61
+ * guard shutting everyone down after N total bot messages.
55
62
  */
56
- getBotStreak(chatId: string): number;
63
+ getBotStreak(chatId: string, botName: string): number;
57
64
  upsertChatInfo(info: ChatInfo): void;
58
65
  setFreeDiscussion(chatId: string, on: boolean): void;
59
66
  setVerbose(chatId: string, verbose: boolean): void;
67
+ setDiscussMode(chatId: string, on: boolean): void;
68
+ setDiscussMaxRounds(chatId: string, rounds: number): void;
60
69
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
61
70
  getBotVerbose(botName: string, chatId: string): boolean;
62
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
@@ -233,29 +254,33 @@ export class MessageStore {
233
254
  }));
234
255
  }
235
256
  /**
236
- * Count consecutive bot messages at the tail of a chat.
257
+ * Count consecutive messages from one bot at the tail of a chat.
258
+ *
259
+ * Other bots do not consume this bot's anti-loop budget. Human messages reset
260
+ * the streak. This lets multiple bots free-discuss without a global bot-streak
261
+ * guard shutting everyone down after N total bot messages.
237
262
  */
238
- getBotStreak(chatId) {
263
+ getBotStreak(chatId, botName) {
239
264
  const rows = this.db.prepare(`
240
- SELECT sender_type FROM messages
265
+ SELECT sender_type, sender_name FROM messages
241
266
  WHERE chat_id = ?
242
267
  ORDER BY timestamp DESC
243
- LIMIT 20
268
+ LIMIT 50
244
269
  `).all(chatId);
245
270
  let count = 0;
246
271
  for (const r of rows) {
247
- if (r.sender_type === "bot")
248
- count++;
249
- else
272
+ if (r.sender_type === "human")
250
273
  break;
274
+ if (r.sender_type === "bot" && r.sender_name === botName)
275
+ count++;
251
276
  }
252
277
  return count;
253
278
  }
254
279
  // --- Chat info ---
255
280
  upsertChatInfo(info) {
256
281
  this.db.prepare(`
257
- INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, updated_at)
258
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
259
284
  ON CONFLICT (chat_id) DO UPDATE SET
260
285
  chat_type = excluded.chat_type,
261
286
  chat_name = excluded.chat_name,
@@ -264,8 +289,10 @@ export class MessageStore {
264
289
  verbose = excluded.verbose,
265
290
  free_discussion = excluded.free_discussion,
266
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,
267
294
  updated_at = excluded.updated_at
268
- `).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);
269
296
  }
270
297
  setFreeDiscussion(chatId, on) {
271
298
  this.db.prepare(`UPDATE chat_info SET free_discussion = ? WHERE chat_id = ?`).run(on ? 1 : 0, chatId);
@@ -275,6 +302,21 @@ export class MessageStore {
275
302
  UPDATE chat_info SET verbose = ? WHERE chat_id = ?
276
303
  `).run(verbose ? 1 : 0, chatId);
277
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
+ }
278
320
  setBotVerbose(botName, chatId, verbose) {
279
321
  this.db.prepare(`
280
322
  INSERT INTO bot_chat_settings (bot_name, chat_id, verbose, updated_at)
@@ -330,6 +372,8 @@ export class MessageStore {
330
372
  ownerBot: row.owner_bot || '',
331
373
  freeDiscussion: !!row.free_discussion,
332
374
  verbose: !!row.verbose,
375
+ discuss: !!row.discuss,
376
+ discussMaxRounds: row.discuss_max_rounds || 3,
333
377
  updatedAt: row.updated_at,
334
378
  };
335
379
  }
@@ -344,6 +388,8 @@ export class MessageStore {
344
388
  ownerBot: r.owner_bot || '',
345
389
  freeDiscussion: !!r.free_discussion,
346
390
  verbose: !!r.verbose,
391
+ discuss: !!r.discuss,
392
+ discussMaxRounds: r.discuss_max_rounds || 3,
347
393
  updatedAt: r.updated_at,
348
394
  }));
349
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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": {