openclaw-lark-multi-agent 1.0.13 → 1.0.15

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/dist/config.d.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { type Locale } from "./i18n.js";
1
2
  export interface BotConfig {
2
3
  name: string;
3
4
  appId: string;
4
5
  appSecret: string;
5
6
  model: string;
7
+ locale?: Locale;
6
8
  }
7
9
  export interface OpenClawConfig {
8
10
  baseUrl: string;
@@ -13,5 +15,7 @@ export interface AppConfig {
13
15
  bots: BotConfig[];
14
16
  /** Optional Feishu/Lark open_id for model-drift notifications */
15
17
  adminOpenId?: string;
18
+ /** Default UI/prompt language. Bot-level locale overrides this. */
19
+ locale?: Locale;
16
20
  }
17
21
  export declare function loadConfig(path?: string): AppConfig;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { resolve } from "path";
3
+ import { normalizeLocale } from "./i18n.js";
3
4
  export function loadConfig(path) {
4
5
  const configPath = path || resolve(process.cwd(), "config.json");
5
6
  const raw = readFileSync(configPath, "utf-8");
@@ -15,6 +16,10 @@ export function loadConfig(path) {
15
16
  throw new Error(`Bot "${bot.name}" missing appId, appSecret, or model`);
16
17
  }
17
18
  }
19
+ config.locale = normalizeLocale(config.locale);
20
+ for (const bot of config.bots) {
21
+ bot.locale = normalizeLocale(bot.locale || config.locale);
22
+ }
18
23
  // Validate uniqueness
19
24
  const names = config.bots.map((b) => b.name);
20
25
  const appIds = config.bots.map((b) => b.appId);
@@ -1,15 +1,24 @@
1
+ import { type Locale } from "./i18n.js";
1
2
  export type ReplyResult = {
2
3
  botName: string;
3
4
  text: string;
4
5
  visible: boolean;
5
6
  error?: string;
6
7
  };
8
+ export type DiscussionCompleteReason = "chairman_final" | "all_no_reply" | "max_rounds";
9
+ export type DiscussionCompleteEvent = {
10
+ chatId: string;
11
+ reason: DiscussionCompleteReason;
12
+ chairmanName?: string;
13
+ };
7
14
  type DiscussionSession = {
8
15
  id: string;
9
16
  chatId: string;
10
17
  rootMessageId: string;
11
18
  topic: string;
12
19
  participants: string[];
20
+ chairmanName?: string;
21
+ locale: Locale;
13
22
  currentRound: number;
14
23
  maxRounds: number;
15
24
  completedRounds: Array<{
@@ -26,9 +35,11 @@ export type DiscussionParticipant = {
26
35
  }): Promise<ReplyResult>;
27
36
  };
28
37
  export declare class DiscussionManager {
38
+ private defaultLocale;
29
39
  private sessions;
30
40
  private seenRoots;
31
41
  private readonly seenRootTtlMs;
42
+ constructor(defaultLocale?: Locale);
32
43
  isActive(chatId: string): boolean;
33
44
  stop(chatId: string): boolean;
34
45
  status(chatId: string): DiscussionSession | null;
@@ -38,10 +49,15 @@ export declare class DiscussionManager {
38
49
  topic: string;
39
50
  maxRounds: number;
40
51
  participants: DiscussionParticipant[];
52
+ chairman?: DiscussionParticipant;
41
53
  sendSystemMessage?: (text: string) => Promise<void>;
54
+ onComplete?: (event: DiscussionCompleteEvent) => Promise<void>;
55
+ locale?: Locale;
42
56
  }): boolean;
43
57
  private pruneSeenRoots;
44
58
  private runLoop;
59
+ private hasFinalSummaryMarker;
60
+ private buildChairmanPrompt;
45
61
  private buildPrompt;
46
62
  }
47
63
  export declare const discussionManager: DiscussionManager;
@@ -1,8 +1,13 @@
1
1
  import { randomUUID } from "crypto";
2
+ import { getI18n } from "./i18n.js";
2
3
  export class DiscussionManager {
4
+ defaultLocale;
3
5
  sessions = new Map();
4
6
  seenRoots = new Map();
5
7
  seenRootTtlMs = 6 * 60 * 60 * 1000;
8
+ constructor(defaultLocale = "zh") {
9
+ this.defaultLocale = defaultLocale;
10
+ }
6
11
  isActive(chatId) {
7
12
  return this.sessions.get(chatId)?.status === "running";
8
13
  }
@@ -26,8 +31,11 @@ export class DiscussionManager {
26
31
  this.seenRoots.set(key, Date.now());
27
32
  if (this.isActive(params.chatId))
28
33
  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) {
34
+ const chairman = params.chairman;
35
+ const participants = params.participants
36
+ .filter((p) => p.name !== chairman?.name)
37
+ .filter((p, index, arr) => arr.findIndex((x) => x.name === p.name) === index);
38
+ if (participants.length === 0 && !chairman) {
31
39
  this.seenRoots.delete(key);
32
40
  return false;
33
41
  }
@@ -36,14 +44,16 @@ export class DiscussionManager {
36
44
  chatId: params.chatId,
37
45
  rootMessageId: params.rootMessageId,
38
46
  topic: params.topic,
39
- participants: participants.map((p) => p.name),
47
+ participants: [...participants.map((p) => p.name), ...(chairman ? [chairman.name] : [])],
48
+ chairmanName: chairman?.name,
49
+ locale: params.locale || this.defaultLocale,
40
50
  currentRound: 1,
41
51
  maxRounds: params.maxRounds,
42
52
  completedRounds: [],
43
53
  status: "running",
44
54
  };
45
55
  this.sessions.set(params.chatId, session);
46
- void this.runLoop(session.id, participants, params.sendSystemMessage).catch((err) => {
56
+ void this.runLoop(session.id, participants, chairman, params.sendSystemMessage, params.onComplete).catch((err) => {
47
57
  console.warn(`[Discussion] loop failed for ${params.chatId}:`, err instanceof Error ? err.message : String(err));
48
58
  const current = this.sessions.get(params.chatId);
49
59
  if (current?.id === session.id)
@@ -57,7 +67,7 @@ export class DiscussionManager {
57
67
  this.seenRoots.delete(key);
58
68
  }
59
69
  }
60
- async runLoop(sessionId, participants, sendSystemMessage) {
70
+ async runLoop(sessionId, participants, chairman, sendSystemMessage, onComplete) {
61
71
  while (true) {
62
72
  const session = Array.from(this.sessions.values()).find((s) => s.id === sessionId);
63
73
  if (!session || session.status !== "running")
@@ -101,62 +111,88 @@ export class DiscussionManager {
101
111
  const errorNames = participants
102
112
  .map((participant) => participant.name)
103
113
  .filter((name) => (replies[name] || "").trim().startsWith("[ERROR]"));
104
- const allNoReply = participants.every((participant) => {
114
+ const allNoReply = participants.length > 0 && participants.every((participant) => {
105
115
  const text = (replies[participant.name] || "").trim();
106
116
  return !text || text.toUpperCase() === "NO_REPLY" || text.startsWith("[ERROR]");
107
117
  });
108
118
  if (sendSystemMessage && !allNoReply && (noReplyNames.length > 0 || errorNames.length > 0)) {
109
119
  const parts = [];
120
+ const t = getI18n(current.locale).labels;
110
121
  if (noReplyNames.length > 0)
111
- parts.push(`${noReplyNames.join("、")} 无新增回复`);
122
+ parts.push(`${noReplyNames.join("、")} ${t.noNewReply}`);
112
123
  if (errorNames.length > 0)
113
- parts.push(`${errorNames.join("、")} 出错`);
114
- await sendSystemMessage(`💬 第 ${current.currentRound}/${current.maxRounds} 轮:${parts.join(";")}`).catch(() => { });
124
+ parts.push(`${errorNames.join("、")} ${t.error}`);
125
+ await sendSystemMessage(t.discussionRoundNotice(current.currentRound, current.maxRounds, parts.join(current.locale === "zh" ? ";" : "; "))).catch(() => { });
126
+ }
127
+ const mustFinish = allNoReply || current.currentRound >= current.maxRounds;
128
+ if (chairman) {
129
+ const chairmanPrompt = this.buildChairmanPrompt(current, replies, mustFinish);
130
+ const chairResult = await chairman.runDiscussionTurn(current.chatId, chairmanPrompt, { round: current.currentRound, maxRounds: current.maxRounds });
131
+ const chairText = (chairResult.text || "").trim();
132
+ replies[chairman.name] = chairText;
133
+ const latest = current.completedRounds[current.completedRounds.length - 1];
134
+ if (latest)
135
+ latest.replies[chairman.name] = chairText;
136
+ const wantsFinal = this.hasFinalSummaryMarker(chairText);
137
+ if (mustFinish || wantsFinal) {
138
+ current.status = "completed";
139
+ this.sessions.delete(current.chatId);
140
+ if (onComplete)
141
+ await onComplete({ chatId: current.chatId, reason: "chairman_final", chairmanName: chairman.name }).catch(() => { });
142
+ else if (sendSystemMessage)
143
+ await sendSystemMessage(getI18n(current.locale).labels.discussEndedChairman(chairman.name)).catch(() => { });
144
+ return;
145
+ }
115
146
  }
116
- if (allNoReply) {
147
+ else if (allNoReply) {
117
148
  current.status = "completed";
118
149
  this.sessions.delete(current.chatId);
150
+ if (onComplete)
151
+ await onComplete({ chatId: current.chatId, reason: "all_no_reply" }).catch(() => { });
119
152
  if (sendSystemMessage)
120
- await sendSystemMessage(`💬 Discuss 已结束:第 ${current.currentRound} 轮没有新的有效补充。`).catch(() => { });
153
+ await sendSystemMessage(getI18n(current.locale).labels.discussEndedNoNew(current.currentRound)).catch(() => { });
121
154
  return;
122
155
  }
123
- if (current.currentRound >= current.maxRounds) {
156
+ else if (current.currentRound >= current.maxRounds) {
124
157
  current.status = "completed";
125
158
  this.sessions.delete(current.chatId);
159
+ if (onComplete)
160
+ await onComplete({ chatId: current.chatId, reason: "max_rounds" }).catch(() => { });
126
161
  if (sendSystemMessage)
127
- await sendSystemMessage(`💬 Discuss 已完成:已达到 ${current.maxRounds} 轮。`).catch(() => { });
162
+ await sendSystemMessage(getI18n(current.locale).labels.discussMaxRounds(current.maxRounds)).catch(() => { });
128
163
  return;
129
164
  }
130
165
  current.currentRound += 1;
131
166
  }
132
167
  }
168
+ hasFinalSummaryMarker(text) {
169
+ return /(^|\n)\s*FINAL_SUMMARY\s*[::]/i.test(text) || /(^|\n)\s*最终总结\s*[::]/.test(text);
170
+ }
171
+ buildChairmanPrompt(session, replies, mustFinish) {
172
+ const t = getI18n(session.locale);
173
+ const lines = Object.entries(replies).map(([bot, text]) => `- ${bot}: ${text || "NO_REPLY"}`);
174
+ return t.chairmanPrompt({
175
+ topic: session.topic,
176
+ round: session.currentRound,
177
+ maxRounds: session.maxRounds,
178
+ replies: lines.length ? lines.join("\n") : t.labels.noRegularReplies,
179
+ mustFinish,
180
+ });
181
+ }
133
182
  buildPrompt(session) {
183
+ const t = getI18n(session.locale);
134
184
  const previous = session.completedRounds.length === 0
135
- ? "(暂无,当前是第一轮)"
185
+ ? t.labels.noPreviousRounds
136
186
  : (() => {
137
187
  const round = session.completedRounds[session.completedRounds.length - 1];
138
188
  const lines = Object.entries(round.replies).map(([bot, text]) => `- ${bot}: ${text || "NO_REPLY"}`);
139
189
  return `Round ${round.round}:\n${lines.join("\n")}`;
140
190
  })();
141
- return [
142
- "这是一个多智能体结构化讨论。",
143
- "",
144
- "话题:",
145
- session.topic,
146
- "",
147
- `当前轮次:${session.currentRound}`,
148
- "",
149
- "已完成的轮次:",
191
+ return t.discussParticipantPrompt({
192
+ topic: session.topic,
193
+ round: session.currentRound,
150
194
  previous,
151
- "",
152
- "本轮其他 bot 的回复你暂时看不到,请基于同一份上下文独立给出观点。",
153
- "",
154
- "要求:",
155
- "1. 不要重复前几轮已经说过的观点。",
156
- "2. 只补充新的、有价值的信息。",
157
- "3. 如果没有新东西,回复 NO_REPLY。",
158
- "4. 简洁作答。",
159
- ].join("\n");
195
+ });
160
196
  }
161
197
  }
162
198
  export const discussionManager = new DiscussionManager();
@@ -36,6 +36,7 @@ export declare class FeishuBot {
36
36
  /** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
37
37
  private activeDeliveryTargets;
38
38
  private adminOpenId;
39
+ private locale;
39
40
  private static allBots;
40
41
  constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string);
41
42
  private handleMessageRecalled;
@@ -45,6 +46,8 @@ export declare class FeishuBot {
45
46
  * Format: lma-<botname>-<chatId>
46
47
  */
47
48
  getSessionKey(chatId: string): string;
49
+ private chatLocale;
50
+ private isEn;
48
51
  private lmaBridgePolicy;
49
52
  private injectBridgePolicy;
50
53
  /**
@@ -76,6 +79,7 @@ export declare class FeishuBot {
76
79
  private processQueueInner;
77
80
  private shouldHandleBridgeCommand;
78
81
  private shouldRespond;
82
+ private getRoutingIntent;
79
83
  private isMentioned;
80
84
  private isAllMention;
81
85
  private isAllMentionItem;
@@ -95,8 +99,12 @@ export declare class FeishuBot {
95
99
  private deliverySourceId;
96
100
  private enqueueAndDispatchDelivery;
97
101
  private dispatchPendingDeliveries;
102
+ private hasFreeModeBot;
103
+ private mentionedBotNames;
98
104
  private isDiscussionCoordinator;
99
105
  private getDiscussionParticipants;
106
+ private getChairmanParticipant;
107
+ private asDiscussionParticipant;
100
108
  private runDiscussionTurn;
101
109
  private replyMessage;
102
110
  private extractBridgeAttachments;
@@ -127,7 +135,9 @@ export declare class FeishuBot {
127
135
  * Finds the bot's own reaction of that type and deletes it.
128
136
  */
129
137
  private removeReaction;
138
+ private handleLocaleCommand;
130
139
  private handleDiscussCommand;
140
+ private handleChairmanCommand;
131
141
  /**
132
142
  * Handle /status command: show current session info.
133
143
  */
@@ -1,5 +1,6 @@
1
1
  import * as lark from "@larksuiteoapi/node-sdk";
2
2
  import { createRequire } from "module";
3
+ import { getI18n, normalizeLocale } from "./i18n.js";
3
4
  import { existsSync, readFileSync, statSync } from "fs";
4
5
  import { basename, extname, resolve } from "path";
5
6
  import { getBridgeAttachmentsDir } from "./paths.js";
@@ -44,12 +45,14 @@ export class FeishuBot {
44
45
  /** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
45
46
  activeDeliveryTargets = new Map();
46
47
  adminOpenId;
48
+ locale;
47
49
  static allBots = new Map();
48
50
  constructor(config, openclawClient, store, adminOpenId) {
49
51
  this.config = config;
50
52
  this.openclawClient = openclawClient;
51
53
  this.store = store;
52
54
  this.adminOpenId = adminOpenId || null;
55
+ this.locale = normalizeLocale(config.locale);
53
56
  // Session keys are now per-chat: lma-<botname>-<chatId>
54
57
  this.client = new lark.Client({
55
58
  appId: config.appId,
@@ -100,13 +103,14 @@ export class FeishuBot {
100
103
  getSessionKey(chatId) {
101
104
  return `lma-${this.config.name.toLowerCase()}-${chatId}`;
102
105
  }
106
+ chatLocale(chatId) {
107
+ return normalizeLocale(chatId ? this.store.getChatLocale(chatId) || this.locale : this.locale);
108
+ }
109
+ isEn(chatId) {
110
+ return this.chatLocale(chatId) === "en";
111
+ }
103
112
  lmaBridgePolicy() {
104
- return [
105
- "[LMA bridge policy]",
106
- "你正在 OpenClaw Lark Multi-Agent bridge 会话中。",
107
- "不要调用 message、sessions_send、feishu_im_user_message 或任何主动向飞书/外部聊天发送消息的工具。",
108
- "直接在当前回复中作答;LMA bridge 会负责把最终回复投递回原始飞书群。",
109
- ].join("\n");
113
+ return getI18n(this.locale).bridgePolicy;
110
114
  }
111
115
  async injectBridgePolicy(sessionKey) {
112
116
  await this.openclawClient.injectAssistantMessage({
@@ -384,6 +388,7 @@ export class FeishuBot {
384
388
  }
385
389
  if (!cleanText.trim())
386
390
  return;
391
+ const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "");
387
392
  // Commands may be prefixed by @all / @bot in group chats. Strip those
388
393
  // leading routing mentions before deciding whether this is a bridge command
389
394
  // or an escaped OpenClaw command.
@@ -415,15 +420,34 @@ export class FeishuBot {
415
420
  // Single slash commands are handled by the bridge. Double slash commands were
416
421
  // already unescaped above and should pass through to OpenClaw instead.
417
422
  const isBridgeCommand = !commandText.startsWith("//");
418
- const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode|discuss)/.test(cleanText.trim());
423
+ const isCommand = isBridgeCommand && /^\/(help|status|compact|reset|verbose|free|mute|mode|discuss|chairman|locale)/.test(cleanText.trim());
419
424
  if (isCommand) {
420
425
  // In group chats, most bridge commands must be explicitly routed to this
421
426
  // bot or @all. /discuss is a group-level command, so an unmentioned
422
427
  // /discuss command is handled by one coordinator bot to avoid N replies.
423
428
  const isDiscussCommand = cleanText.trim().startsWith("/discuss");
424
- if (chatType !== "p2p" && !this.shouldHandleBridgeCommand(chatType, message, isBot, message.content)) {
425
- if (!(isDiscussCommand && this.isDiscussionCoordinator()))
429
+ const isChairmanCommand = cleanText.trim().startsWith("/chairman");
430
+ const isLocaleCommand = cleanText.trim().startsWith("/locale");
431
+ if (chatType !== "p2p") {
432
+ if (isChairmanCommand || isLocaleCommand) {
433
+ // Group-level settings; one coordinator handles them.
434
+ if (!this.isDiscussionCoordinator())
435
+ return;
436
+ }
437
+ else if (isDiscussCommand && !routing.hasTargetedMention) {
438
+ // Untargeted or @all /discuss is group-level; one coordinator handles it.
439
+ if (!this.isDiscussionCoordinator())
440
+ return;
441
+ }
442
+ else if (routing.hasTargetedMention) {
443
+ // Explicitly targeted commands belong only to the mentioned bot.
444
+ if (!routing.isCurrentBotMentioned)
445
+ return;
446
+ }
447
+ else if (!routing.isAllMention) {
448
+ // Other bridge commands in a group need an explicit target or @all.
426
449
  return;
450
+ }
427
451
  }
428
452
  const markCommandSynced = () => {
429
453
  if (insertedId > 0) {
@@ -443,10 +467,12 @@ export class FeishuBot {
443
467
  `🧹 /compact — 压缩当前 bot 的 OpenClaw session`,
444
468
  `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
445
469
  `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
446
- `🔓 /free 切换当前 bot 的 free 模式(不 @ 也可回复)`,
470
+ `🔓 /free [on|off] 开关当前 bot 的 free 模式(不 @ 也可回复)`,
447
471
  `🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
448
472
  `🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
449
473
  `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
474
+ `👑 /chairman @Bot|off — 设置/查看/清除本群唯一 Chairman`,
475
+ `🌐 /locale zh|en — 设置/查看当前群语言`,
450
476
  `❓ /help — 显示此帮助信息`,
451
477
  ``,
452
478
  `OpenClaw 原生命令(双斜杠,会转成单斜杠发给 OpenClaw)`,
@@ -509,14 +535,24 @@ export class FeishuBot {
509
535
  markCommandSynced();
510
536
  return;
511
537
  }
538
+ const parts = cleanText.trim().split(/\s+/).filter(Boolean);
539
+ const arg = (parts[1] || "toggle").toLowerCase();
540
+ if (!["toggle", "on", "off"].includes(arg)) {
541
+ await this.replyMessage(messageId, "❌ 用法:/free [on|off]");
542
+ markCommandSynced();
543
+ return;
544
+ }
512
545
  const current = this.store.getBotMode(this.config.name, chatId);
513
- const next = current === "free" ? "normal" : "free";
546
+ const next = arg === "on" ? "free" : arg === "off" ? "normal" : current === "free" ? "normal" : "free";
514
547
  this.store.setBotMode(this.config.name, chatId, next);
515
548
  if (next === "free") {
516
- await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式\n不需要 @ 也可以回复普通人类消息;如果消息明确 @ 了其他 bot 或普通人,我不会抢答。\n如需多轮自动讨论,请使用群级命令 /discuss on。`);
549
+ await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式
550
+ 不需要 @ 也可以回复普通人类消息;如果消息明确 @ 了其他 bot 或普通人,我不会抢答。
551
+ 如需多轮自动讨论,请使用群级命令 /discuss on。`);
517
552
  }
518
553
  else {
519
- await this.replyMessage(messageId, `🔒 ${this.config.name} 已切换到 normal 模式\n只有明确 @ 我才会回复`);
554
+ await this.replyMessage(messageId, `🔒 ${this.config.name} 已切换到 normal 模式
555
+ 只有明确 @ 我才会回复`);
520
556
  }
521
557
  markCommandSynced();
522
558
  return;
@@ -556,26 +592,53 @@ export class FeishuBot {
556
592
  markCommandSynced();
557
593
  return;
558
594
  }
595
+ if (cleanText.trim().startsWith("/chairman")) {
596
+ await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim());
597
+ markCommandSynced();
598
+ return;
599
+ }
600
+ if (cleanText.trim().startsWith("/locale")) {
601
+ await this.handleLocaleCommand(chatId, chatType, messageId, cleanText.trim());
602
+ markCommandSynced();
603
+ return;
604
+ }
559
605
  }
560
606
  // --- Discuss mode: group-level multi-bot round scheduler. It takes over
561
607
  // plain human messages so normal Free mode does not duplicate Round 1.
562
608
  // Targeted mentions must fall through to normal routing so @GPT still
563
609
  // works while discuss mode is enabled.
564
610
  if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
565
- const mentions = message.mentions || [];
566
- const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
567
- if (!hasTargetedMention) {
611
+ // Discuss mode owns ordinary and @all human messages. Explicit @bot/@human
612
+ // falls through to normal targeted routing.
613
+ if (!routing.hasTargetedMention) {
614
+ const discussionLocale = this.chatLocale(chatId);
615
+ const chairman = this.getChairmanParticipant(chatId);
568
616
  const participants = this.getDiscussionParticipants(chatId);
569
- if (participants.length > 0) {
617
+ if (participants.length > 0 || chairman) {
570
618
  discussionManager.startIfAbsent({
571
619
  chatId,
572
620
  rootMessageId: messageId,
573
621
  topic: cleanText,
574
- maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
622
+ maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
575
623
  participants,
624
+ chairman,
576
625
  sendSystemMessage: async (text) => { await this.sendMessage(chatId, text); },
626
+ locale: discussionLocale,
627
+ onComplete: async (event) => {
628
+ if (event.reason === "chairman_final") {
629
+ this.store.setDiscussMode(event.chatId, false);
630
+ await this.sendMessage(event.chatId, this.isEn(event.chatId)
631
+ ? `💬 Discuss ended: Chairman ${event.chairmanName || ""} completed the final summary. Discuss mode has been turned off automatically.`
632
+ : `💬 Discuss 已结束:Chairman ${event.chairmanName || ""} 已完成总结,已自动关闭 Discuss 模式。`);
633
+ }
634
+ },
577
635
  });
578
636
  }
637
+ else if (this.isDiscussionCoordinator()) {
638
+ await this.sendMessage(chatId, this.isEn(chatId)
639
+ ? "💬 Discuss is on, but there are no free bots or Chairman available. Use /free on or /chairman @Bot first."
640
+ : "💬 Discuss 已开启,但当前没有 free bot 或 Chairman 可参与。请先使用 /free on 或 /chairman @Bot。");
641
+ }
579
642
  if (insertedId > 0)
580
643
  this.store.markSynced(this.config.name, chatId, insertedId);
581
644
  return;
@@ -583,7 +646,7 @@ export class FeishuBot {
583
646
  }
584
647
  // --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
585
648
  if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
586
- if (this.isMentioned(message.mentions || [])) {
649
+ if (routing.isCurrentBotMentioned) {
587
650
  await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
588
651
  if (insertedId > 0) {
589
652
  this.store.markSynced(this.config.name, chatId, insertedId);
@@ -593,7 +656,7 @@ export class FeishuBot {
593
656
  return;
594
657
  }
595
658
  // --- Should this bot respond? ---
596
- if (!this.shouldRespond(chatType, message, isBot, chatId, message.content))
659
+ if (!this.shouldRespond(chatType, message, isBot, chatId, routing))
597
660
  return;
598
661
  if (!isBot && insertedId > 0) {
599
662
  this.store.markPendingTrigger(this.config.name, chatId, insertedId);
@@ -861,34 +924,47 @@ export class FeishuBot {
861
924
  return true;
862
925
  return this.isMentioned(mentions);
863
926
  }
864
- shouldRespond(chatType, message, isBot, chatId, rawText) {
927
+ shouldRespond(chatType, _message, isBot, chatId, routing) {
865
928
  if (chatType === "p2p")
866
929
  return !isBot;
867
- const mentions = message.mentions || [];
868
- // Bot messages: only respond if this bot is mentioned
869
- if (isBot) {
870
- return this.isMentioned(mentions);
871
- }
872
- // @all in text: all bots respond
873
- if (this.isAllMention(rawText, mentions))
874
- return true;
875
- // Check if this bot is explicitly mentioned
876
- if (this.isMentioned(mentions))
930
+ // Bot messages: only respond if this bot is explicitly mentioned.
931
+ if (isBot)
932
+ return routing.isCurrentBotMentioned;
933
+ // @all is an explicit broadcast to all non-muted bots.
934
+ if (routing.isAllMention)
877
935
  return true;
878
- // Targeted mentions are exclusive. If a human mentions another person or
879
- // another bot, free-mode bots must not steal that message. Free mode only
880
- // applies to plain human messages with no targeted mentions.
881
- const hasTargetedMention = mentions.some((m) => !this.isAllMentionItem(m));
882
- if (hasTargetedMention)
936
+ // Explicit targeted mentions are exclusive. If this bot is not one of the
937
+ // mentioned bots, free/chairman must not steal the turn.
938
+ if (routing.hasTargetedMention)
939
+ return routing.isCurrentBotMentioned;
940
+ // Human-only mentions are also exclusive; free/chairman should not jump in.
941
+ if (routing.hasHumanMention)
883
942
  return false;
884
- // No bot mentioned: check current per-bot mode
885
943
  if (chatId) {
886
944
  if (this.store.getBotMode(this.config.name, chatId) === "free")
887
945
  return true;
946
+ // Chairman fallback: if nobody is in free mode, the unique chairman
947
+ // answers ordinary unmentioned messages for the group.
948
+ const chairman = this.store.getChairmanBot(chatId);
949
+ if (chairman === this.config.name && !this.hasFreeModeBot(chatId))
950
+ return true;
888
951
  }
889
- // Default: don't respond without @
890
952
  return false;
891
953
  }
954
+ getRoutingIntent(chatType, message, rawText) {
955
+ const mentions = message.mentions || [];
956
+ const isAllMention = chatType !== "p2p" && this.isAllMention(rawText, mentions);
957
+ const targetedBotNames = this.mentionedBotNames(mentions);
958
+ const hasHumanMention = mentions.some((m) => !this.isAllMentionItem(m) && !this.mentionedBotName(m));
959
+ const hasTargetedMention = targetedBotNames.length > 0 || hasHumanMention;
960
+ return {
961
+ isAllMention,
962
+ targetedBotNames,
963
+ hasTargetedMention,
964
+ hasHumanMention,
965
+ isCurrentBotMentioned: targetedBotNames.includes(this.config.name),
966
+ };
967
+ }
892
968
  isMentioned(mentions) {
893
969
  return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
894
970
  }
@@ -905,14 +981,24 @@ export class FeishuBot {
905
981
  return null;
906
982
  const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
907
983
  for (const bot of candidates) {
908
- if (mention.id?.app_id === bot.config.appId)
984
+ if (mention.id?.app_id && mention.id.app_id === bot.config.appId)
909
985
  return bot.config.name;
910
- if (bot.botOpenId && mention.id?.open_id === bot.botOpenId)
986
+ if (bot.botOpenId && mention.id?.open_id && mention.id.open_id === bot.botOpenId)
911
987
  return bot.config.name;
912
- if (typeof mention.name === "string") {
913
- const n = mention.name.toLowerCase();
914
- const botName = bot.config.name.toLowerCase();
915
- if (n === botName || n.includes(`(${botName})`) || n.includes(`(${botName})`))
988
+ }
989
+ // Name is only a fallback because Feishu should normally provide app_id/open_id.
990
+ // Keep it exact to avoid shared-prefix bots like 万万(GPT) / 万万(Claude)
991
+ // stealing each other's mentions.
992
+ if (typeof mention.name === "string") {
993
+ const raw = mention.name.trim().replace(/^@+/, "").replace(/\s+/g, "").toLowerCase();
994
+ for (const bot of candidates) {
995
+ const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
996
+ const exactNames = [
997
+ botName,
998
+ `万万(${botName})`,
999
+ `万万(${botName})`,
1000
+ ];
1001
+ if (exactNames.includes(raw))
916
1002
  return bot.config.name;
917
1003
  }
918
1004
  }
@@ -1112,6 +1198,16 @@ export class FeishuBot {
1112
1198
  });
1113
1199
  }
1114
1200
  }
1201
+ hasFreeModeBot(chatId) {
1202
+ return Array.from(FeishuBot.allBots.values())
1203
+ .some((bot) => bot.store === this.store && bot.store.getBotMode(bot.config.name, chatId) === "free");
1204
+ }
1205
+ mentionedBotNames(mentions) {
1206
+ const names = mentions
1207
+ .map((mention) => this.mentionedBotName(mention))
1208
+ .filter((name) => Boolean(name));
1209
+ return Array.from(new Set(names));
1210
+ }
1115
1211
  isDiscussionCoordinator() {
1116
1212
  const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
1117
1213
  if (bots.length === 0)
@@ -1119,12 +1215,23 @@ export class FeishuBot {
1119
1215
  return bots[0] === this;
1120
1216
  }
1121
1217
  getDiscussionParticipants(chatId) {
1218
+ const chairman = this.store.getChairmanBot(chatId);
1122
1219
  return Array.from(FeishuBot.allBots.values())
1123
- .filter((bot) => bot.store === this.store && bot.store.getBotMode(bot.config.name, chatId) === "free")
1124
- .map((bot) => ({
1220
+ .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) === "free")
1221
+ .map((bot) => this.asDiscussionParticipant(bot, chatId));
1222
+ }
1223
+ getChairmanParticipant(chatId) {
1224
+ const chairman = this.store.getChairmanBot(chatId);
1225
+ if (!chairman)
1226
+ return undefined;
1227
+ const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman);
1228
+ return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
1229
+ }
1230
+ asDiscussionParticipant(bot, chatId) {
1231
+ return {
1125
1232
  name: bot.config.name,
1126
1233
  runDiscussionTurn: async (_chatId, prompt, meta) => bot.runDiscussionTurn(chatId, prompt, meta),
1127
- }));
1234
+ };
1128
1235
  }
1129
1236
  async runDiscussionTurn(chatId, prompt, meta) {
1130
1237
  const sessionKey = await this.ensureSession(chatId);
@@ -1151,8 +1258,12 @@ export class FeishuBot {
1151
1258
  const rawVisibleReply = parsedReply.text.trim();
1152
1259
  const discussionMarkerPattern = /\n*—— 第 \d+\/\d+ 轮 · .+$/;
1153
1260
  const cleanVisibleReply = rawVisibleReply.replace(discussionMarkerPattern, "").trim();
1154
- let displayReply = cleanVisibleReply;
1155
- const isVisible = cleanVisibleReply.length > 0 && cleanVisibleReply.toUpperCase() !== "NO_REPLY";
1261
+ const userVisibleReply = cleanVisibleReply
1262
+ .replace(/(^|\n)\s*(FINAL_SUMMARY|CHAIRMAN_NOTE)\s*[::]\s*/gi, "$1")
1263
+ .replace(/(^|\n)\s*最终总结\s*[::]\s*/g, "$1")
1264
+ .trim();
1265
+ let displayReply = userVisibleReply;
1266
+ const isVisible = userVisibleReply.length > 0 && userVisibleReply.toUpperCase() !== "NO_REPLY";
1156
1267
  if (isVisible && meta) {
1157
1268
  const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
1158
1269
  displayReply = `${displayReply}\n\n${roundMarker}`;
@@ -1448,49 +1559,125 @@ export class FeishuBot {
1448
1559
  // ignore
1449
1560
  }
1450
1561
  }
1562
+ async handleLocaleCommand(chatId, chatType, messageId, text) {
1563
+ if (chatType === "p2p") {
1564
+ await this.replyMessage(messageId, "Locale is only configurable in group chats.");
1565
+ return;
1566
+ }
1567
+ const parts = text.split(/\s+/).filter(Boolean);
1568
+ const value = (parts[1] || "").toLowerCase();
1569
+ if (!value) {
1570
+ const locale = this.chatLocale(chatId);
1571
+ await this.replyMessage(messageId, locale === "en" ? "🌐 Current locale: en" : "🌐 当前语言:zh");
1572
+ return;
1573
+ }
1574
+ if (value !== "zh" && value !== "en") {
1575
+ await this.replyMessage(messageId, "Usage: /locale zh|en");
1576
+ return;
1577
+ }
1578
+ this.store.setChatLocale(chatId, value);
1579
+ await this.replyMessage(messageId, value === "en" ? "🌐 Locale set to en" : "🌐 语言已设置为 zh");
1580
+ }
1451
1581
  async handleDiscussCommand(chatId, chatType, messageId, text) {
1452
1582
  if (chatType === "p2p") {
1453
- await this.replyMessage(messageId, "Discuss 模式只在群聊中可用");
1583
+ await this.replyMessage(messageId, "Discuss mode is only available in group chats.");
1454
1584
  return;
1455
1585
  }
1456
1586
  const parts = text.split(/\s+/).filter(Boolean);
1457
1587
  const action = parts[1] || "status";
1458
1588
  if (action === "on") {
1589
+ const chairman = this.store.getChairmanBot(chatId);
1590
+ if (!chairman) {
1591
+ await this.replyMessage(messageId, this.isEn(chatId)
1592
+ ? "❌ You must set a Chairman before enabling Discuss.\nUsage: /chairman @Bot"
1593
+ : "❌ 开启 Discuss 前必须先设置 Chairman。\n用法:/chairman @某个Bot");
1594
+ return;
1595
+ }
1459
1596
  this.store.setDiscussMode(chatId, true);
1460
- await this.replyMessage(messageId, `💬 Discuss 已开启\n参与者:当前群所有 free 模式 bot\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 3}`);
1597
+ await this.replyMessage(messageId, this.isEn(chatId)
1598
+ ? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all free-mode bots in this group + Chairman\nRounds: ${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`
1599
+ : `💬 Discuss 已开启\nChairman:${chairman}\n参与者:当前群所有 free 模式 bot + Chairman\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`);
1461
1600
  return;
1462
1601
  }
1463
1602
  if (action === "off") {
1464
1603
  this.store.setDiscussMode(chatId, false);
1465
1604
  discussionManager.stop(chatId);
1466
- await this.replyMessage(messageId, "💬 Discuss 已关闭");
1605
+ await this.replyMessage(messageId, this.isEn(chatId) ? "💬 Discuss disabled" : "💬 Discuss 已关闭");
1467
1606
  return;
1468
1607
  }
1469
1608
  if (action === "stop") {
1470
1609
  const stopped = discussionManager.stop(chatId);
1471
- await this.replyMessage(messageId, stopped ? "💬 当前 discuss 已停止" : "💬 当前没有运行中的 discuss");
1610
+ await this.replyMessage(messageId, this.isEn(chatId)
1611
+ ? (stopped ? "💬 Current discuss stopped" : "💬 No active discuss")
1612
+ : (stopped ? "💬 当前 discuss 已停止" : "💬 当前没有运行中的 discuss"));
1472
1613
  return;
1473
1614
  }
1474
1615
  if (action === "rounds") {
1475
1616
  const n = Number.parseInt(parts[2] || "", 10);
1476
1617
  if (!Number.isFinite(n)) {
1477
- await this.replyMessage(messageId, "❌ 用法:/discuss rounds <1-10>");
1618
+ await this.replyMessage(messageId, "❌ Usage: /discuss rounds <1-10>");
1478
1619
  return;
1479
1620
  }
1480
1621
  this.store.setDiscussMaxRounds(chatId, n);
1481
- await this.replyMessage(messageId, `💬 Discuss 轮数已设置为 ${this.store.getChatInfo(chatId)?.discussMaxRounds || n}`);
1622
+ await this.replyMessage(messageId, this.isEn(chatId)
1623
+ ? `💬 Discuss rounds set to ${this.store.getChatInfo(chatId)?.discussMaxRounds || n}`
1624
+ : `💬 Discuss 轮数已设置为 ${this.store.getChatInfo(chatId)?.discussMaxRounds || n}`);
1482
1625
  return;
1483
1626
  }
1484
1627
  const info = this.store.getChatInfo(chatId);
1485
1628
  const active = discussionManager.status(chatId);
1486
1629
  const participants = this.getDiscussionParticipants(chatId).map((p) => p.name);
1487
- await this.replyMessage(messageId, [
1630
+ await this.replyMessage(messageId, this.isEn(chatId) ? [
1488
1631
  `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
1489
- `轮数:${info?.discussMaxRounds || 3}`,
1490
- `参与者:${participants.length ? participants.join(", ") : "(无 free bot)"}`,
1632
+ `Rounds: ${info?.discussMaxRounds || 10}`,
1633
+ `Chairman: ${info?.chairmanBot || "not set"}`,
1634
+ `Participants: ${participants.length ? participants.join(", ") : "(no free bot / chairman)"}`,
1635
+ active ? `Active: round ${active.currentRound}/${active.maxRounds}, topic=${active.topic.slice(0, 80)}` : "Active: none",
1636
+ ].join("\n") : [
1637
+ `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
1638
+ `轮数:${info?.discussMaxRounds || 10}`,
1639
+ `Chairman:${info?.chairmanBot || "未设置"}`,
1640
+ `参与者:${participants.length ? participants.join(", ") : "(无 free bot / chairman)"}`,
1491
1641
  active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
1492
1642
  ].join("\n"));
1493
1643
  }
1644
+ async handleChairmanCommand(chatId, chatType, messageId, mentions, text) {
1645
+ if (chatType === "p2p") {
1646
+ await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
1647
+ return;
1648
+ }
1649
+ const parts = text.split(/\s+/).filter(Boolean);
1650
+ const action = (parts[1] || "status").toLowerCase();
1651
+ if (["off", "clear", "none"].includes(action)) {
1652
+ const previous = this.store.getChairmanBot(chatId);
1653
+ this.store.clearChairmanBot(chatId);
1654
+ await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
1655
+ return;
1656
+ }
1657
+ const botNames = this.mentionedBotNames(mentions);
1658
+ if (botNames.length === 0) {
1659
+ const current = this.store.getChairmanBot(chatId);
1660
+ await this.replyMessage(messageId, current
1661
+ ? `👑 当前 Chairman:${current}\n作用:普通消息无人 free 时由 TA 回答;Discuss 模式下负责主持、调停和最终总结。`
1662
+ : "👑 当前没有 Chairman\n用法:/chairman @某个Bot");
1663
+ return;
1664
+ }
1665
+ if (botNames.length > 1) {
1666
+ await this.replyMessage(messageId, "❌ 一个群只能设置一个 Chairman。请只 @ 一个 bot。");
1667
+ return;
1668
+ }
1669
+ const next = botNames[0];
1670
+ const previous = this.store.getChairmanBot(chatId);
1671
+ this.store.setChairmanBot(chatId, next);
1672
+ const mode = this.store.getBotMode(next, chatId);
1673
+ const lines = [previous && previous !== next ? `✅ Chairman 已从 ${previous} 切换为 ${next}` : `✅ Chairman 已设置为 ${next}`];
1674
+ lines.push("作用:");
1675
+ lines.push("- 当没有 free bot 且普通消息无人被 @ 时,Chairman 会负责回答");
1676
+ lines.push("- Discuss 模式下,Chairman 会参与主持、调停并做最终总结");
1677
+ if (mode === "mute")
1678
+ lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
1679
+ await this.replyMessage(messageId, lines.join("\n"));
1680
+ }
1494
1681
  /**
1495
1682
  * Handle /status command: show current session info.
1496
1683
  */
@@ -1520,6 +1707,13 @@ export class FeishuBot {
1520
1707
  const status = session?.status || "unknown";
1521
1708
  const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
1522
1709
  const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
1710
+ const chairman = chatType === "p2p" ? "" : this.store.getChairmanBot(chatId);
1711
+ const chairmanStatus = chatType === "p2p"
1712
+ ? "不适用"
1713
+ : chairman
1714
+ ? chairman === this.config.name ? `👑 是(${chairman})` : `否(当前:${chairman})`
1715
+ : "未设置";
1716
+ const localeStatus = chatType === "p2p" ? this.locale : this.chatLocale(chatId);
1523
1717
  const statusText = [
1524
1718
  `📊 ${this.config.name} Bot Status`,
1525
1719
  `━━━━━━━━━━━━━━━━━━`,
@@ -1534,6 +1728,8 @@ export class FeishuBot {
1534
1728
  `📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
1535
1729
  `🔧 Verbose: ${verboseStatus}`,
1536
1730
  `🎛️ Mode: ${mode}`,
1731
+ `👑 Chairman: ${chairmanStatus}`,
1732
+ `🌐 Locale: ${localeStatus}`,
1537
1733
  ].join("\n");
1538
1734
  await this.replyMessage(messageId, statusText);
1539
1735
  }
@@ -1593,7 +1789,7 @@ export class FeishuBot {
1593
1789
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
1594
1790
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
1595
1791
  discuss: this.store.getChatInfo(chatId)?.discuss || false,
1596
- discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
1792
+ discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
1597
1793
  updatedAt: Date.now(),
1598
1794
  });
1599
1795
  return;
@@ -1632,7 +1828,7 @@ export class FeishuBot {
1632
1828
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
1633
1829
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
1634
1830
  discuss: this.store.getChatInfo(chatId)?.discuss || false,
1635
- discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 3,
1831
+ discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
1636
1832
  updatedAt: Date.now(),
1637
1833
  });
1638
1834
  console.log(`[${this.config.name}] Cached chat info: ${chatName} (${chatId.slice(-8)})`);
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ export type Locale = "zh" | "en";
2
+ export declare function normalizeLocale(value?: string | null): Locale;
3
+ export declare function isLocale(value: string): value is Locale;
4
+ declare const dict: {
5
+ readonly zh: {
6
+ readonly bridgePolicy: string;
7
+ readonly discussParticipantPrompt: (p: {
8
+ topic: string;
9
+ round: number;
10
+ previous: string;
11
+ }) => string;
12
+ readonly chairmanPrompt: (p: {
13
+ topic: string;
14
+ round: number;
15
+ maxRounds: number;
16
+ replies: string;
17
+ mustFinish: boolean;
18
+ }) => string;
19
+ readonly labels: {
20
+ readonly noPreviousRounds: "(暂无,当前是第一轮)";
21
+ readonly noRegularReplies: "(本轮没有普通参与者发言)";
22
+ readonly noNewReply: "无新增回复";
23
+ readonly error: "出错";
24
+ readonly discussionRoundNotice: (round: number, max: number, parts: string) => string;
25
+ readonly round: "轮";
26
+ readonly discussEndedChairman: (name: string) => string;
27
+ readonly discussEndedNoNew: (round: number) => string;
28
+ readonly discussMaxRounds: (max: number) => string;
29
+ };
30
+ };
31
+ readonly en: {
32
+ readonly bridgePolicy: string;
33
+ readonly discussParticipantPrompt: (p: {
34
+ topic: string;
35
+ round: number;
36
+ previous: string;
37
+ }) => string;
38
+ readonly chairmanPrompt: (p: {
39
+ topic: string;
40
+ round: number;
41
+ maxRounds: number;
42
+ replies: string;
43
+ mustFinish: boolean;
44
+ }) => string;
45
+ readonly labels: {
46
+ readonly noPreviousRounds: "(None yet; this is the first round)";
47
+ readonly noRegularReplies: "(No regular participant replies in this round)";
48
+ readonly noNewReply: "no new reply";
49
+ readonly error: "error";
50
+ readonly discussionRoundNotice: (round: number, max: number, parts: string) => string;
51
+ readonly round: "round";
52
+ readonly discussEndedChairman: (name: string) => string;
53
+ readonly discussEndedNoNew: (round: number) => string;
54
+ readonly discussMaxRounds: (max: number) => string;
55
+ };
56
+ };
57
+ };
58
+ export type I18n = typeof dict[Locale];
59
+ export declare function getI18n(locale?: string | null): I18n;
60
+ export {};
package/dist/i18n.js ADDED
@@ -0,0 +1,140 @@
1
+ export function normalizeLocale(value) {
2
+ const normalized = (value || "").trim().toLowerCase();
3
+ if (normalized.startsWith("en"))
4
+ return "en";
5
+ return "zh";
6
+ }
7
+ export function isLocale(value) {
8
+ return value === "zh" || value === "en";
9
+ }
10
+ const dict = {
11
+ zh: {
12
+ bridgePolicy: [
13
+ "[LMA bridge policy]",
14
+ "你正在 OpenClaw Lark Multi-Agent bridge 会话中。",
15
+ "不要调用 message、sessions_send、feishu_im_user_message 或任何主动向飞书/外部聊天发送消息的工具。",
16
+ "直接在当前回复中作答;LMA bridge 会负责把最终回复投递回原始飞书群。",
17
+ ].join("\n"),
18
+ discussParticipantPrompt: (p) => [
19
+ "这是一个多智能体结构化讨论。",
20
+ "",
21
+ "话题:",
22
+ p.topic,
23
+ "",
24
+ `当前轮次:${p.round}`,
25
+ "",
26
+ "已完成的轮次:",
27
+ p.previous,
28
+ "",
29
+ "本轮其他 bot 的回复你暂时看不到,请基于同一份上下文独立给出观点。",
30
+ "",
31
+ "要求:",
32
+ "1. 不要重复前几轮已经说过的观点。",
33
+ "2. 只补充新的、有价值的信息。",
34
+ "3. 如果没有新东西,回复 NO_REPLY。",
35
+ "4. 简洁作答。",
36
+ ].join("\n"),
37
+ chairmanPrompt: (p) => [
38
+ "这是一个多智能体结构化讨论。你是本群的 Chairman / 主席。",
39
+ "",
40
+ "话题:",
41
+ p.topic,
42
+ "",
43
+ `当前轮次:${p.round}/${p.maxRounds}`,
44
+ "",
45
+ "本轮发言:",
46
+ p.replies || "(本轮没有普通参与者发言)",
47
+ "",
48
+ "你的职责:",
49
+ "1. 先发表你自己的实质观点和判断,不要只做中立转述。",
50
+ "2. 识别大家已经达成的共识。",
51
+ "3. 扮演质疑者:主动检查薄弱证据、跳跃结论、未验证假设、遗漏风险和可能错误。",
52
+ "4. 识别还没解决的关键分歧。",
53
+ "5. 如果观点冲突,要调停并指出下一轮应聚焦的问题。",
54
+ "6. 如果已经足够清楚,或者本轮必须结束,请做最终总结。",
55
+ "",
56
+ p.mustFinish
57
+ ? "本轮必须结束。请以 `FINAL_SUMMARY:` 开头,先给出你的个人判断,再指出你认为仍需警惕的问题/薄弱点,最后给出最终总结、共识、分歧和下一步建议。"
58
+ : "如果应继续讨论,请以 `CHAIRMAN_NOTE:` 开头,先给出你的个人判断,再提出必要质疑(薄弱证据、跳跃结论、未验证假设、遗漏风险),最后简要调停并提出下一轮聚焦问题;如果已经可以结束,请以 `FINAL_SUMMARY:` 开头,先给出你的个人判断和必要质疑,再做最终总结。",
59
+ "",
60
+ "请简洁、有主持感,但必须包含你自己的观点;在需要时要敢于质疑,不要只做中立转述,也不要只重复普通参与者的长篇内容。",
61
+ ].join("\n"),
62
+ labels: {
63
+ noPreviousRounds: "(暂无,当前是第一轮)",
64
+ noRegularReplies: "(本轮没有普通参与者发言)",
65
+ noNewReply: "无新增回复",
66
+ error: "出错",
67
+ discussionRoundNotice: (round, max, parts) => `💬 第 ${round}/${max} 轮:${parts}`,
68
+ round: "轮",
69
+ discussEndedChairman: (name) => `💬 Discuss 已结束:Chairman ${name} 已完成总结。`,
70
+ discussEndedNoNew: (round) => `💬 Discuss 已结束:第 ${round} 轮没有新的有效补充。`,
71
+ discussMaxRounds: (max) => `💬 Discuss 已完成:已达到 ${max} 轮。`,
72
+ },
73
+ },
74
+ en: {
75
+ bridgePolicy: [
76
+ "[LMA bridge policy]",
77
+ "You are in an OpenClaw Lark Multi-Agent bridge session.",
78
+ "Do not call message, sessions_send, feishu_im_user_message, or any proactive external-chat sending tool.",
79
+ "Reply directly in the current assistant response; the LMA bridge will deliver the final reply back to the original Feishu chat.",
80
+ ].join("\n"),
81
+ discussParticipantPrompt: (p) => [
82
+ "This is a structured multi-agent discussion.",
83
+ "",
84
+ "Topic:",
85
+ p.topic,
86
+ "",
87
+ `Current round: ${p.round}`,
88
+ "",
89
+ "Completed rounds:",
90
+ p.previous,
91
+ "",
92
+ "You cannot see other bots' replies in this round yet. Give an independent view based on the same context.",
93
+ "",
94
+ "Rules:",
95
+ "1. Do not repeat points already made in previous rounds.",
96
+ "2. Only add new, useful information.",
97
+ "3. If you have nothing new, reply exactly NO_REPLY.",
98
+ "4. Be concise.",
99
+ ].join("\n"),
100
+ chairmanPrompt: (p) => [
101
+ "This is a structured multi-agent discussion. You are the Chairman of this group.",
102
+ "",
103
+ "Topic:",
104
+ p.topic,
105
+ "",
106
+ `Current round: ${p.round}/${p.maxRounds}`,
107
+ "",
108
+ "This round's replies:",
109
+ p.replies || "(No regular participant replies in this round)",
110
+ "",
111
+ "Your responsibilities:",
112
+ "1. First state your own substantive view and judgment; do not merely summarize neutrally.",
113
+ "2. Identify the consensus already reached.",
114
+ "3. Act as a challenger: inspect weak evidence, logical jumps, unverified assumptions, missed risks, and possible mistakes.",
115
+ "4. Identify unresolved key disagreements.",
116
+ "5. If views conflict, mediate and specify what the next round should focus on.",
117
+ "6. If the discussion is sufficiently clear, or this round must finish, provide a final summary.",
118
+ "",
119
+ p.mustFinish
120
+ ? "This round must finish. Start with `FINAL_SUMMARY:`. First give your own judgment, then point out remaining caveats/weak spots, then provide the final summary, consensus, disagreements, and next steps."
121
+ : "If the discussion should continue, start with `CHAIRMAN_NOTE:`. First give your own judgment, then raise necessary challenges (weak evidence, logical jumps, unverified assumptions, missed risks), then mediate briefly and propose the next focus. If it can end now, start with `FINAL_SUMMARY:`, give your own judgment and necessary challenges first, then provide the final summary.",
122
+ "",
123
+ "Be concise and chair-like, but include your own view. Challenge when needed; do not merely restate participants' long answers.",
124
+ ].join("\n"),
125
+ labels: {
126
+ noPreviousRounds: "(None yet; this is the first round)",
127
+ noRegularReplies: "(No regular participant replies in this round)",
128
+ noNewReply: "no new reply",
129
+ error: "error",
130
+ discussionRoundNotice: (round, max, parts) => `💬 Round ${round}/${max}: ${parts}`,
131
+ round: "round",
132
+ discussEndedChairman: (name) => `💬 Discuss ended: Chairman ${name} completed the final summary.`,
133
+ discussEndedNoNew: (round) => `💬 Discuss ended: round ${round} had no new useful additions.`,
134
+ discussMaxRounds: (max) => `💬 Discuss completed: reached ${max} rounds.`,
135
+ },
136
+ },
137
+ };
138
+ export function getI18n(locale) {
139
+ return dict[normalizeLocale(locale)];
140
+ }
@@ -14,6 +14,9 @@ export interface ChatInfo {
14
14
  verbose: boolean;
15
15
  discuss: boolean;
16
16
  discussMaxRounds: number;
17
+ /** Group-level chairman bot name. Empty means no chairman. */
18
+ chairmanBot?: string;
19
+ locale?: string;
17
20
  updatedAt: number;
18
21
  }
19
22
  export interface ChatMessage {
@@ -101,6 +104,11 @@ export declare class MessageStore {
101
104
  setVerbose(chatId: string, verbose: boolean): void;
102
105
  setDiscussMode(chatId: string, on: boolean): void;
103
106
  setDiscussMaxRounds(chatId: string, rounds: number): void;
107
+ setChatLocale(chatId: string, locale: string): void;
108
+ getChatLocale(chatId: string): string;
109
+ setChairmanBot(chatId: string, botName: string): void;
110
+ clearChairmanBot(chatId: string): void;
111
+ getChairmanBot(chatId: string): string;
104
112
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
105
113
  getBotVerbose(botName: string, chatId: string): boolean;
106
114
  setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
@@ -33,7 +33,9 @@ export class MessageStore {
33
33
  member_names TEXT NOT NULL DEFAULT '',
34
34
  verbose INTEGER NOT NULL DEFAULT 0,
35
35
  discuss INTEGER NOT NULL DEFAULT 0,
36
- discuss_max_rounds INTEGER NOT NULL DEFAULT 3,
36
+ discuss_max_rounds INTEGER NOT NULL DEFAULT 10,
37
+ chairman_bot TEXT NOT NULL DEFAULT '',
38
+ locale TEXT NOT NULL DEFAULT '',
37
39
  updated_at INTEGER NOT NULL DEFAULT 0
38
40
  );
39
41
 
@@ -181,6 +183,20 @@ export class MessageStore {
181
183
  catch {
182
184
  // Column already exists
183
185
  }
186
+ // Migration: add locale column if missing
187
+ try {
188
+ this.db.exec(`ALTER TABLE chat_info ADD COLUMN locale TEXT NOT NULL DEFAULT ''`);
189
+ }
190
+ catch {
191
+ // Column already exists
192
+ }
193
+ // Migration: add chairman column if missing
194
+ try {
195
+ this.db.exec(`ALTER TABLE chat_info ADD COLUMN chairman_bot TEXT NOT NULL DEFAULT ''`);
196
+ }
197
+ catch {
198
+ // Column already exists
199
+ }
184
200
  // Migration: add discuss columns if missing
185
201
  try {
186
202
  this.db.exec(`ALTER TABLE chat_info ADD COLUMN discuss INTEGER NOT NULL DEFAULT 0`);
@@ -189,11 +205,14 @@ export class MessageStore {
189
205
  // Column already exists
190
206
  }
191
207
  try {
192
- this.db.exec(`ALTER TABLE chat_info ADD COLUMN discuss_max_rounds INTEGER NOT NULL DEFAULT 3`);
208
+ this.db.exec(`ALTER TABLE chat_info ADD COLUMN discuss_max_rounds INTEGER NOT NULL DEFAULT 10`);
193
209
  }
194
210
  catch {
195
211
  // Column already exists
196
212
  }
213
+ // Migration: promote the old discuss default (3 rounds) to the new default
214
+ // (10 rounds). Explicit non-default values such as 1/2/5/8 are preserved.
215
+ this.db.prepare(`UPDATE chat_info SET discuss_max_rounds = 10 WHERE discuss_max_rounds = 3`).run();
197
216
  // Migration: add owner_bot column if missing
198
217
  try {
199
218
  this.db.exec(`ALTER TABLE chat_info ADD COLUMN owner_bot TEXT NOT NULL DEFAULT ''`);
@@ -540,8 +559,8 @@ export class MessageStore {
540
559
  // --- Chat info ---
541
560
  upsertChatInfo(info) {
542
561
  this.db.prepare(`
543
- INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, discuss, discuss_max_rounds, updated_at)
544
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
562
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, members, member_names, verbose, free_discussion, owner_bot, discuss, discuss_max_rounds, chairman_bot, locale, updated_at)
563
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
545
564
  ON CONFLICT (chat_id) DO UPDATE SET
546
565
  chat_type = excluded.chat_type,
547
566
  chat_name = excluded.chat_name,
@@ -551,9 +570,11 @@ export class MessageStore {
551
570
  free_discussion = excluded.free_discussion,
552
571
  owner_bot = CASE WHEN excluded.owner_bot != '' THEN excluded.owner_bot ELSE chat_info.owner_bot END,
553
572
  discuss = CASE WHEN excluded.discuss != 0 THEN excluded.discuss ELSE chat_info.discuss END,
554
- discuss_max_rounds = CASE WHEN excluded.discuss_max_rounds != 3 THEN excluded.discuss_max_rounds ELSE chat_info.discuss_max_rounds END,
573
+ discuss_max_rounds = CASE WHEN excluded.discuss_max_rounds != 10 THEN excluded.discuss_max_rounds ELSE chat_info.discuss_max_rounds END,
574
+ chairman_bot = CASE WHEN excluded.chairman_bot != '' THEN excluded.chairman_bot ELSE chat_info.chairman_bot END,
575
+ locale = CASE WHEN excluded.locale != '' THEN excluded.locale ELSE chat_info.locale END,
555
576
  updated_at = excluded.updated_at
556
- `).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);
577
+ `).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 || 10, info.chairmanBot || '', info.locale || '', info.updatedAt);
557
578
  }
558
579
  setFreeDiscussion(chatId, on) {
559
580
  this.db.prepare(`UPDATE chat_info SET free_discussion = ? WHERE chat_id = ?`).run(on ? 1 : 0, chatId);
@@ -566,7 +587,7 @@ export class MessageStore {
566
587
  setDiscussMode(chatId, on) {
567
588
  this.db.prepare(`
568
589
  INSERT INTO chat_info (chat_id, chat_type, chat_name, discuss, discuss_max_rounds, updated_at)
569
- VALUES (?, 'group', '', ?, 3, ?)
590
+ VALUES (?, 'group', '', ?, 10, ?)
570
591
  ON CONFLICT (chat_id) DO UPDATE SET discuss = excluded.discuss, updated_at = excluded.updated_at
571
592
  `).run(chatId, on ? 1 : 0, Date.now());
572
593
  }
@@ -578,6 +599,31 @@ export class MessageStore {
578
599
  ON CONFLICT (chat_id) DO UPDATE SET discuss_max_rounds = excluded.discuss_max_rounds, updated_at = excluded.updated_at
579
600
  `).run(chatId, normalized, Date.now());
580
601
  }
602
+ setChatLocale(chatId, locale) {
603
+ this.db.prepare(`
604
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, locale, updated_at)
605
+ VALUES (?, 'group', '', ?, ?)
606
+ ON CONFLICT (chat_id) DO UPDATE SET locale = excluded.locale, updated_at = excluded.updated_at
607
+ `).run(chatId, locale, Date.now());
608
+ }
609
+ getChatLocale(chatId) {
610
+ const row = this.db.prepare(`SELECT locale FROM chat_info WHERE chat_id = ?`).get(chatId);
611
+ return row?.locale || '';
612
+ }
613
+ setChairmanBot(chatId, botName) {
614
+ this.db.prepare(`
615
+ INSERT INTO chat_info (chat_id, chat_type, chat_name, chairman_bot, updated_at)
616
+ VALUES (?, 'group', '', ?, ?)
617
+ ON CONFLICT (chat_id) DO UPDATE SET chairman_bot = excluded.chairman_bot, updated_at = excluded.updated_at
618
+ `).run(chatId, botName, Date.now());
619
+ }
620
+ clearChairmanBot(chatId) {
621
+ this.setChairmanBot(chatId, '');
622
+ }
623
+ getChairmanBot(chatId) {
624
+ const row = this.db.prepare(`SELECT chairman_bot FROM chat_info WHERE chat_id = ?`).get(chatId);
625
+ return row?.chairman_bot || '';
626
+ }
581
627
  setBotVerbose(botName, chatId, verbose) {
582
628
  this.db.prepare(`
583
629
  INSERT INTO bot_chat_settings (bot_name, chat_id, verbose, updated_at)
@@ -634,7 +680,9 @@ export class MessageStore {
634
680
  freeDiscussion: !!row.free_discussion,
635
681
  verbose: !!row.verbose,
636
682
  discuss: !!row.discuss,
637
- discussMaxRounds: row.discuss_max_rounds || 3,
683
+ discussMaxRounds: row.discuss_max_rounds || 10,
684
+ chairmanBot: row.chairman_bot || '',
685
+ locale: row.locale || '',
638
686
  updatedAt: row.updated_at,
639
687
  };
640
688
  }
@@ -650,7 +698,9 @@ export class MessageStore {
650
698
  freeDiscussion: !!r.free_discussion,
651
699
  verbose: !!r.verbose,
652
700
  discuss: !!r.discuss,
653
- discussMaxRounds: r.discuss_max_rounds || 3,
701
+ discussMaxRounds: r.discuss_max_rounds || 10,
702
+ chairmanBot: r.chairman_bot || '',
703
+ locale: r.locale || '',
654
704
  updatedAt: r.updated_at,
655
705
  }));
656
706
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
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": {