openclaw-lark-multi-agent 1.0.21 → 1.0.23

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";
@@ -15,6 +16,7 @@ const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MA
15
16
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
16
17
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
17
18
  const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
19
+ const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
18
20
  class UnhealthySessionError extends Error {
19
21
  status;
20
22
  constructor(status) {
@@ -62,6 +64,8 @@ export class FeishuBot {
62
64
  locale;
63
65
  configPath;
64
66
  static allBots = new Map();
67
+ /** Chats where this bot app has actually received an event in this process. */
68
+ static seenBotChats = new Map();
65
69
  constructor(config, openclawClient, store, adminOpenId, configPath) {
66
70
  this.config = config;
67
71
  this.openclawClient = openclawClient;
@@ -191,7 +195,17 @@ export class FeishuBot {
191
195
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
192
196
  this.cancelDelayedFailure(chatId);
193
197
  const activeTarget = this.activeDeliveryTargets.get(chatId);
194
- await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, undefined);
198
+ try {
199
+ await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, undefined);
200
+ if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
201
+ await activeTarget?.liveStatus?.complete().catch(() => { });
202
+ }
203
+ }
204
+ catch (err) {
205
+ if (sourceType !== "verbose_transcript")
206
+ await activeTarget?.liveStatus?.fail().catch(() => { });
207
+ throw err;
208
+ }
195
209
  }
196
210
  catch (err) {
197
211
  console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
@@ -329,6 +343,7 @@ export class FeishuBot {
329
343
  const message = event.message;
330
344
  const sender = event.sender;
331
345
  const chatId = message.chat_id;
346
+ this.markCurrentBotSeenInChat(chatId);
332
347
  const chatType = message.chat_type;
333
348
  const messageType = message.message_type;
334
349
  const messageId = message.message_id;
@@ -447,7 +462,7 @@ export class FeishuBot {
447
462
  cleanText = commandText;
448
463
  }
449
464
  const preliminaryCommandName = cleanText.trim().split(/\s+/)[0]?.toLowerCase() || "";
450
- const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
465
+ const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
451
466
  const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
452
467
  // --- Record to local store (ALL messages, before command/response checks) ---
453
468
  const senderName = isBot
@@ -485,6 +500,7 @@ export class FeishuBot {
485
500
  const isChairmanCommand = commandName === "/chairman";
486
501
  const isLocaleCommand = commandName === "/locale";
487
502
  const isModelCommand = commandName === "/model";
503
+ const isHelpCommand = commandName === "/help";
488
504
  const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
489
505
  if (chatType !== "p2p") {
490
506
  const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
@@ -556,6 +572,13 @@ export class FeishuBot {
556
572
  if (!this.isDiscussionCoordinator())
557
573
  return;
558
574
  }
575
+ else if (isHelpCommand && !routing.hasTargetedMention) {
576
+ // Bare or @all /help in a multi-bot group should never be silent or
577
+ // spammy. Let the coordinator answer once instead of requiring users
578
+ // to know they must @ a bot before they can discover the command list.
579
+ if (!this.isDiscussionCoordinator())
580
+ return;
581
+ }
559
582
  else if (isDiscussCommand && !routing.hasTargetedMention) {
560
583
  // Untargeted or @all /discuss is group-level; one coordinator handles it.
561
584
  if (!this.isDiscussionCoordinator())
@@ -591,12 +614,13 @@ export class FeishuBot {
591
614
  `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
592
615
  `⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
593
616
  `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
594
- `🔓 /free [on|off] — 开关当前 bot 的 free 模式(不 @ 也可回复)`,
617
+ `📡 /livestatus [on|off] — 开关非 verbose 下的单条覆盖式运行状态消息(默认开启;/livestatus off 可关闭)`,
618
+ `🔓 /free [on|off] — 开关当前 bot 的 free 模式(仅非 Discuss 模式下影响普通消息主动回复)`,
595
619
  `🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
596
620
  `🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
597
621
  `🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
598
- `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
599
- `👑 /chairman @Bot|off — 设置/查看/清除本群唯一 Chairman`,
622
+ `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论(所有非 mute bot + Chairman 参与,忽略 free)`,
623
+ `👑 /chairman @Bot|off — 设置/清除本群唯一 Chairman(状态见 /status)`,
600
624
  `🌐 /locale zh|en — 设置/查看当前群语言`,
601
625
  `❓ /help — 显示此帮助信息`,
602
626
  ``,
@@ -679,6 +703,18 @@ export class FeishuBot {
679
703
  markCommandSynced();
680
704
  return;
681
705
  }
706
+ if (commandName === "/livestatus") {
707
+ const current = this.store.getBotLiveStatus(this.config.name, chatId);
708
+ const parts = cleanText.trim().split(/\s+/);
709
+ const arg = (parts[1] || "toggle").toLowerCase();
710
+ const next = arg === "on" ? true : arg === "off" ? false : !current;
711
+ this.store.setBotLiveStatus(this.config.name, chatId, next);
712
+ await this.replyMessage(messageId, next
713
+ ? `📡 ${this.config.name} Live Status 已开启\n非 verbose 模式下会用一条可覆盖消息显示运行状态,并在最终回复时覆盖成答案。`
714
+ : `📡 ${this.config.name} Live Status 已关闭\n非 verbose 模式下只发送最终回复。`);
715
+ markCommandSynced();
716
+ return;
717
+ }
682
718
  if (commandName === "/free") {
683
719
  if (chatType === "p2p") {
684
720
  await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
@@ -773,35 +809,37 @@ export class FeishuBot {
773
809
  maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
774
810
  participants,
775
811
  chairman,
776
- sendSystemMessage: async (text) => { await this.sendMessage(chatId, text); },
812
+ sendSystemMessage: async (text) => { await this.sendSystemDelivery(chatId, text); },
777
813
  locale: discussionLocale,
778
814
  onComplete: async (event) => {
779
815
  if (event.reason === "chairman_final") {
780
816
  this.store.setDiscussMode(event.chatId, false);
781
- await this.sendMessage(event.chatId, this.isEn(event.chatId)
817
+ await this.sendSystemDelivery(event.chatId, this.isEn(event.chatId)
782
818
  ? `💬 Discuss ended: Chairman ${event.chairmanName || ""} completed the final summary. Discuss mode has been turned off automatically.`
783
819
  : `💬 Discuss 已结束:Chairman ${event.chairmanName || ""} 已完成总结,已自动关闭 Discuss 模式。`);
784
820
  }
785
821
  },
786
822
  });
787
823
  if (started && preempted) {
788
- await this.sendMessage(chatId, this.isEn(chatId)
824
+ await this.sendSystemDelivery(chatId, this.isEn(chatId)
789
825
  ? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
790
826
  : `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
791
827
  }
792
828
  }
793
829
  else if (this.isDiscussionCoordinator()) {
794
- await this.sendMessage(chatId, this.isEn(chatId)
795
- ? "💬 Discuss is on, but there are no free bots or Chairman available. Use /free on or /chairman @Bot first."
796
- : "💬 Discuss 已开启,但当前没有 free bot 或 Chairman 可参与。请先使用 /free on 或 /chairman @Bot。");
830
+ await this.sendSystemDelivery(chatId, this.isEn(chatId)
831
+ ? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
832
+ : "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
797
833
  }
798
834
  if (insertedId > 0)
799
835
  this.store.markSynced(this.config.name, chatId, insertedId);
800
836
  return;
801
837
  }
802
838
  }
803
- // --- Mute mode: do not forward anything to OpenClaw. Only direct mentions get a local notice. ---
804
- if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute") {
839
+ // --- Mute mode: do not forward ordinary muted bots to OpenClaw. Chairman
840
+ // intentionally outranks mute, so a muted Chairman can still answer/facilitate
841
+ // in Chairman paths.
842
+ if (chatType !== "p2p" && !isBot && this.store.getBotMode(this.config.name, chatId) === "mute" && this.store.getChairmanBot(chatId) !== this.config.name) {
805
843
  if (routing.isCurrentBotMentioned) {
806
844
  await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
807
845
  if (insertedId > 0) {
@@ -821,8 +859,7 @@ export class FeishuBot {
821
859
  const pending = this.pendingAckMessages.get(chatId) || [];
822
860
  const unhealthy = await this.getUnhealthySessionStatus(chatId);
823
861
  if (unhealthy) {
824
- await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
825
- return;
862
+ await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
826
863
  }
827
864
  // Anti-loop
828
865
  const streak = this.store.getBotStreak(chatId, this.config.name);
@@ -860,6 +897,15 @@ export class FeishuBot {
860
897
  console.error(`[${this.config.name}] Error:`, err);
861
898
  }
862
899
  }
900
+ shouldUseLiveStatus(chatId, isNativeCommandTrigger = false) {
901
+ if (isNativeCommandTrigger)
902
+ return false;
903
+ if (this.store.getBotVerbose(this.config.name, chatId))
904
+ return false;
905
+ if (!this.store.getBotLiveStatus(this.config.name, chatId))
906
+ return false;
907
+ return process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS !== "0";
908
+ }
863
909
  async getUnhealthySessionStatus(chatId) {
864
910
  const sessionKey = this.getSessionKey(chatId);
865
911
  try {
@@ -867,9 +913,8 @@ export class FeishuBot {
867
913
  const status = String(resp?.session?.status || "").toLowerCase();
868
914
  if (!status)
869
915
  return null;
870
- if (["killed", "kill", "dead", "crashed", "crash", "failed", "failure", "error", "errored", "invalid"].some((bad) => status.includes(bad))) {
916
+ if (this.isSessionDeadStatus(status))
871
917
  return status;
872
- }
873
918
  }
874
919
  catch {
875
920
  // Absence/transient describe failures should not block a new run; chat.send
@@ -877,24 +922,48 @@ export class FeishuBot {
877
922
  }
878
923
  return null;
879
924
  }
880
- async recoverFromUnhealthySession(chatId, messageId, insertedId, status) {
925
+ /**
926
+ * Distinguish SESSION death from RUN failure. `sessions.describe` reports the
927
+ * status of the most recent run, not whether the session is usable. A run can
928
+ * end as `aborted`/`error`/`failed`/`timeout` (e.g. an LLM idle timeout) while
929
+ * the session itself stays perfectly usable for the next message — confirmed in
930
+ * the wild: LMA reported "killed" but `/status` immediately showed running.
931
+ *
932
+ * Only treat the session as dead for statuses that mean the session itself is
933
+ * gone and a new run cannot start without /reset. Use word-boundary matching so
934
+ * a run-level "error" substring never trips this. Run-level failures are NOT
935
+ * surfaced here: the work promise will either return the (possibly empty) reply
936
+ * or throw, and that is handled by the normal delivery/error path.
937
+ */
938
+ isSessionDeadStatus(status) {
939
+ const s = status.toLowerCase();
940
+ // Session-level death tokens only. Notably excludes aborted/error/errored/
941
+ // failed/failure/timeout, which are run-level outcomes on a live session.
942
+ const deadTokens = ["killed", "dead", "crashed", "destroyed", "terminated", "gone"];
943
+ return deadTokens.some((tok) => new RegExp(`(^|[^a-z])${tok}([^a-z]|$)`).test(s));
944
+ }
945
+ async warnUnhealthySessionAndContinue(chatId, messageId, status) {
946
+ await this.replyMessage(messageId, this.isEn(chatId)
947
+ ? `⚠️ ${this.config.name} session is currently unhealthy (${status}). I will still try this message. If it keeps failing, send /reset and retry.`
948
+ : `⚠️ ${this.config.name} 的 session 当前状态异常(${status})。我会继续尝试处理这条消息;如果连续失败,再发送 /reset 后重试。`);
949
+ console.warn(`[${this.config.name}] unhealthy session ${this.getSessionKey(chatId)} status=${status}; warning user but allowing retry for ${chatId.slice(-8)}`);
950
+ }
951
+ async stopForUnhealthySession(chatId, messageId, status) {
881
952
  const sessionKey = this.getSessionKey(chatId);
882
953
  await this.openclawClient.abortChat(sessionKey).catch(() => { });
883
954
  this.busyChats.set(chatId, 0);
884
- const cleared = this.store.clearAllPendingTriggers(this.config.name, chatId);
885
- if (insertedId > 0)
886
- this.store.markMessagesSynced(this.config.name, chatId, [insertedId], `${this.config.name}:${chatId}:${insertedId}:unhealthy-session`);
887
955
  const acks = this.pendingAckMessages.get(chatId) || [];
956
+ const remainingAcks = [];
888
957
  for (const ack of acks) {
889
958
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
890
- await this.addReaction(ack.messageId, "FAIL").catch(() => { });
959
+ await this.addReaction(ack.messageId, "DONE").catch(() => { });
891
960
  }
892
- this.pendingAckMessages.set(chatId, []);
893
- await this.addReaction(messageId, "FAIL").catch(() => { });
961
+ this.pendingAckMessages.set(chatId, remainingAcks);
962
+ await this.addReaction(messageId, "DONE").catch(() => { });
894
963
  await this.replyMessage(messageId, this.isEn(chatId)
895
- ? `⚠️ ${this.config.name} session is unhealthy (${status}). Cleared ${cleared} pending message(s) and stopped waiting. Please send /reset and retry.`
896
- : `⚠️ ${this.config.name} 的 session 状态异常(${status})。已停止等待、清掉 ${cleared} 条待处理消息并关闭等待反应。请先发送 /reset 后重试。`);
897
- console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status}; cleared ${cleared} pending trigger(s) for ${chatId.slice(-8)}`);
964
+ ? `⚠️ ${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.`
965
+ : `⚠️ ${this.config.name} 的 session 在等待过程中变成异常状态(${status})。我已停止这次等待。你可以直接再发一条继续尝试;如果连续失败,再发送 /reset。`);
966
+ console.warn(`[${this.config.name}] unhealthy session ${sessionKey} status=${status}; stopped current attempt for ${chatId.slice(-8)}`);
898
967
  }
899
968
  async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
900
969
  let done = false;
@@ -906,8 +975,18 @@ export class FeishuBot {
906
975
  break;
907
976
  const unhealthy = await this.getUnhealthySessionStatus(chatId);
908
977
  if (unhealthy) {
909
- await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
910
- throw new UnhealthySessionError(unhealthy);
978
+ // Session status can briefly report killed/aborted while the final
979
+ // response is still racing back to the bridge. Confirm once before
980
+ // surfacing a user-visible failure, otherwise users can see both a
981
+ // successful reply and a misleading "session became unhealthy" notice.
982
+ await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_CONFIRM_MS));
983
+ if (done)
984
+ break;
985
+ const confirmedUnhealthy = await this.getUnhealthySessionStatus(chatId);
986
+ if (confirmedUnhealthy) {
987
+ await this.stopForUnhealthySession(chatId, messageId, confirmedUnhealthy);
988
+ throw new UnhealthySessionError(confirmedUnhealthy);
989
+ }
911
990
  }
912
991
  }
913
992
  return undefined;
@@ -946,12 +1025,11 @@ export class FeishuBot {
946
1025
  if (pendingHumanTriggers.length === 0) {
947
1026
  break;
948
1027
  }
949
- const unhealthy = await this.getUnhealthySessionStatus(chatId);
950
- if (unhealthy) {
951
- const latest = pendingHumanTriggers[pendingHumanTriggers.length - 1];
952
- await this.recoverFromUnhealthySession(chatId, latest.messageId, latest.id || 0, unhealthy);
953
- break;
954
- }
1028
+ // Do not preflight-block a retry just because the previous session status
1029
+ // is still marked unhealthy. handleMessage already warns the user; the
1030
+ // current chat.send attempt should be allowed so users can choose to try
1031
+ // again without being forced through /reset. The in-flight health monitor
1032
+ // below still stops a run that becomes unhealthy while waiting.
955
1033
  this.busyChats.set(chatId, Date.now());
956
1034
  // Pick the batch to process this loop. Consecutive plain human messages are
957
1035
  // merged into a single run; native escaped commands (//x) are processed on
@@ -1080,6 +1158,7 @@ export class FeishuBot {
1080
1158
  }
1081
1159
  }
1082
1160
  let runAcceptedByOpenClaw = false;
1161
+ let liveStatus;
1083
1162
  try {
1084
1163
  const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
1085
1164
  let reply;
@@ -1098,6 +1177,23 @@ export class FeishuBot {
1098
1177
  currentMarkedSubmitted = true;
1099
1178
  }
1100
1179
  };
1180
+ liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
1181
+ ? new LiveStatusController({
1182
+ create: (text) => this.sendOrdered(chatId, () => this.replyTextMessage(lastHuman.messageId || "", text)),
1183
+ edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
1184
+ warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
1185
+ }, {
1186
+ botName: this.config.name,
1187
+ locale: this.isEn(chatId) ? "en" : "zh",
1188
+ disableHint: this.isEn(chatId)
1189
+ ? `Tip: send /livestatus off to hide this status message`
1190
+ : `提示:调用 /livestatus off 关闭该状态提示`,
1191
+ })
1192
+ : undefined;
1193
+ liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
1194
+ const activeTargetForStatus = this.activeDeliveryTargets.get(chatId);
1195
+ if (activeTargetForStatus?.triggerId === triggerId && liveStatus)
1196
+ activeTargetForStatus.liveStatus = liveStatus;
1101
1197
  try {
1102
1198
  reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
1103
1199
  sessionKey,
@@ -1123,6 +1219,7 @@ export class FeishuBot {
1123
1219
  // INSERT OR IGNORE in message_sync.
1124
1220
  markSubmittedBatch(`submitted:${runId}`);
1125
1221
  },
1222
+ onProgress: (event) => liveStatus?.progress(event),
1126
1223
  }));
1127
1224
  }
1128
1225
  finally {
@@ -1145,6 +1242,7 @@ export class FeishuBot {
1145
1242
  // submitted far enough that auto-replaying risks duplicate delivery.
1146
1243
  // The user can resend a new message if they want to retry.
1147
1244
  markSubmittedBatch("empty-returned");
1245
+ liveStatus?.dispose();
1148
1246
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
1149
1247
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1150
1248
  const remainingAcks = [];
@@ -1215,10 +1313,16 @@ export class FeishuBot {
1215
1313
  if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
1216
1314
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
1217
1315
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
1316
+ await liveStatus?.complete().catch(() => { });
1218
1317
  }
1219
1318
  else {
1220
1319
  try {
1320
+ // Final answer always goes through the normal interactive-card
1321
+ // delivery path so Markdown renders correctly. The live status is a
1322
+ // SEPARATE message: once the final reply is enqueued, mark the
1323
+ // status message done.
1221
1324
  await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
1325
+ await liveStatus?.complete();
1222
1326
  // Mark every merged trigger as delivered so retries/restarts do
1223
1327
  // not re-process any of them.
1224
1328
  for (const id of mergedTriggerIds)
@@ -1228,13 +1332,18 @@ export class FeishuBot {
1228
1332
  // enqueueAndDispatchDelivery already sent a user-visible delivery
1229
1333
  // failure. Do not fall through to the generic provider-error path;
1230
1334
  // that creates a second misleading "bot did not complete" message.
1335
+ await liveStatus?.fail().catch(() => { });
1231
1336
  console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
1232
1337
  }
1233
1338
  }
1234
1339
  }
1235
1340
  if (isRuntimeFailure && lastHuman.messageId) {
1341
+ await liveStatus?.fail().catch(() => { });
1236
1342
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1237
1343
  }
1344
+ else if (!shouldReply && !hasAttachments) {
1345
+ liveStatus?.dispose();
1346
+ }
1238
1347
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1239
1348
  // Replace ack reactions with DONE only for trigger messages actually
1240
1349
  // processed in this run. Queued messages that arrived mid-run keep their
@@ -1254,18 +1363,28 @@ export class FeishuBot {
1254
1363
  }
1255
1364
  catch (err) {
1256
1365
  if (err instanceof UnhealthySessionError) {
1366
+ // stopForUnhealthySession already warned the user; mark the live
1367
+ // status as interrupted. The user can send another message to retry
1368
+ // without being forced through /reset.
1369
+ await liveStatus?.fail().catch(() => { });
1257
1370
  console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
1258
1371
  break;
1259
1372
  }
1260
1373
  console.error(`[${this.config.name}] processQueue error:`, err);
1261
1374
  const errorText = this.formatUserVisibleError(err);
1262
1375
  if (lastHuman.messageId) {
1376
+ // Error message goes through the normal delivery path; the live status
1377
+ // is a separate message that we mark interrupted afterwards.
1263
1378
  await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
1264
1379
  .then(() => {
1265
1380
  if (triggerId)
1266
1381
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
1267
1382
  })
1268
1383
  .catch(() => { });
1384
+ await liveStatus?.fail().catch(() => { });
1385
+ }
1386
+ else {
1387
+ await liveStatus?.fail().catch(() => { });
1269
1388
  }
1270
1389
  if (triggerId) {
1271
1390
  // At-most-once: if anything after send attempt failed, this batch has
@@ -1282,7 +1401,7 @@ export class FeishuBot {
1282
1401
  for (const ack of pendingAcks) {
1283
1402
  if (completedTriggerIdSet.has(ack.rowId)) {
1284
1403
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
1285
- await this.addReaction(ack.messageId, "FAIL").catch(() => { });
1404
+ await this.addReaction(ack.messageId, "DONE").catch(() => { });
1286
1405
  }
1287
1406
  else {
1288
1407
  remainingAcks.push(ack);
@@ -1477,7 +1596,7 @@ export class FeishuBot {
1477
1596
  const t = text.trim();
1478
1597
  if (!t)
1479
1598
  return false;
1480
- return /^\/(help|status|compact|reset|stop|verbose|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
1599
+ return /^\/(help|status|compact|reset|stop|verbose|livestatus|free|mute|mode|model|models|discuss|chairman|locale)(\s|$)/i.test(t)
1481
1600
  || this.isBridgeControlReply(t);
1482
1601
  }
1483
1602
  isRuntimeFailureText(text) {
@@ -1540,6 +1659,10 @@ export class FeishuBot {
1540
1659
  deliverySourceId(kind, text) {
1541
1660
  return `${kind}:${this.stableHash(text)}`;
1542
1661
  }
1662
+ async sendSystemDelivery(chatId, text) {
1663
+ const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
1664
+ await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
1665
+ }
1543
1666
  async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
1544
1667
  if (!text.trim() && attachments.length === 0)
1545
1668
  return;
@@ -1608,6 +1731,8 @@ export class FeishuBot {
1608
1731
  this.store.markDeliveryDelivered(item.id);
1609
1732
  }
1610
1733
  catch (err) {
1734
+ if (this.isOutOfChatError(err))
1735
+ this.markCurrentBotUnavailable(chatId, err);
1611
1736
  if (item.id)
1612
1737
  this.store.markDeliveryFailed(item.id);
1613
1738
  const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
@@ -1685,17 +1810,42 @@ export class FeishuBot {
1685
1810
  return true;
1686
1811
  return bots[0] === this;
1687
1812
  }
1813
+ static markBotSeenInChat(botName, chatId) {
1814
+ let set = FeishuBot.seenBotChats.get(botName);
1815
+ if (!set) {
1816
+ set = new Set();
1817
+ FeishuBot.seenBotChats.set(botName, set);
1818
+ }
1819
+ set.add(chatId);
1820
+ }
1821
+ markCurrentBotSeenInChat(chatId) {
1822
+ FeishuBot.markBotSeenInChat(this.config.name, chatId);
1823
+ this.store.markBotSeenInChat(this.config.name, chatId);
1824
+ }
1825
+ isBotAvailableInChat(bot, chatId) {
1826
+ // A bot can be globally configured but not actually installed in this Feishu
1827
+ // group. Discuss participants must be restricted to bots that either have
1828
+ // received an event in this chat during this process, or have previously
1829
+ // delivered a message successfully in this chat. Otherwise LMA may generate
1830
+ // "ghost" participants and Feishu will reject delivery with 230002
1831
+ // (Bot/User can NOT be out of the chat).
1832
+ if (this.store.isBotUnavailableInChat(bot.config.name, chatId))
1833
+ return false;
1834
+ return FeishuBot.seenBotChats.get(bot.config.name)?.has(chatId)
1835
+ || this.store.hasBotSeenInChat(bot.config.name, chatId)
1836
+ || this.store.hasAnyDeliveredToChat(bot.config.name, chatId);
1837
+ }
1688
1838
  getDiscussionParticipants(chatId) {
1689
1839
  const chairman = this.store.getChairmanBot(chatId);
1690
1840
  return Array.from(FeishuBot.allBots.values())
1691
- .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) === "free")
1841
+ .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute" && this.isBotAvailableInChat(bot, chatId))
1692
1842
  .map((bot) => this.asDiscussionParticipant(bot, chatId));
1693
1843
  }
1694
1844
  getChairmanParticipant(chatId) {
1695
1845
  const chairman = this.store.getChairmanBot(chatId);
1696
1846
  if (!chairman)
1697
1847
  return undefined;
1698
- const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman);
1848
+ const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman && this.isBotAvailableInChat(candidate, chatId));
1699
1849
  return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
1700
1850
  }
1701
1851
  asDiscussionParticipant(bot, chatId) {
@@ -1707,6 +1857,21 @@ export class FeishuBot {
1707
1857
  async runDiscussionTurn(chatId, prompt, meta) {
1708
1858
  const sessionKey = await this.ensureSession(chatId);
1709
1859
  const releaseProactiveMute = this.openclawClient.muteProactiveDelivery(sessionKey);
1860
+ const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
1861
+ const liveStatus = liveStatusEnabled
1862
+ ? new LiveStatusController({
1863
+ create: (text) => this.sendOrdered(chatId, () => this.sendTextMessage(chatId, text)),
1864
+ edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
1865
+ warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
1866
+ }, {
1867
+ botName: this.config.name,
1868
+ locale: this.isEn(chatId) ? "en" : "zh",
1869
+ disableHint: this.isEn(chatId)
1870
+ ? `Tip: send /livestatus off to hide this status message`
1871
+ : `提示:调用 /livestatus off 关闭该状态提示`,
1872
+ })
1873
+ : undefined;
1874
+ liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
1710
1875
  let reply;
1711
1876
  try {
1712
1877
  reply = await this.openclawClient.chatSendWithContext({
@@ -1717,8 +1882,13 @@ export class FeishuBot {
1717
1882
  deliver: false,
1718
1883
  timeoutMs: 1_800_000,
1719
1884
  emptyFinalAsNoReply: true,
1885
+ onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
1720
1886
  });
1721
1887
  }
1888
+ catch (err) {
1889
+ await liveStatus?.fail().catch(() => { });
1890
+ throw err;
1891
+ }
1722
1892
  finally {
1723
1893
  // OpenClaw can emit the final assistant session.message shortly after
1724
1894
  // chatSend/collectReply returns. Keep discussion proactive muted briefly;
@@ -1751,36 +1921,90 @@ export class FeishuBot {
1751
1921
  content: storedContent,
1752
1922
  timestamp: Date.now(),
1753
1923
  });
1754
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
1924
+ try {
1925
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
1926
+ }
1927
+ catch (err) {
1928
+ await liveStatus?.fail().catch(() => { });
1929
+ throw err;
1930
+ }
1755
1931
  }
1932
+ await liveStatus?.complete().catch(() => { });
1756
1933
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
1757
1934
  }
1935
+ async sendTextMessage(chatId, text) {
1936
+ try {
1937
+ const res = await this.client.im.message.create({
1938
+ params: { receive_id_type: "chat_id" },
1939
+ data: {
1940
+ receive_id: chatId,
1941
+ content: JSON.stringify({ text }),
1942
+ msg_type: "text",
1943
+ },
1944
+ });
1945
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
1946
+ return res?.data?.message_id || res?.message_id;
1947
+ }
1948
+ catch (err) {
1949
+ if (this.isOutOfChatError(err))
1950
+ this.markCurrentBotUnavailable(chatId, err);
1951
+ throw err;
1952
+ }
1953
+ }
1954
+ async replyTextMessage(messageId, text) {
1955
+ const res = await this.client.im.message.reply({
1956
+ path: { message_id: messageId },
1957
+ data: {
1958
+ content: JSON.stringify({ text }),
1959
+ msg_type: "text",
1960
+ },
1961
+ });
1962
+ return res?.data?.message_id || res?.message_id;
1963
+ }
1964
+ async editTextMessage(messageId, text) {
1965
+ await this.client.im.v1.message.update({
1966
+ path: { message_id: messageId },
1967
+ data: {
1968
+ content: JSON.stringify({ text }),
1969
+ msg_type: "text",
1970
+ },
1971
+ });
1972
+ }
1973
+ async deleteMessageById(messageId) {
1974
+ await this.client.im.v1.message.delete({ path: { message_id: messageId } });
1975
+ }
1758
1976
  async replyMessage(messageId, text) {
1759
1977
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
1760
1978
  const card = this.buildMarkdownCard(text);
1761
1979
  try {
1762
- await this.client.im.message.reply({
1980
+ const res = await this.client.im.message.reply({
1763
1981
  path: { message_id: messageId },
1764
1982
  data: {
1765
1983
  content: JSON.stringify(card),
1766
1984
  msg_type: "interactive",
1767
1985
  },
1768
1986
  });
1987
+ return res?.data?.message_id || res?.message_id;
1769
1988
  }
1770
1989
  catch {
1771
1990
  // Fallback to plain text if card fails
1772
- await this.client.im.message.reply({
1991
+ const res = await this.client.im.message.reply({
1773
1992
  path: { message_id: messageId },
1774
1993
  data: {
1775
1994
  content: JSON.stringify({ text }),
1776
1995
  msg_type: "text",
1777
1996
  },
1778
1997
  });
1998
+ return res?.data?.message_id || res?.message_id;
1779
1999
  }
1780
2000
  }
1781
2001
  extractBridgeAttachments(reply) {
1782
2002
  const attachments = [];
1783
- const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)<\/LMA_BRIDGE_ATTACHMENTS>/g;
2003
+ // Some models occasionally corrupt the closing tag from
2004
+ // </LMA_BRIDGE_ATTACHMENTS> to </parameter> while preserving the exact JSON
2005
+ // payload. Treat that as a recoverable marker shape; otherwise the whole
2006
+ // marker leaks as user-visible text and attachments are not sent.
2007
+ const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
1784
2008
  let text = reply.replace(markerPattern, (_match, jsonText) => {
1785
2009
  try {
1786
2010
  const parsed = JSON.parse(String(jsonText).trim());
@@ -1850,6 +2074,16 @@ export class FeishuBot {
1850
2074
  const msg = data?.msg || data?.message || err?.message || String(err);
1851
2075
  return `${code}${msg}`.slice(0, 800);
1852
2076
  }
2077
+ isOutOfChatError(err) {
2078
+ const data = err?.response?.data || err?.data;
2079
+ const code = data?.code;
2080
+ const msg = data?.msg || data?.message || err?.message || String(err);
2081
+ return Number(code) === 230002 || /Bot\/User can NOT be out of the chat/i.test(String(msg));
2082
+ }
2083
+ markCurrentBotUnavailable(chatId, err) {
2084
+ this.store.markBotUnavailableInChat(this.config.name, chatId, this.errorSummary(err));
2085
+ console.warn(`[${this.config.name}] Marked unavailable in chat ${chatId.slice(-8)} after Feishu out-of-chat error`);
2086
+ }
1853
2087
  stripReadOnlyDocxFields(value) {
1854
2088
  if (Array.isArray(value))
1855
2089
  return value.map((item) => this.stripReadOnlyDocxFields(item));
@@ -1956,7 +2190,7 @@ export class FeishuBot {
1956
2190
  async sendMessage(chatId, text) {
1957
2191
  const card = this.buildMarkdownCard(text);
1958
2192
  try {
1959
- await this.client.im.message.create({
2193
+ const res = await this.client.im.message.create({
1960
2194
  params: { receive_id_type: "chat_id" },
1961
2195
  data: {
1962
2196
  receive_id: chatId,
@@ -1964,12 +2198,16 @@ export class FeishuBot {
1964
2198
  msg_type: "interactive",
1965
2199
  },
1966
2200
  });
2201
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2202
+ return res?.data?.message_id || res?.message_id;
1967
2203
  }
1968
2204
  catch (err) {
1969
2205
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
2206
+ if (this.isOutOfChatError(err))
2207
+ this.markCurrentBotUnavailable(chatId, err);
1970
2208
  // Fallback to plain text
1971
2209
  try {
1972
- await this.client.im.message.create({
2210
+ const res = await this.client.im.message.create({
1973
2211
  params: { receive_id_type: "chat_id" },
1974
2212
  data: {
1975
2213
  receive_id: chatId,
@@ -1977,9 +2215,13 @@ export class FeishuBot {
1977
2215
  msg_type: "text",
1978
2216
  },
1979
2217
  });
2218
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2219
+ return res?.data?.message_id || res?.message_id;
1980
2220
  }
1981
2221
  catch (fallbackErr) {
1982
2222
  console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
2223
+ if (this.isOutOfChatError(fallbackErr))
2224
+ this.markCurrentBotUnavailable(chatId, fallbackErr);
1983
2225
  throw fallbackErr;
1984
2226
  }
1985
2227
  }
@@ -1991,7 +2233,7 @@ export class FeishuBot {
1991
2233
  sendOrdered(chatId, fn) {
1992
2234
  const prev = this.sendQueue.get(chatId) || Promise.resolve();
1993
2235
  const next = prev.then(fn, fn); // run even if previous failed
1994
- this.sendQueue.set(chatId, next);
2236
+ this.sendQueue.set(chatId, next.then(() => undefined, () => undefined));
1995
2237
  return next;
1996
2238
  }
1997
2239
  /**
@@ -2066,8 +2308,8 @@ export class FeishuBot {
2066
2308
  }
2067
2309
  this.store.setDiscussMode(chatId, true);
2068
2310
  await this.replyMessage(messageId, this.isEn(chatId)
2069
- ? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all free-mode bots in this group + Chairman\nRounds: ${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`
2070
- : `💬 Discuss 已开启\nChairman:${chairman}\n参与者:当前群所有 free 模式 bot + Chairman\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`);
2311
+ ? `💬 Discuss enabled\nChairman: ${chairman}\nParticipants: all non-muted bots + Chairman; free mode is ignored in Discuss\nRounds: ${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`
2312
+ : `💬 Discuss 已开启\nChairman:${chairman}\n参与者:当前群所有非 mute bot + Chairman(Discuss 模式忽略 free 开关)\n轮数:${this.store.getChatInfo(chatId)?.discussMaxRounds || 10}`);
2071
2313
  return;
2072
2314
  }
2073
2315
  if (action === "off") {
@@ -2102,13 +2344,13 @@ export class FeishuBot {
2102
2344
  `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
2103
2345
  `Rounds: ${info?.discussMaxRounds || 10}`,
2104
2346
  `Chairman: ${info?.chairmanBot || "not set"}`,
2105
- `Participants: ${participants.length ? participants.join(", ") : "(no free bot / chairman)"}`,
2347
+ `Participants: ${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(none available)"}`,
2106
2348
  active ? `Active: round ${active.currentRound}/${active.maxRounds}, topic=${active.topic.slice(0, 80)}` : "Active: none",
2107
2349
  ].join("\n") : [
2108
2350
  `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
2109
2351
  `轮数:${info?.discussMaxRounds || 10}`,
2110
2352
  `Chairman:${info?.chairmanBot || "未设置"}`,
2111
- `参与者:${participants.length ? participants.join(", ") : "(无 free bot / chairman)"}`,
2353
+ `参与者:${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(无可参与者)"}`,
2112
2354
  active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
2113
2355
  ].join("\n"));
2114
2356
  }
@@ -2154,8 +2396,9 @@ export class FeishuBot {
2154
2396
  const mode = this.store.getBotMode(next, chatId);
2155
2397
  const lines = [previous && previous !== next ? `✅ Chairman 已从 ${previous} 切换为 ${next}` : `✅ Chairman 已设置为 ${next}`];
2156
2398
  lines.push("作用:");
2157
- lines.push("- 当没有 free bot 且普通消息无人被 @ 时,Chairman 会负责回答");
2158
- lines.push("- Discuss 模式下,Chairman 会参与主持、调停并做最终总结");
2399
+ lines.push("- 非 Discuss 模式下:free bot 会主动回答普通消息;没有 free bot 时由 Chairman 兜底");
2400
+ lines.push("- Discuss 模式下:所有非 mute bot + Chairman 参与讨论,free 开关会被忽略");
2401
+ lines.push("- Chairman 优先级高于 mute,会参与兜底回答、主持、调停并做最终总结");
2159
2402
  if (mode === "mute")
2160
2403
  lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
2161
2404
  await this.replyMessage(messageId, lines.join("\n"));
@@ -2221,6 +2464,7 @@ export class FeishuBot {
2221
2464
  const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
2222
2465
  const status = session?.status || "unknown";
2223
2466
  const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
2467
+ const liveStatus = this.store.getBotLiveStatus(this.config.name, chatId) ? "📡 开启" : "📴 关闭";
2224
2468
  const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
2225
2469
  const chairman = chatType === "p2p" ? "" : this.store.getChairmanBot(chatId);
2226
2470
  const chairmanStatus = chatType === "p2p"
@@ -2242,6 +2486,7 @@ export class FeishuBot {
2242
2486
  `🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
2243
2487
  `📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
2244
2488
  `🔧 Verbose: ${verboseStatus}`,
2489
+ `📡 Live Status: ${liveStatus}`,
2245
2490
  `🎛️ Mode: ${mode}`,
2246
2491
  `👑 Chairman: ${chairmanStatus}`,
2247
2492
  `🌐 Locale: ${localeStatus}`,