openclaw-lark-multi-agent 1.0.20 → 1.0.22

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.
@@ -2,6 +2,7 @@ import * as lark from "@larksuiteoapi/node-sdk";
2
2
  import { createRequire } from "module";
3
3
  import { persistBotModel } from "./config.js";
4
4
  import { getI18n, normalizeLocale } from "./i18n.js";
5
+ import { LiveStatusController } from "./live-status.js";
5
6
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
6
7
  import { basename, extname, join, resolve } from "path";
7
8
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
@@ -14,6 +15,15 @@ const MAX_MERGED_TRIGGER_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT
14
15
  const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_BYTES || 128 * 1024);
15
16
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
16
17
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
18
+ const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
19
+ class UnhealthySessionError extends Error {
20
+ status;
21
+ constructor(status) {
22
+ super(`unhealthy session: ${status}`);
23
+ this.status = status;
24
+ this.name = "UnhealthySessionError";
25
+ }
26
+ }
17
27
  /**
18
28
  * Manages a single Feishu bot instance.
19
29
  *
@@ -261,6 +271,10 @@ export class FeishuBot {
261
271
  */
262
272
  async drainOnStartup() {
263
273
  try {
274
+ const restoredDeliveries = this.store.resetStaleDeliveries(this.config.name, 5 * 60_000, 5);
275
+ if (restoredDeliveries.restored > 0 || restoredDeliveries.failed > 0) {
276
+ console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries.restored}, failed=${restoredDeliveries.failed}`);
277
+ }
264
278
  const chats = this.store.getAllChatInfo();
265
279
  const drainTasks = [];
266
280
  for (const chat of chats) {
@@ -273,6 +287,12 @@ export class FeishuBot {
273
287
  // Drain only messages that were explicitly marked as reply triggers.
274
288
  // Context-only messages should not start an OpenClaw run after restart.
275
289
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
290
+ const pendingDeliveries = this.store.getPendingDeliveries(chat.chatId, this.config.name, 1);
291
+ if (pendingDeliveries.length > 0) {
292
+ drainTasks.push(this.dispatchPendingDeliveries(chat.chatId).catch((err) => {
293
+ console.warn(`[${this.config.name}] Startup delivery drain failed for ${chat.chatId.slice(-8)}:`, err.message);
294
+ }));
295
+ }
276
296
  if (pendingTriggerIds.size > 0) {
277
297
  console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
278
298
  // Do not let one slow/stuck chat block startup drain for all other chats.
@@ -350,8 +370,9 @@ export class FeishuBot {
350
370
  }
351
371
  // --- Extract text content based on message type ---
352
372
  let cleanText = "";
373
+ let rawText = "";
353
374
  if (messageType === "text") {
354
- const rawText = content.text || "";
375
+ rawText = content.text || "";
355
376
  cleanText = this.cleanMentions(rawText);
356
377
  // A mention-only text message is still a valid routing trigger. Feishu may
357
378
  // expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
@@ -427,7 +448,7 @@ export class FeishuBot {
427
448
  cleanText = commandText;
428
449
  }
429
450
  const preliminaryCommandName = cleanText.trim().split(/\s+/)[0]?.toLowerCase() || "";
430
- const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
451
+ const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
431
452
  const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
432
453
  // --- Record to local store (ALL messages, before command/response checks) ---
433
454
  const senderName = isBot
@@ -467,9 +488,12 @@ export class FeishuBot {
467
488
  const isModelCommand = commandName === "/model";
468
489
  const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
469
490
  if (chatType !== "p2p") {
491
+ const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
492
+ const singleBotGroup = groupBotCount === 1;
470
493
  if (isModelCommand) {
471
494
  // Model switching is per-bot. @all is rejected by one coordinator;
472
- // otherwise group /model must explicitly target this bot.
495
+ // otherwise group /model must explicitly target this bot. A single-bot
496
+ // group treats the sole bot as the implicit target.
473
497
  if (routing.isAllMention) {
474
498
  if (!this.isDiscussionCoordinator())
475
499
  return;
@@ -478,12 +502,58 @@ export class FeishuBot {
478
502
  if (!routing.isCurrentBotMentioned)
479
503
  return;
480
504
  }
505
+ else if (!singleBotGroup) {
506
+ if (!this.isDiscussionCoordinator())
507
+ return;
508
+ }
509
+ }
510
+ else if (isChairmanCommand) {
511
+ // /chairman @Bot is owned by the mentioned bot itself. This avoids
512
+ // requiring a coordinator to parse another bot's mention metadata,
513
+ // which can vary across Feishu clients/bridges. Untargeted/status,
514
+ // @all, and clear/off remain coordinator-owned group commands.
515
+ //
516
+ // Target resolution uses both Feishu mention metadata AND a text
517
+ // fallback (parsing "@Bot" / "Bot" from the raw message), so a
518
+ // /chairman that arrives without mention metadata still routes to
519
+ // the intended bot instead of silently failing.
520
+ const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
521
+ const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
522
+ const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
523
+ const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
524
+ // A pure status query: no clear keyword, no mention metadata, and no
525
+ // textual argument at all. Anything else is a set/route attempt.
526
+ const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
527
+ if (isClearArg || isStatusArg) {
528
+ // Group-level status/clear: one coordinator handles it.
529
+ if (!this.isDiscussionCoordinator())
530
+ return;
531
+ }
532
+ else if (targets.length >= 1) {
533
+ // Targets resolved: only the (single) targeted bot acts. If
534
+ // multiple distinct targets resolved, the coordinator handles
535
+ // the "only one chairman" error once.
536
+ const uniqueTargets = Array.from(new Set(targets));
537
+ const targetedSelf = uniqueTargets.includes(this.config.name);
538
+ if (uniqueTargets.length > 1) {
539
+ if (!this.isDiscussionCoordinator())
540
+ return;
541
+ }
542
+ else if (!targetedSelf) {
543
+ return;
544
+ }
545
+ }
481
546
  else {
482
- return;
547
+ // A set attempt whose target could not be resolved at all (missing
548
+ // metadata AND no readable name in text): coordinator handles the
549
+ // fallback so the user still gets an actionable reply.
550
+ if (!this.isDiscussionCoordinator())
551
+ return;
483
552
  }
484
553
  }
485
- else if (isChairmanCommand || isLocaleCommand) {
486
- // Group-level settings; one coordinator handles them.
554
+ else if (isLocaleCommand && !routing.hasTargetedMention) {
555
+ // Untargeted or @all locale is a group-level setting; one coordinator handles it.
556
+ // If the user explicitly @s a bot, the targeted bot should execute and answer.
487
557
  if (!this.isDiscussionCoordinator())
488
558
  return;
489
559
  }
@@ -497,8 +567,9 @@ export class FeishuBot {
497
567
  if (!routing.isCurrentBotMentioned)
498
568
  return;
499
569
  }
500
- else if (!routing.isAllMention) {
501
- // Other bridge commands in a group need an explicit target or @all.
570
+ else if (!routing.isAllMention && !singleBotGroup) {
571
+ // Other bridge commands in a multi-bot group need an explicit target or @all.
572
+ // A single-bot group treats the sole bot as the implicit target.
502
573
  return;
503
574
  }
504
575
  }
@@ -521,12 +592,13 @@ export class FeishuBot {
521
592
  `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
522
593
  `⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
523
594
  `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
524
- `🔓 /free [on|off] — 开关当前 bot 的 free 模式(不 @ 也可回复)`,
595
+ `📡 /livestatus [on|off] — 开关非 verbose 下的单条覆盖式运行状态消息(默认开启;/livestatus off 可关闭)`,
596
+ `🔓 /free [on|off] — 开关当前 bot 的 free 模式(仅非 Discuss 模式下影响普通消息主动回复)`,
525
597
  `🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
526
598
  `🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
527
599
  `🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
528
- `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
529
- `👑 /chairman @Bot|off — 设置/查看/清除本群唯一 Chairman`,
600
+ `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论(所有非 mute bot + Chairman 参与,忽略 free)`,
601
+ `👑 /chairman @Bot|off — 设置/清除本群唯一 Chairman(状态见 /status)`,
530
602
  `🌐 /locale zh|en — 设置/查看当前群语言`,
531
603
  `❓ /help — 显示此帮助信息`,
532
604
  ``,
@@ -583,6 +655,14 @@ export class FeishuBot {
583
655
  return;
584
656
  }
585
657
  if (commandName === "/model") {
658
+ if (chatType !== "p2p" && !routing.isAllMention && !routing.hasTargetedMention) {
659
+ const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
660
+ if (groupBotCount > 1) {
661
+ await this.replyMessage(messageId, "❌ /model 是单 bot 设置,请明确 @ 一个 bot,例如:@Claude /model provider/model-id");
662
+ markCommandSynced();
663
+ return;
664
+ }
665
+ }
586
666
  await this.handleModelCommand(chatId, messageId, cleanText.trim());
587
667
  markCommandSynced();
588
668
  return;
@@ -601,6 +681,18 @@ export class FeishuBot {
601
681
  markCommandSynced();
602
682
  return;
603
683
  }
684
+ if (commandName === "/livestatus") {
685
+ const current = this.store.getBotLiveStatus(this.config.name, chatId);
686
+ const parts = cleanText.trim().split(/\s+/);
687
+ const arg = (parts[1] || "toggle").toLowerCase();
688
+ const next = arg === "on" ? true : arg === "off" ? false : !current;
689
+ this.store.setBotLiveStatus(this.config.name, chatId, next);
690
+ await this.replyMessage(messageId, next
691
+ ? `📡 ${this.config.name} Live Status 已开启\n非 verbose 模式下会用一条可覆盖消息显示运行状态,并在最终回复时覆盖成答案。`
692
+ : `📡 ${this.config.name} Live Status 已关闭\n非 verbose 模式下只发送最终回复。`);
693
+ markCommandSynced();
694
+ return;
695
+ }
604
696
  if (commandName === "/free") {
605
697
  if (chatType === "p2p") {
606
698
  await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
@@ -665,7 +757,7 @@ export class FeishuBot {
665
757
  return;
666
758
  }
667
759
  if (commandName === "/chairman") {
668
- await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim());
760
+ await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
669
761
  markCommandSynced();
670
762
  return;
671
763
  }
@@ -687,7 +779,8 @@ export class FeishuBot {
687
779
  const chairman = this.getChairmanParticipant(chatId);
688
780
  const participants = this.getDiscussionParticipants(chatId);
689
781
  if (participants.length > 0 || chairman) {
690
- discussionManager.startIfAbsent({
782
+ const preempted = discussionManager.status(chatId);
783
+ const started = discussionManager.startIfAbsent({
691
784
  chatId,
692
785
  rootMessageId: messageId,
693
786
  topic: cleanText,
@@ -705,19 +798,26 @@ export class FeishuBot {
705
798
  }
706
799
  },
707
800
  });
801
+ if (started && preempted) {
802
+ await this.sendMessage(chatId, this.isEn(chatId)
803
+ ? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
804
+ : `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
805
+ }
708
806
  }
709
807
  else if (this.isDiscussionCoordinator()) {
710
808
  await this.sendMessage(chatId, this.isEn(chatId)
711
- ? "💬 Discuss is on, but there are no free bots or Chairman available. Use /free on or /chairman @Bot first."
712
- : "💬 Discuss 已开启,但当前没有 free bot 或 Chairman 可参与。请先使用 /free on 或 /chairman @Bot。");
809
+ ? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
810
+ : "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
713
811
  }
714
812
  if (insertedId > 0)
715
813
  this.store.markSynced(this.config.name, chatId, insertedId);
716
814
  return;
717
815
  }
718
816
  }
719
- // --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
720
- if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
817
+ // --- Mute mode: do not forward ordinary muted bots to OpenClaw. Chairman
818
+ // intentionally outranks mute, so a muted Chairman can still answer/facilitate
819
+ // in Chairman paths.
820
+ if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute" && this.store.getChairmanBot(chatId) !== this.config.name) {
721
821
  if (routing.isCurrentBotMentioned) {
722
822
  await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
723
823
  if (insertedId > 0) {
@@ -735,6 +835,10 @@ export class FeishuBot {
735
835
  }
736
836
  // Track this message for reaction status updates
737
837
  const pending = this.pendingAckMessages.get(chatId) || [];
838
+ const unhealthy = await this.getUnhealthySessionStatus(chatId);
839
+ if (unhealthy) {
840
+ await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
841
+ }
738
842
  // Anti-loop
739
843
  const streak = this.store.getBotStreak(chatId, this.config.name);
740
844
  if (streak >= MAX_BOT_STREAK) {
@@ -771,6 +875,78 @@ export class FeishuBot {
771
875
  console.error(`[${this.config.name}] Error:`, err);
772
876
  }
773
877
  }
878
+ shouldUseLiveStatus(chatId, isNativeCommandTrigger = false) {
879
+ if (isNativeCommandTrigger)
880
+ return false;
881
+ if (this.store.getBotVerbose(this.config.name, chatId))
882
+ return false;
883
+ if (!this.store.getBotLiveStatus(this.config.name, chatId))
884
+ return false;
885
+ return process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS !== "0";
886
+ }
887
+ async getUnhealthySessionStatus(chatId) {
888
+ const sessionKey = this.getSessionKey(chatId);
889
+ try {
890
+ const resp = await this.openclawClient.getSessionInfo(sessionKey);
891
+ const status = String(resp?.session?.status || "").toLowerCase();
892
+ if (!status)
893
+ return null;
894
+ if (["killed", "kill", "dead", "crashed", "crash", "failed", "failure", "error", "errored", "invalid"].some((bad) => status.includes(bad))) {
895
+ return status;
896
+ }
897
+ }
898
+ catch {
899
+ // Absence/transient describe failures should not block a new run; chat.send
900
+ // will create/report the session as usual.
901
+ }
902
+ return null;
903
+ }
904
+ async warnUnhealthySessionAndContinue(chatId, messageId, status) {
905
+ await this.replyMessage(messageId, this.isEn(chatId)
906
+ ? `⚠️ ${this.config.name} session is currently unhealthy (${status}). I will still try this message. If it keeps failing, send /reset and retry.`
907
+ : `⚠️ ${this.config.name} 的 session 当前状态异常(${status})。我会继续尝试处理这条消息;如果连续失败,再发送 /reset 后重试。`);
908
+ console.warn(`[${this.config.name}] unhealthy session ${this.getSessionKey(chatId)} status=${status}; warning user but allowing retry for ${chatId.slice(-8)}`);
909
+ }
910
+ async stopForUnhealthySession(chatId, messageId, status) {
911
+ const sessionKey = this.getSessionKey(chatId);
912
+ await this.openclawClient.abortChat(sessionKey).catch(() => { });
913
+ this.busyChats.set(chatId, 0);
914
+ const acks = this.pendingAckMessages.get(chatId) || [];
915
+ const remainingAcks = [];
916
+ for (const ack of acks) {
917
+ await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
918
+ await this.addReaction(ack.messageId, "DONE").catch(() => { });
919
+ }
920
+ this.pendingAckMessages.set(chatId, remainingAcks);
921
+ await this.addReaction(messageId, "DONE").catch(() => { });
922
+ await this.replyMessage(messageId, this.isEn(chatId)
923
+ ? `⚠️ ${this.config.name} session became unhealthy while waiting (${status}). I stopped this attempt. You can send another message to try again; if it keeps failing, send /reset.`
924
+ : `⚠️ ${this.config.name} 的 session 在等待过程中变成异常状态(${status})。我已停止这次等待。你可以直接再发一条继续尝试;如果连续失败,再发送 /reset。`);
925
+ console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status}; stopped current attempt for ${chatId.slice(-8)}`);
926
+ }
927
+ async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
928
+ let done = false;
929
+ const monitoredWork = work.finally(() => { done = true; });
930
+ const monitor = (async () => {
931
+ while (!done) {
932
+ await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_POLL_MS));
933
+ if (done)
934
+ break;
935
+ const unhealthy = await this.getUnhealthySessionStatus(chatId);
936
+ if (unhealthy) {
937
+ await this.stopForUnhealthySession(chatId, messageId, unhealthy);
938
+ throw new UnhealthySessionError(unhealthy);
939
+ }
940
+ }
941
+ return undefined;
942
+ })();
943
+ try {
944
+ return await Promise.race([monitoredWork, monitor]);
945
+ }
946
+ finally {
947
+ done = true;
948
+ }
949
+ }
774
950
  /**
775
951
  * Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
776
952
  * Loops until no more unsynced human messages remain.
@@ -798,6 +974,11 @@ export class FeishuBot {
798
974
  if (pendingHumanTriggers.length === 0) {
799
975
  break;
800
976
  }
977
+ // Do not preflight-block a retry just because the previous session status
978
+ // is still marked unhealthy. handleMessage already warns the user; the
979
+ // current chat.send attempt should be allowed so users can choose to try
980
+ // again without being forced through /reset. The in-flight health monitor
981
+ // below still stops a run that becomes unhealthy while waiting.
801
982
  this.busyChats.set(chatId, Date.now());
802
983
  // Pick the batch to process this loop. Consecutive plain human messages are
803
984
  // merged into a single run; native escaped commands (//x) are processed on
@@ -850,6 +1031,17 @@ export class FeishuBot {
850
1031
  console.warn(`[${this.config.name}] Dropped ${droppedMergedTriggers.length} older pending trigger(s) for ${chatId.slice(-8)} due to merge limits (kept=${mergedTriggers.length}, bytes=${bytes}): ${summaries}`);
851
1032
  }
852
1033
  }
1034
+ const deliveredTriggerIds = mergedTriggers
1035
+ .map((m) => m.id || 0)
1036
+ .filter((id) => id > 0 && this.store.hasDeliveredReply(this.config.name, chatId, id));
1037
+ if (deliveredTriggerIds.length > 0) {
1038
+ console.warn(`[${this.config.name}] Clearing ${deliveredTriggerIds.length} stale delivered pending trigger(s) for ${chatId.slice(-8)}: ${deliveredTriggerIds.join(",")}`);
1039
+ for (const id of deliveredTriggerIds)
1040
+ this.store.clearPendingTrigger(this.config.name, chatId, id);
1041
+ mergedTriggers = mergedTriggers.filter((m) => !m.id || !deliveredTriggerIds.includes(m.id));
1042
+ if (mergedTriggers.length === 0)
1043
+ continue;
1044
+ }
853
1045
  const isNativeCommandTrigger = firstIsNative;
854
1046
  const lastHuman = mergedTriggers[mergedTriggers.length - 1];
855
1047
  const triggerId = lastHuman.id || 0;
@@ -915,6 +1107,7 @@ export class FeishuBot {
915
1107
  }
916
1108
  }
917
1109
  let runAcceptedByOpenClaw = false;
1110
+ let liveStatus;
918
1111
  try {
919
1112
  const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
920
1113
  let reply;
@@ -933,8 +1126,22 @@ export class FeishuBot {
933
1126
  currentMarkedSubmitted = true;
934
1127
  }
935
1128
  };
1129
+ liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
1130
+ ? new LiveStatusController({
1131
+ create: (text) => this.sendOrdered(chatId, () => this.replyTextMessage(lastHuman.messageId || "", text)),
1132
+ edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
1133
+ warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
1134
+ }, {
1135
+ botName: this.config.name,
1136
+ locale: this.isEn(chatId) ? "en" : "zh",
1137
+ disableHint: this.isEn(chatId)
1138
+ ? `Tip: send /livestatus off to hide this status message`
1139
+ : `提示:调用 /livestatus off 关闭该状态提示`,
1140
+ })
1141
+ : undefined;
1142
+ liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
936
1143
  try {
937
- reply = await this.openclawClient.chatSendWithContext({
1144
+ reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
938
1145
  sessionKey,
939
1146
  unsyncedMessages: contextMsgs,
940
1147
  currentMessage: mergedContent,
@@ -958,7 +1165,8 @@ export class FeishuBot {
958
1165
  // INSERT OR IGNORE in message_sync.
959
1166
  markSubmittedBatch(`submitted:${runId}`);
960
1167
  },
961
- });
1168
+ onProgress: (event) => liveStatus?.progress(event),
1169
+ }));
962
1170
  }
963
1171
  finally {
964
1172
  // OpenClaw may emit the final assistant session.message just after
@@ -980,6 +1188,7 @@ export class FeishuBot {
980
1188
  // submitted far enough that auto-replaying risks duplicate delivery.
981
1189
  // The user can resend a new message if they want to retry.
982
1190
  markSubmittedBatch("empty-returned");
1191
+ liveStatus?.dispose();
983
1192
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
984
1193
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
985
1194
  const remainingAcks = [];
@@ -1053,7 +1262,12 @@ export class FeishuBot {
1053
1262
  }
1054
1263
  else {
1055
1264
  try {
1265
+ // Final answer always goes through the normal interactive-card
1266
+ // delivery path so Markdown renders correctly. The live status is a
1267
+ // SEPARATE message: once the final reply is enqueued, mark the
1268
+ // status message done.
1056
1269
  await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
1270
+ await liveStatus?.complete();
1057
1271
  // Mark every merged trigger as delivered so retries/restarts do
1058
1272
  // not re-process any of them.
1059
1273
  for (const id of mergedTriggerIds)
@@ -1063,13 +1277,18 @@ export class FeishuBot {
1063
1277
  // enqueueAndDispatchDelivery already sent a user-visible delivery
1064
1278
  // failure. Do not fall through to the generic provider-error path;
1065
1279
  // that creates a second misleading "bot did not complete" message.
1280
+ await liveStatus?.fail().catch(() => { });
1066
1281
  console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
1067
1282
  }
1068
1283
  }
1069
1284
  }
1070
1285
  if (isRuntimeFailure && lastHuman.messageId) {
1286
+ liveStatus?.dispose();
1071
1287
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1072
1288
  }
1289
+ else if (!shouldReply && !hasAttachments) {
1290
+ liveStatus?.dispose();
1291
+ }
1073
1292
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1074
1293
  // Replace ack reactions with DONE only for trigger messages actually
1075
1294
  // processed in this run. Queued messages that arrived mid-run keep their
@@ -1088,15 +1307,29 @@ export class FeishuBot {
1088
1307
  this.pendingAckMessages.set(chatId, remainingAcks);
1089
1308
  }
1090
1309
  catch (err) {
1310
+ if (err instanceof UnhealthySessionError) {
1311
+ // stopForUnhealthySession already warned the user; mark the live
1312
+ // status as interrupted. The user can send another message to retry
1313
+ // without being forced through /reset.
1314
+ await liveStatus?.fail().catch(() => { });
1315
+ console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
1316
+ break;
1317
+ }
1091
1318
  console.error(`[${this.config.name}] processQueue error:`, err);
1092
1319
  const errorText = this.formatUserVisibleError(err);
1093
1320
  if (lastHuman.messageId) {
1321
+ // Error message goes through the normal delivery path; the live status
1322
+ // is a separate message that we mark interrupted afterwards.
1094
1323
  await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
1095
1324
  .then(() => {
1096
1325
  if (triggerId)
1097
1326
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
1098
1327
  })
1099
1328
  .catch(() => { });
1329
+ await liveStatus?.fail().catch(() => { });
1330
+ }
1331
+ else {
1332
+ await liveStatus?.fail().catch(() => { });
1100
1333
  }
1101
1334
  if (triggerId) {
1102
1335
  // At-most-once: if anything after send attempt failed, this batch has
@@ -1113,7 +1346,7 @@ export class FeishuBot {
1113
1346
  for (const ack of pendingAcks) {
1114
1347
  if (completedTriggerIdSet.has(ack.rowId)) {
1115
1348
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
1116
- await this.addReaction(ack.messageId, "FAIL").catch(() => { });
1349
+ await this.addReaction(ack.messageId, "DONE").catch(() => { });
1117
1350
  }
1118
1351
  else {
1119
1352
  remainingAcks.push(ack);
@@ -1185,7 +1418,7 @@ export class FeishuBot {
1185
1418
  return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
1186
1419
  }
1187
1420
  isAllMention(rawText, mentions = []) {
1188
- if (rawText && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
1421
+ if (rawText && /(^|\s)@(?:_all|all|所有人)(?=\s|$)/i.test(rawText))
1189
1422
  return true;
1190
1423
  return mentions.some((m) => this.isAllMentionItem(m));
1191
1424
  }
@@ -1227,7 +1460,7 @@ export class FeishuBot {
1227
1460
  return null;
1228
1461
  }
1229
1462
  escapeRegExp(value) {
1230
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1463
+ return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
1231
1464
  }
1232
1465
  resolveBotName(sender) {
1233
1466
  const openId = sender?.sender_id?.open_id;
@@ -1308,7 +1541,7 @@ export class FeishuBot {
1308
1541
  const t = text.trim();
1309
1542
  if (!t)
1310
1543
  return false;
1311
- return /^\/(help|status|compact|reset|stop|verbose|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
1544
+ return /^\/(help|status|compact|reset|stop|verbose|livestatus|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
1312
1545
  || this.isBridgeControlReply(t);
1313
1546
  }
1314
1547
  isRuntimeFailureText(text) {
@@ -1390,12 +1623,6 @@ export class FeishuBot {
1390
1623
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
1391
1624
  return;
1392
1625
  }
1393
- else if (sourceType === "assistant_visible") {
1394
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["assistant_visible"]))
1395
- return;
1396
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 30, ["assistant_visible"]))
1397
- return;
1398
- }
1399
1626
  const deliveryId = this.store.enqueueDelivery({
1400
1627
  sessionKey: this.deliverySessionKey(chatId),
1401
1628
  chatId,
@@ -1473,6 +1700,49 @@ export class FeishuBot {
1473
1700
  .filter((name) => Boolean(name));
1474
1701
  return Array.from(new Set(names));
1475
1702
  }
1703
+ mentionedBotNamesFromChairmanText(...texts) {
1704
+ const normalizedTexts = texts
1705
+ .filter(Boolean)
1706
+ .map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
1707
+ .filter(Boolean)
1708
+ .map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
1709
+ if (normalizedTexts.length === 0)
1710
+ return [];
1711
+ const names = [];
1712
+ const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
1713
+ for (const raw of normalizedTexts) {
1714
+ for (const bot of candidates) {
1715
+ const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
1716
+ const displayNames = [
1717
+ botName,
1718
+ `@${botName}`,
1719
+ `万万(${botName})`,
1720
+ `@万万(${botName})`,
1721
+ `万万(${botName})`,
1722
+ `@万万(${botName})`,
1723
+ ];
1724
+ if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
1725
+ names.push(bot.config.name);
1726
+ }
1727
+ }
1728
+ }
1729
+ return Array.from(new Set(names));
1730
+ }
1731
+ /**
1732
+ * Resolve the target bot name(s) for a /chairman command using every signal
1733
+ * available, in priority order:
1734
+ * 1. Feishu mention metadata (app_id / open_id / name).
1735
+ * 2. Text fallback: "@Bot" or "Bot" written in the message body.
1736
+ *
1737
+ * This keeps /chairman working even when a Feishu client or bridge omits
1738
+ * mention metadata. Returns a de-duplicated, order-preserving list.
1739
+ */
1740
+ resolveChairmanTargets(mentions, text, rawText = "") {
1741
+ const fromMeta = this.mentionedBotNames(mentions);
1742
+ if (fromMeta.length > 0)
1743
+ return fromMeta;
1744
+ return this.mentionedBotNamesFromChairmanText(text, rawText);
1745
+ }
1476
1746
  isDiscussionCoordinator() {
1477
1747
  const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
1478
1748
  if (bots.length === 0)
@@ -1482,7 +1752,7 @@ export class FeishuBot {
1482
1752
  getDiscussionParticipants(chatId) {
1483
1753
  const chairman = this.store.getChairmanBot(chatId);
1484
1754
  return Array.from(FeishuBot.allBots.values())
1485
- .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) === "free")
1755
+ .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute")
1486
1756
  .map((bot) => this.asDiscussionParticipant(bot, chatId));
1487
1757
  }
1488
1758
  getChairmanParticipant(chatId) {
@@ -1549,27 +1819,51 @@ export class FeishuBot {
1549
1819
  }
1550
1820
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
1551
1821
  }
1822
+ async replyTextMessage(messageId, text) {
1823
+ const res = await this.client.im.message.reply({
1824
+ path: { message_id: messageId },
1825
+ data: {
1826
+ content: JSON.stringify({ text }),
1827
+ msg_type: "text",
1828
+ },
1829
+ });
1830
+ return res?.data?.message_id || res?.message_id;
1831
+ }
1832
+ async editTextMessage(messageId, text) {
1833
+ await this.client.im.v1.message.update({
1834
+ path: { message_id: messageId },
1835
+ data: {
1836
+ content: JSON.stringify({ text }),
1837
+ msg_type: "text",
1838
+ },
1839
+ });
1840
+ }
1841
+ async deleteMessageById(messageId) {
1842
+ await this.client.im.v1.message.delete({ path: { message_id: messageId } });
1843
+ }
1552
1844
  async replyMessage(messageId, text) {
1553
1845
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
1554
1846
  const card = this.buildMarkdownCard(text);
1555
1847
  try {
1556
- await this.client.im.message.reply({
1848
+ const res = await this.client.im.message.reply({
1557
1849
  path: { message_id: messageId },
1558
1850
  data: {
1559
1851
  content: JSON.stringify(card),
1560
1852
  msg_type: "interactive",
1561
1853
  },
1562
1854
  });
1855
+ return res?.data?.message_id || res?.message_id;
1563
1856
  }
1564
1857
  catch {
1565
1858
  // Fallback to plain text if card fails
1566
- await this.client.im.message.reply({
1859
+ const res = await this.client.im.message.reply({
1567
1860
  path: { message_id: messageId },
1568
1861
  data: {
1569
1862
  content: JSON.stringify({ text }),
1570
1863
  msg_type: "text",
1571
1864
  },
1572
1865
  });
1866
+ return res?.data?.message_id || res?.message_id;
1573
1867
  }
1574
1868
  }
1575
1869
  extractBridgeAttachments(reply) {
@@ -1750,7 +2044,7 @@ export class FeishuBot {
1750
2044
  async sendMessage(chatId, text) {
1751
2045
  const card = this.buildMarkdownCard(text);
1752
2046
  try {
1753
- await this.client.im.message.create({
2047
+ const res = await this.client.im.message.create({
1754
2048
  params: { receive_id_type: "chat_id" },
1755
2049
  data: {
1756
2050
  receive_id: chatId,
@@ -1758,12 +2052,13 @@ export class FeishuBot {
1758
2052
  msg_type: "interactive",
1759
2053
  },
1760
2054
  });
2055
+ return res?.data?.message_id || res?.message_id;
1761
2056
  }
1762
2057
  catch (err) {
1763
2058
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
1764
2059
  // Fallback to plain text
1765
2060
  try {
1766
- await this.client.im.message.create({
2061
+ const res = await this.client.im.message.create({
1767
2062
  params: { receive_id_type: "chat_id" },
1768
2063
  data: {
1769
2064
  receive_id: chatId,
@@ -1771,6 +2066,7 @@ export class FeishuBot {
1771
2066
  msg_type: "text",
1772
2067
  },
1773
2068
  });
2069
+ return res?.data?.message_id || res?.message_id;
1774
2070
  }
1775
2071
  catch (fallbackErr) {
1776
2072
  console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
@@ -1785,7 +2081,7 @@ export class FeishuBot {
1785
2081
  sendOrdered(chatId, fn) {
1786
2082
  const prev = this.sendQueue.get(chatId) || Promise.resolve();
1787
2083
  const next = prev.then(fn, fn); // run even if previous failed
1788
- this.sendQueue.set(chatId, next);
2084
+ this.sendQueue.set(chatId, next.then(() => undefined, () => undefined));
1789
2085
  return next;
1790
2086
  }
1791
2087
  /**
@@ -1860,8 +2156,8 @@ export class FeishuBot {
1860
2156
  }
1861
2157
  this.store.setDiscussMode(chatId, true);
1862
2158
  await this.replyMessage(messageId, this.isEn(chatId)
1863
- ? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all free-mode bots in this group + Chairman\nRounds: ${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`
1864
- : `💬 Discuss 已开启\nChairman:${chairman}\n参与者:当前群所有 free 模式 bot + Chairman\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`);
2159
+ ? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all non-muted bots + Chairman; free mode is ignored in Discuss\nRounds: ${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`
2160
+ : `💬 Discuss 已开启\nChairman:${chairman}\n参与者:当前群所有非 mute bot + Chairman(Discuss 模式忽略 free 开关)\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`);
1865
2161
  return;
1866
2162
  }
1867
2163
  if (action === "off") {
@@ -1879,7 +2175,7 @@ export class FeishuBot {
1879
2175
  }
1880
2176
  if (action === "rounds") {
1881
2177
  const n = Number.parseInt(parts[2] || "", 10);
1882
- if (!Number.isFinite(n)) {
2178
+ if (!Number.isInteger(n) || n < 1 || n > 10) {
1883
2179
  await this.replyMessage(messageId, "❌ Usage: /discuss rounds <1-10>");
1884
2180
  return;
1885
2181
  }
@@ -1896,49 +2192,61 @@ export class FeishuBot {
1896
2192
  `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
1897
2193
  `Rounds: ${info?.discussMaxRounds || 10}`,
1898
2194
  `Chairman: ${info?.chairmanBot || "not set"}`,
1899
- `Participants: ${participants.length ? participants.join(", ") : "(no free bot / chairman)"}`,
2195
+ `Participants: ${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(none available)"}`,
1900
2196
  active ? `Active: round ${active.currentRound}/${active.maxRounds}, topic=${active.topic.slice(0, 80)}` : "Active: none",
1901
2197
  ].join("\n") : [
1902
2198
  `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
1903
2199
  `轮数:${info?.discussMaxRounds || 10}`,
1904
2200
  `Chairman:${info?.chairmanBot || "未设置"}`,
1905
- `参与者:${participants.length ? participants.join(", ") : "(无 free bot / chairman)"}`,
2201
+ `参与者:${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(无可参与者)"}`,
1906
2202
  active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
1907
2203
  ].join("\n"));
1908
2204
  }
1909
- async handleChairmanCommand(chatId, chatType, messageId, mentions, text) {
2205
+ async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
1910
2206
  if (chatType === "p2p") {
1911
2207
  await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
1912
2208
  return;
1913
2209
  }
1914
2210
  const parts = text.split(/\s+/).filter(Boolean);
1915
- const action = (parts[1] || "status").toLowerCase();
2211
+ const action = (parts[1] || "").toLowerCase();
1916
2212
  if (["off", "clear", "none"].includes(action)) {
1917
2213
  const previous = this.store.getChairmanBot(chatId);
1918
2214
  this.store.clearChairmanBot(chatId);
1919
2215
  await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
1920
2216
  return;
1921
2217
  }
1922
- const botNames = this.mentionedBotNames(mentions);
1923
- if (botNames.length === 0) {
1924
- const current = this.store.getChairmanBot(chatId);
1925
- await this.replyMessage(messageId, current
1926
- ? `👑 当前 Chairman:${current}\n作用:普通消息无人 free 时由 TA 回答;Discuss 模式下负责主持、调停和最终总结。`
1927
- : "👑 当前没有 Chairman\n用法:/chairman @某个Bot");
2218
+ const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
2219
+ if (resolvedBotNames.length === 0) {
2220
+ // /chairman is intentionally not a status query. Status lives in /status;
2221
+ // this command is only for switching or clearing the Chairman. Keeping the
2222
+ // two meanings separate prevents failed mention parsing from being masked
2223
+ // as a harmless "current status" response.
2224
+ const groupBots = Array.from(FeishuBot.allBots.values())
2225
+ .filter((bot) => bot.store === this.store)
2226
+ .map((bot) => bot.config.name);
2227
+ const uniqueGroupBots = Array.from(new Set(groupBots));
2228
+ const choices = uniqueGroupBots.length > 0
2229
+ ? uniqueGroupBots.map((name) => `/chairman ${name}`).join("\n")
2230
+ : "/chairman @某个Bot";
2231
+ await this.replyMessage(messageId, `❌ /chairman 只用于设置/切换 Chairman,不再用于状态查询。\n` +
2232
+ `查看当前 Chairman 请用 /status。\n` +
2233
+ `切换请 @ 一个 bot,或直接发(可复制):\n${choices}\n` +
2234
+ `清除请用:/chairman off`);
1928
2235
  return;
1929
2236
  }
1930
- if (botNames.length > 1) {
2237
+ if (resolvedBotNames.length > 1) {
1931
2238
  await this.replyMessage(messageId, "❌ 一个群只能设置一个 Chairman。请只 @ 一个 bot。");
1932
2239
  return;
1933
2240
  }
1934
- const next = botNames[0];
2241
+ const next = resolvedBotNames[0];
1935
2242
  const previous = this.store.getChairmanBot(chatId);
1936
2243
  this.store.setChairmanBot(chatId, next);
1937
2244
  const mode = this.store.getBotMode(next, chatId);
1938
2245
  const lines = [previous && previous !== next ? `✅ Chairman 已从 ${previous} 切换为 ${next}` : `✅ Chairman 已设置为 ${next}`];
1939
2246
  lines.push("作用:");
1940
- lines.push("- 当没有 free bot 且普通消息无人被 @ 时,Chairman 会负责回答");
1941
- lines.push("- Discuss 模式下,Chairman 会参与主持、调停并做最终总结");
2247
+ lines.push("- 非 Discuss 模式下:free bot 会主动回答普通消息;没有 free bot 时由 Chairman 兜底");
2248
+ lines.push("- Discuss 模式下:所有非 mute bot + Chairman 参与讨论,free 开关会被忽略");
2249
+ lines.push("- Chairman 优先级高于 mute,会参与兜底回答、主持、调停并做最终总结");
1942
2250
  if (mode === "mute")
1943
2251
  lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
1944
2252
  await this.replyMessage(messageId, lines.join("\n"));
@@ -2004,6 +2312,7 @@ export class FeishuBot {
2004
2312
  const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
2005
2313
  const status = session?.status || "unknown";
2006
2314
  const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
2315
+ const liveStatus = this.store.getBotLiveStatus(this.config.name, chatId) ? "📡 开启" : "📴 关闭";
2007
2316
  const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
2008
2317
  const chairman = chatType === "p2p" ? "" : this.store.getChairmanBot(chatId);
2009
2318
  const chairmanStatus = chatType === "p2p"
@@ -2025,6 +2334,7 @@ export class FeishuBot {
2025
2334
  `🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
2026
2335
  `📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
2027
2336
  `🔧 Verbose: ${verboseStatus}`,
2337
+ `📡 Live Status: ${liveStatus}`,
2028
2338
  `🎛️ Mode: ${mode}`,
2029
2339
  `👑 Chairman: ${chairmanStatus}`,
2030
2340
  `🌐 Locale: ${localeStatus}`,
@@ -2113,7 +2423,7 @@ export class FeishuBot {
2113
2423
  chatName: "私聊",
2114
2424
  members: "",
2115
2425
  memberNames: "",
2116
- ownerBot: this.config.name,
2426
+ ownerBot: existing?.ownerBot || this.config.name,
2117
2427
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
2118
2428
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
2119
2429
  discuss: this.store.getChatInfo(chatId)?.discuss || false,