openclaw-lark-multi-agent 1.0.21 → 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.
@@ -73,8 +73,10 @@ export declare class FeishuBot {
73
73
  static getByName(name: string): FeishuBot | undefined;
74
74
  static findByOpenId(openId: string): FeishuBot | undefined;
75
75
  private handleMessage;
76
+ private shouldUseLiveStatus;
76
77
  private getUnhealthySessionStatus;
77
- private recoverFromUnhealthySession;
78
+ private warnUnhealthySessionAndContinue;
79
+ private stopForUnhealthySession;
78
80
  private withSessionHealthMonitor;
79
81
  /**
80
82
  * Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
@@ -125,6 +127,9 @@ export declare class FeishuBot {
125
127
  private getChairmanParticipant;
126
128
  private asDiscussionParticipant;
127
129
  private runDiscussionTurn;
130
+ private replyTextMessage;
131
+ private editTextMessage;
132
+ private deleteMessageById;
128
133
  private replyMessage;
129
134
  private extractBridgeAttachments;
130
135
  private pushBridgeAttachment;
@@ -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";
@@ -447,7 +448,7 @@ export class FeishuBot {
447
448
  cleanText = commandText;
448
449
  }
449
450
  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"]);
451
+ const preliminaryBridgeCommands = new Set(["/help", "/status", "/compact", "/reset", "/stop", "/verbose", "/livestatus", "/free", "/mute", "/mode", "/model", "/models", "/discuss", "/chairman", "/locale"]);
451
452
  const isPreliminaryBridgeCommand = !isNativeOpenClawCommand && preliminaryBridgeCommands.has(preliminaryCommandName);
452
453
  // --- Record to local store (ALL messages, before command/response checks) ---
453
454
  const senderName = isBot
@@ -591,12 +592,13 @@ export class FeishuBot {
591
592
  `🔄 /reset — 重置当前 bot 的 OpenClaw session`,
592
593
  `⏹️ /stop — 强制停止当前 bot 在本聊天的卡死 run,解锁队列`,
593
594
  `🔊 /verbose — 开关当前聊天里的 Tool Call 显示`,
594
- `🔓 /free [on|off] — 开关当前 bot 的 free 模式(不 @ 也可回复)`,
595
+ `📡 /livestatus [on|off] — 开关非 verbose 下的单条覆盖式运行状态消息(默认开启;/livestatus off 可关闭)`,
596
+ `🔓 /free [on|off] — 开关当前 bot 的 free 模式(仅非 Discuss 模式下影响普通消息主动回复)`,
595
597
  `🤐 /mute — 切换当前 bot 的 mute 模式(禁言,不转发 OpenClaw)`,
596
598
  `🎛️ /mode — 查看当前 bot 在当前群聊的模式`,
597
599
  `🤖 /model [id] — 查看/切换当前 bot 绑定模型(持久化)`,
598
- `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论`,
599
- `👑 /chairman @Bot|off — 设置/查看/清除本群唯一 Chairman`,
600
+ `💬 /discuss on|off|status|stop|rounds N — 群级多 bot 连续讨论(所有非 mute bot + Chairman 参与,忽略 free)`,
601
+ `👑 /chairman @Bot|off — 设置/清除本群唯一 Chairman(状态见 /status)`,
600
602
  `🌐 /locale zh|en — 设置/查看当前群语言`,
601
603
  `❓ /help — 显示此帮助信息`,
602
604
  ``,
@@ -679,6 +681,18 @@ export class FeishuBot {
679
681
  markCommandSynced();
680
682
  return;
681
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
+ }
682
696
  if (commandName === "/free") {
683
697
  if (chatType === "p2p") {
684
698
  await this.replyMessage(messageId, "❌ Free 模式只在群聊中可用");
@@ -792,16 +806,18 @@ export class FeishuBot {
792
806
  }
793
807
  else if (this.isDiscussionCoordinator()) {
794
808
  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。");
809
+ ? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
810
+ : "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
797
811
  }
798
812
  if (insertedId > 0)
799
813
  this.store.markSynced(this.config.name, chatId, insertedId);
800
814
  return;
801
815
  }
802
816
  }
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") {
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) {
805
821
  if (routing.isCurrentBotMentioned) {
806
822
  await this.replyMessage(messageId, `🤐 ${this.config.name} 当前处于 mute 模式,发送 /mute 可解除`);
807
823
  if (insertedId > 0) {
@@ -821,8 +837,7 @@ export class FeishuBot {
821
837
  const pending = this.pendingAckMessages.get(chatId) || [];
822
838
  const unhealthy = await this.getUnhealthySessionStatus(chatId);
823
839
  if (unhealthy) {
824
- await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
825
- return;
840
+ await this.warnUnhealthySessionAndContinue(chatId, messageId, unhealthy);
826
841
  }
827
842
  // Anti-loop
828
843
  const streak = this.store.getBotStreak(chatId, this.config.name);
@@ -860,6 +875,15 @@ export class FeishuBot {
860
875
  console.error(`[${this.config.name}] Error:`, err);
861
876
  }
862
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
+ }
863
887
  async getUnhealthySessionStatus(chatId) {
864
888
  const sessionKey = this.getSessionKey(chatId);
865
889
  try {
@@ -877,24 +901,28 @@ export class FeishuBot {
877
901
  }
878
902
  return null;
879
903
  }
880
- async recoverFromUnhealthySession(chatId, messageId, insertedId, status) {
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) {
881
911
  const sessionKey = this.getSessionKey(chatId);
882
912
  await this.openclawClient.abortChat(sessionKey).catch(() => { });
883
913
  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
914
  const acks = this.pendingAckMessages.get(chatId) || [];
915
+ const remainingAcks = [];
888
916
  for (const ack of acks) {
889
917
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
890
- await this.addReaction(ack.messageId, "FAIL").catch(() => { });
918
+ await this.addReaction(ack.messageId, "DONE").catch(() => { });
891
919
  }
892
- this.pendingAckMessages.set(chatId, []);
893
- await this.addReaction(messageId, "FAIL").catch(() => { });
920
+ this.pendingAckMessages.set(chatId, remainingAcks);
921
+ await this.addReaction(messageId, "DONE").catch(() => { });
894
922
  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)}`);
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)}`);
898
926
  }
899
927
  async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
900
928
  let done = false;
@@ -906,7 +934,7 @@ export class FeishuBot {
906
934
  break;
907
935
  const unhealthy = await this.getUnhealthySessionStatus(chatId);
908
936
  if (unhealthy) {
909
- await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
937
+ await this.stopForUnhealthySession(chatId, messageId, unhealthy);
910
938
  throw new UnhealthySessionError(unhealthy);
911
939
  }
912
940
  }
@@ -946,12 +974,11 @@ export class FeishuBot {
946
974
  if (pendingHumanTriggers.length === 0) {
947
975
  break;
948
976
  }
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
- }
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.
955
982
  this.busyChats.set(chatId, Date.now());
956
983
  // Pick the batch to process this loop. Consecutive plain human messages are
957
984
  // merged into a single run; native escaped commands (//x) are processed on
@@ -1080,6 +1107,7 @@ export class FeishuBot {
1080
1107
  }
1081
1108
  }
1082
1109
  let runAcceptedByOpenClaw = false;
1110
+ let liveStatus;
1083
1111
  try {
1084
1112
  const releaseActiveDeliveryTarget = lastHuman.messageId ? this.setActiveDeliveryTarget(chatId, triggerId, lastHuman.messageId) : () => { };
1085
1113
  let reply;
@@ -1098,6 +1126,20 @@ export class FeishuBot {
1098
1126
  currentMarkedSubmitted = true;
1099
1127
  }
1100
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 回复");
1101
1143
  try {
1102
1144
  reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
1103
1145
  sessionKey,
@@ -1123,6 +1165,7 @@ export class FeishuBot {
1123
1165
  // INSERT OR IGNORE in message_sync.
1124
1166
  markSubmittedBatch(`submitted:${runId}`);
1125
1167
  },
1168
+ onProgress: (event) => liveStatus?.progress(event),
1126
1169
  }));
1127
1170
  }
1128
1171
  finally {
@@ -1145,6 +1188,7 @@ export class FeishuBot {
1145
1188
  // submitted far enough that auto-replaying risks duplicate delivery.
1146
1189
  // The user can resend a new message if they want to retry.
1147
1190
  markSubmittedBatch("empty-returned");
1191
+ liveStatus?.dispose();
1148
1192
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
1149
1193
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1150
1194
  const remainingAcks = [];
@@ -1218,7 +1262,12 @@ export class FeishuBot {
1218
1262
  }
1219
1263
  else {
1220
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.
1221
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();
1222
1271
  // Mark every merged trigger as delivered so retries/restarts do
1223
1272
  // not re-process any of them.
1224
1273
  for (const id of mergedTriggerIds)
@@ -1228,13 +1277,18 @@ export class FeishuBot {
1228
1277
  // enqueueAndDispatchDelivery already sent a user-visible delivery
1229
1278
  // failure. Do not fall through to the generic provider-error path;
1230
1279
  // that creates a second misleading "bot did not complete" message.
1280
+ await liveStatus?.fail().catch(() => { });
1231
1281
  console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
1232
1282
  }
1233
1283
  }
1234
1284
  }
1235
1285
  if (isRuntimeFailure && lastHuman.messageId) {
1286
+ liveStatus?.dispose();
1236
1287
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1237
1288
  }
1289
+ else if (!shouldReply && !hasAttachments) {
1290
+ liveStatus?.dispose();
1291
+ }
1238
1292
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1239
1293
  // Replace ack reactions with DONE only for trigger messages actually
1240
1294
  // processed in this run. Queued messages that arrived mid-run keep their
@@ -1254,18 +1308,28 @@ export class FeishuBot {
1254
1308
  }
1255
1309
  catch (err) {
1256
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(() => { });
1257
1315
  console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
1258
1316
  break;
1259
1317
  }
1260
1318
  console.error(`[${this.config.name}] processQueue error:`, err);
1261
1319
  const errorText = this.formatUserVisibleError(err);
1262
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.
1263
1323
  await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`)
1264
1324
  .then(() => {
1265
1325
  if (triggerId)
1266
1326
  this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
1267
1327
  })
1268
1328
  .catch(() => { });
1329
+ await liveStatus?.fail().catch(() => { });
1330
+ }
1331
+ else {
1332
+ await liveStatus?.fail().catch(() => { });
1269
1333
  }
1270
1334
  if (triggerId) {
1271
1335
  // At-most-once: if anything after send attempt failed, this batch has
@@ -1282,7 +1346,7 @@ export class FeishuBot {
1282
1346
  for (const ack of pendingAcks) {
1283
1347
  if (completedTriggerIdSet.has(ack.rowId)) {
1284
1348
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
1285
- await this.addReaction(ack.messageId, "FAIL").catch(() => { });
1349
+ await this.addReaction(ack.messageId, "DONE").catch(() => { });
1286
1350
  }
1287
1351
  else {
1288
1352
  remainingAcks.push(ack);
@@ -1477,7 +1541,7 @@ export class FeishuBot {
1477
1541
  const t = text.trim();
1478
1542
  if (!t)
1479
1543
  return false;
1480
- 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)
1481
1545
  || this.isBridgeControlReply(t);
1482
1546
  }
1483
1547
  isRuntimeFailureText(text) {
@@ -1688,7 +1752,7 @@ export class FeishuBot {
1688
1752
  getDiscussionParticipants(chatId) {
1689
1753
  const chairman = this.store.getChairmanBot(chatId);
1690
1754
  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")
1755
+ .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute")
1692
1756
  .map((bot) => this.asDiscussionParticipant(bot, chatId));
1693
1757
  }
1694
1758
  getChairmanParticipant(chatId) {
@@ -1755,27 +1819,51 @@ export class FeishuBot {
1755
1819
  }
1756
1820
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
1757
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
+ }
1758
1844
  async replyMessage(messageId, text) {
1759
1845
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
1760
1846
  const card = this.buildMarkdownCard(text);
1761
1847
  try {
1762
- await this.client.im.message.reply({
1848
+ const res = await this.client.im.message.reply({
1763
1849
  path: { message_id: messageId },
1764
1850
  data: {
1765
1851
  content: JSON.stringify(card),
1766
1852
  msg_type: "interactive",
1767
1853
  },
1768
1854
  });
1855
+ return res?.data?.message_id || res?.message_id;
1769
1856
  }
1770
1857
  catch {
1771
1858
  // Fallback to plain text if card fails
1772
- await this.client.im.message.reply({
1859
+ const res = await this.client.im.message.reply({
1773
1860
  path: { message_id: messageId },
1774
1861
  data: {
1775
1862
  content: JSON.stringify({ text }),
1776
1863
  msg_type: "text",
1777
1864
  },
1778
1865
  });
1866
+ return res?.data?.message_id || res?.message_id;
1779
1867
  }
1780
1868
  }
1781
1869
  extractBridgeAttachments(reply) {
@@ -1956,7 +2044,7 @@ export class FeishuBot {
1956
2044
  async sendMessage(chatId, text) {
1957
2045
  const card = this.buildMarkdownCard(text);
1958
2046
  try {
1959
- await this.client.im.message.create({
2047
+ const res = await this.client.im.message.create({
1960
2048
  params: { receive_id_type: "chat_id" },
1961
2049
  data: {
1962
2050
  receive_id: chatId,
@@ -1964,12 +2052,13 @@ export class FeishuBot {
1964
2052
  msg_type: "interactive",
1965
2053
  },
1966
2054
  });
2055
+ return res?.data?.message_id || res?.message_id;
1967
2056
  }
1968
2057
  catch (err) {
1969
2058
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
1970
2059
  // Fallback to plain text
1971
2060
  try {
1972
- await this.client.im.message.create({
2061
+ const res = await this.client.im.message.create({
1973
2062
  params: { receive_id_type: "chat_id" },
1974
2063
  data: {
1975
2064
  receive_id: chatId,
@@ -1977,6 +2066,7 @@ export class FeishuBot {
1977
2066
  msg_type: "text",
1978
2067
  },
1979
2068
  });
2069
+ return res?.data?.message_id || res?.message_id;
1980
2070
  }
1981
2071
  catch (fallbackErr) {
1982
2072
  console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
@@ -1991,7 +2081,7 @@ export class FeishuBot {
1991
2081
  sendOrdered(chatId, fn) {
1992
2082
  const prev = this.sendQueue.get(chatId) || Promise.resolve();
1993
2083
  const next = prev.then(fn, fn); // run even if previous failed
1994
- this.sendQueue.set(chatId, next);
2084
+ this.sendQueue.set(chatId, next.then(() => undefined, () => undefined));
1995
2085
  return next;
1996
2086
  }
1997
2087
  /**
@@ -2066,8 +2156,8 @@ export class FeishuBot {
2066
2156
  }
2067
2157
  this.store.setDiscussMode(chatId, true);
2068
2158
  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}`);
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}`);
2071
2161
  return;
2072
2162
  }
2073
2163
  if (action === "off") {
@@ -2102,13 +2192,13 @@ export class FeishuBot {
2102
2192
  `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
2103
2193
  `Rounds: ${info?.discussMaxRounds || 10}`,
2104
2194
  `Chairman: ${info?.chairmanBot || "not set"}`,
2105
- `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)"}`,
2106
2196
  active ? `Active: round ${active.currentRound}/${active.maxRounds}, topic=${active.topic.slice(0, 80)}` : "Active: none",
2107
2197
  ].join("\n") : [
2108
2198
  `💬 Discuss: ${info?.discuss ? "on" : "off"}`,
2109
2199
  `轮数:${info?.discussMaxRounds || 10}`,
2110
2200
  `Chairman:${info?.chairmanBot || "未设置"}`,
2111
- `参与者:${participants.length ? participants.join(", ") : "(无 free bot / chairman)"}`,
2201
+ `参与者:${[...participants, info?.chairmanBot].filter(Boolean).length ? [...participants, info?.chairmanBot].filter(Boolean).join(", ") : "(无可参与者)"}`,
2112
2202
  active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
2113
2203
  ].join("\n"));
2114
2204
  }
@@ -2154,8 +2244,9 @@ export class FeishuBot {
2154
2244
  const mode = this.store.getBotMode(next, chatId);
2155
2245
  const lines = [previous && previous !== next ? `✅ Chairman 已从 ${previous} 切换为 ${next}` : `✅ Chairman 已设置为 ${next}`];
2156
2246
  lines.push("作用:");
2157
- lines.push("- 当没有 free bot 且普通消息无人被 @ 时,Chairman 会负责回答");
2158
- 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,会参与兜底回答、主持、调停并做最终总结");
2159
2250
  if (mode === "mute")
2160
2251
  lines.push(`⚠️ ${next} 当前是 mute,但 Chairman 场景下仍会发言`);
2161
2252
  await this.replyMessage(messageId, lines.join("\n"));
@@ -2221,6 +2312,7 @@ export class FeishuBot {
2221
2312
  const sessionExists = session ? "✅ 活跃" : "⏳ 未初始化";
2222
2313
  const status = session?.status || "unknown";
2223
2314
  const verboseStatus = this.store.getBotVerbose(this.config.name, chatId) ? "🔊 开启" : "🔇 关闭";
2315
+ const liveStatus = this.store.getBotLiveStatus(this.config.name, chatId) ? "📡 开启" : "📴 关闭";
2224
2316
  const mode = chatType === "p2p" ? "normal" : this.store.getBotMode(this.config.name, chatId);
2225
2317
  const chairman = chatType === "p2p" ? "" : this.store.getChairmanBot(chatId);
2226
2318
  const chairmanStatus = chatType === "p2p"
@@ -2242,6 +2334,7 @@ export class FeishuBot {
2242
2334
  `🧮 上下文: ${fmtK(totalTokens)} / ${fmtK(contextTokens)} (${usedPct}%)${tokenNote}`,
2243
2335
  `📥 输入: ${fmtK(inputTokens)} | 📤 输出: ${fmtK(outputTokens)}`,
2244
2336
  `🔧 Verbose: ${verboseStatus}`,
2337
+ `📡 Live Status: ${liveStatus}`,
2245
2338
  `🎛️ Mode: ${mode}`,
2246
2339
  `👑 Chairman: ${chairmanStatus}`,
2247
2340
  `🌐 Locale: ${localeStatus}`,
@@ -0,0 +1,56 @@
1
+ import type { ProgressEvent } from "./openclaw-client";
2
+ export type LiveStatusCallbacks = {
3
+ create: (text: string) => Promise<string | undefined>;
4
+ edit: (messageId: string, text: string) => Promise<void>;
5
+ warn?: (message: string, err?: unknown) => void;
6
+ };
7
+ export type LiveStatusOptions = {
8
+ botName: string;
9
+ locale?: "zh" | "en";
10
+ delayMs?: number;
11
+ maxChars?: number;
12
+ maxEdits?: number;
13
+ disableHint?: string;
14
+ };
15
+ export declare class LiveStatusController {
16
+ private readonly callbacks;
17
+ private readonly opts;
18
+ private messageId?;
19
+ private lastSentText;
20
+ private detail;
21
+ /** Number of edits already issued against the live message (excludes create). */
22
+ private editCount;
23
+ private createTimer?;
24
+ private createPromise?;
25
+ private finalized;
26
+ private disabled;
27
+ /** True after the running-state budget is spent; future progress is ignored. */
28
+ private runningLimitReached;
29
+ constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
30
+ get id(): string | undefined;
31
+ start(initialDetail?: string): void;
32
+ progress(event: ProgressEvent | string): Promise<void>;
33
+ /**
34
+ * Finish the live status when the real (final) reply is delivered separately.
35
+ * The final answer is sent by the normal interactive-card path, so the status
36
+ * message must NOT be overwritten with the answer (text type does not render
37
+ * Markdown). Feishu renders message deletion as a visible "recalled a message"
38
+ * tombstone, so keep the status message and mark it done instead.
39
+ */
40
+ complete(): Promise<void>;
41
+ /**
42
+ * Finish the live status when the run failed and the error is delivered by the
43
+ * normal path. Use a neutral/negative marker so it does not contradict the
44
+ * separately delivered error message.
45
+ */
46
+ fail(): Promise<void>;
47
+ dispose(): void;
48
+ private stopTimers;
49
+ private ensureCreatedNow;
50
+ private ensureCreated;
51
+ private editRunningStatus;
52
+ private safeEdit;
53
+ private formatToolStart;
54
+ private formatStatus;
55
+ private formatLimitNotice;
56
+ }
@@ -0,0 +1,184 @@
1
+ const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
2
+ const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
3
+ // Feishu caps a single message at 20 edits (code=230072 once exhausted). Keep the
4
+ // final edit for the completion marker; running-state updates may consume at most
5
+ // maxEdits - 1, with the last running edit reserved for an explicit limit notice.
6
+ const DEFAULT_MAX_EDITS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_EDITS || 20);
7
+ export class LiveStatusController {
8
+ callbacks;
9
+ opts;
10
+ messageId;
11
+ lastSentText = "";
12
+ detail = "";
13
+ /** Number of edits already issued against the live message (excludes create). */
14
+ editCount = 0;
15
+ createTimer;
16
+ createPromise;
17
+ finalized = false;
18
+ disabled = false;
19
+ /** True after the running-state budget is spent; future progress is ignored. */
20
+ runningLimitReached = false;
21
+ constructor(callbacks, opts) {
22
+ this.callbacks = callbacks;
23
+ this.opts = opts;
24
+ }
25
+ get id() { return this.messageId; }
26
+ start(initialDetail) {
27
+ if (this.disabled || this.finalized || this.createTimer || this.messageId)
28
+ return;
29
+ this.detail = initialDetail || "";
30
+ this.createTimer = setTimeout(() => {
31
+ this.createTimer = undefined;
32
+ this.createPromise = this.ensureCreated();
33
+ }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
34
+ }
35
+ async progress(event) {
36
+ if (this.disabled || this.finalized || this.runningLimitReached)
37
+ return;
38
+ // Only tool-call start is meaningful enough to spend one of Feishu's limited
39
+ // edit slots. Ignore verbose assistant notes, lifecycle ticks, tool end, and
40
+ // tool error; the final/error answer is delivered by the normal message path.
41
+ if (typeof event !== "string") {
42
+ if (event.kind !== "tool" || event.phase !== "start")
43
+ return;
44
+ this.detail = this.formatToolStart(event);
45
+ }
46
+ else {
47
+ // String progress is kept for tests/backwards compatibility, but in normal
48
+ // runtime OpenClaw progress is structured and only tool start passes above.
49
+ this.detail = event.trim();
50
+ }
51
+ if (!this.detail)
52
+ return;
53
+ await this.ensureCreatedNow();
54
+ await this.editRunningStatus();
55
+ }
56
+ /**
57
+ * Finish the live status when the real (final) reply is delivered separately.
58
+ * The final answer is sent by the normal interactive-card path, so the status
59
+ * message must NOT be overwritten with the answer (text type does not render
60
+ * Markdown). Feishu renders message deletion as a visible "recalled a message"
61
+ * tombstone, so keep the status message and mark it done instead.
62
+ */
63
+ async complete() {
64
+ this.finalized = true;
65
+ this.stopTimers();
66
+ if (this.createPromise)
67
+ await this.createPromise.catch(() => { });
68
+ if (!this.messageId || this.disabled)
69
+ return;
70
+ const doneText = this.opts.locale === "en"
71
+ ? `\u2705 ${this.opts.botName} done`
72
+ : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
73
+ await this.safeEdit(doneText, true);
74
+ }
75
+ /**
76
+ * Finish the live status when the run failed and the error is delivered by the
77
+ * normal path. Use a neutral/negative marker so it does not contradict the
78
+ * separately delivered error message.
79
+ */
80
+ async fail() {
81
+ this.finalized = true;
82
+ this.stopTimers();
83
+ if (this.createPromise)
84
+ await this.createPromise.catch(() => { });
85
+ if (!this.messageId || this.disabled)
86
+ return;
87
+ const failedText = this.opts.locale === "en"
88
+ ? `\u26A0\uFE0F ${this.opts.botName} stopped`
89
+ : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
90
+ await this.safeEdit(failedText, true);
91
+ }
92
+ dispose() {
93
+ this.finalized = true;
94
+ this.stopTimers();
95
+ }
96
+ stopTimers() {
97
+ if (this.createTimer) {
98
+ clearTimeout(this.createTimer);
99
+ this.createTimer = undefined;
100
+ }
101
+ }
102
+ async ensureCreatedNow() {
103
+ if (this.createTimer) {
104
+ clearTimeout(this.createTimer);
105
+ this.createTimer = undefined;
106
+ this.createPromise = this.ensureCreated();
107
+ }
108
+ if (this.createPromise)
109
+ await this.createPromise.catch(() => { });
110
+ }
111
+ async ensureCreated() {
112
+ if (this.disabled || this.finalized || this.messageId)
113
+ return;
114
+ const text = this.formatStatus(true);
115
+ try {
116
+ const id = await this.callbacks.create(text);
117
+ if (!id) {
118
+ this.disabled = true;
119
+ return;
120
+ }
121
+ this.messageId = id;
122
+ this.lastSentText = text;
123
+ }
124
+ catch (err) {
125
+ this.disabled = true;
126
+ this.callbacks.warn?.("live status create failed", err);
127
+ }
128
+ }
129
+ async editRunningStatus() {
130
+ if (!this.messageId || this.disabled || this.finalized || this.runningLimitReached)
131
+ return;
132
+ const maxEdits = this.opts.maxEdits ?? DEFAULT_MAX_EDITS;
133
+ const runningBudget = Math.max(0, maxEdits - 1); // reserve final edit for complete()
134
+ if (this.editCount >= runningBudget)
135
+ return;
136
+ const isLimitNotice = this.editCount === runningBudget - 1;
137
+ const text = isLimitNotice ? this.formatLimitNotice() : this.formatStatus();
138
+ const ok = await this.safeEdit(text);
139
+ if (ok && isLimitNotice)
140
+ this.runningLimitReached = true;
141
+ }
142
+ async safeEdit(text, force = false) {
143
+ if (!this.messageId || this.disabled)
144
+ return false;
145
+ if (!force && this.finalized)
146
+ return false;
147
+ if (!force && text === this.lastSentText)
148
+ return true;
149
+ try {
150
+ await this.callbacks.edit(this.messageId, text);
151
+ this.lastSentText = text;
152
+ this.editCount++;
153
+ return true;
154
+ }
155
+ catch (err) {
156
+ this.disabled = true;
157
+ this.stopTimers();
158
+ this.callbacks.warn?.("live status edit failed", err);
159
+ return false;
160
+ }
161
+ }
162
+ formatToolStart(event) {
163
+ const raw = event.text || event.name || "";
164
+ const prefix = `${event.name} start`;
165
+ let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
166
+ if (detail.startsWith(":"))
167
+ detail = detail.slice(1).trim();
168
+ return detail ? `${event.name}: ${detail}` : event.name;
169
+ }
170
+ formatStatus(includeDisableHint = false) {
171
+ const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
172
+ const clean = (this.detail || "").replace(/\s+/g, " ").trim();
173
+ const clipped = clean.length > maxChars ? `${clean.slice(0, Math.max(0, maxChars - 1))}\u2026` : clean;
174
+ const base = !clipped
175
+ ? (this.opts.locale === "en" ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`)
176
+ : (this.opts.locale === "en" ? `${this.opts.botName} is working: ${clipped}` : `${this.opts.botName} \u6b63\u5728\u6267\u884c\uff1a${clipped}`);
177
+ return includeDisableHint && this.opts.disableHint ? `${base}\n${this.opts.disableHint}` : base;
178
+ }
179
+ formatLimitNotice() {
180
+ return this.opts.locale === "en"
181
+ ? `${this.opts.botName} reached the update limit and is still running`
182
+ : `${this.opts.botName} \u5df2\u8fbe\u66f4\u65b0\u9650\u5236\uff0c\u6b63\u5728\u6301\u7eed\u6267\u884c\u4e2d`;
183
+ }
184
+ }
@@ -119,6 +119,8 @@ export declare class MessageStore {
119
119
  getChairmanBot(chatId: string): string;
120
120
  setBotVerbose(botName: string, chatId: string, verbose: boolean): void;
121
121
  getBotVerbose(botName: string, chatId: string): boolean;
122
+ setBotLiveStatus(botName: string, chatId: string, on: boolean): void;
123
+ getBotLiveStatus(botName: string, chatId: string): boolean;
122
124
  setBotMode(botName: string, chatId: string, mode: BotChatMode): void;
123
125
  getBotMode(botName: string, chatId: string): BotChatMode;
124
126
  setBotFreeDiscussion(botName: string, chatId: string, on: boolean): void;
@@ -104,6 +104,7 @@ export class MessageStore {
104
104
  bot_name TEXT NOT NULL,
105
105
  chat_id TEXT NOT NULL,
106
106
  verbose INTEGER NOT NULL DEFAULT 0,
107
+ live_status INTEGER NOT NULL DEFAULT 1,
107
108
  free_discussion INTEGER NOT NULL DEFAULT 0,
108
109
  mode TEXT NOT NULL DEFAULT 'normal',
109
110
  updated_at INTEGER NOT NULL DEFAULT 0,
@@ -135,6 +136,13 @@ export class MessageStore {
135
136
  );
136
137
  CREATE INDEX IF NOT EXISTS idx_delivery_outbox_pending ON delivery_outbox(status, created_at);
137
138
  `);
139
+ // Migration: add per-bot live status toggle if missing
140
+ try {
141
+ this.db.exec(`ALTER TABLE bot_chat_settings ADD COLUMN live_status INTEGER NOT NULL DEFAULT 1`);
142
+ }
143
+ catch {
144
+ // Column already exists
145
+ }
138
146
  // Migration: add delivery_outbox delivery key columns if missing
139
147
  try {
140
148
  this.db.exec(`ALTER TABLE delivery_outbox ADD COLUMN delivery_key TEXT NOT NULL DEFAULT ''`);
@@ -694,6 +702,22 @@ export class MessageStore {
694
702
  `).get(botName, chatId);
695
703
  return !!row?.verbose;
696
704
  }
705
+ setBotLiveStatus(botName, chatId, on) {
706
+ this.db.prepare(`
707
+ INSERT INTO bot_chat_settings (bot_name, chat_id, live_status, updated_at)
708
+ VALUES (?, ?, ?, ?)
709
+ ON CONFLICT (bot_name, chat_id) DO UPDATE SET
710
+ live_status = excluded.live_status,
711
+ updated_at = excluded.updated_at
712
+ `).run(botName, chatId, on ? 1 : 0, Date.now());
713
+ }
714
+ getBotLiveStatus(botName, chatId) {
715
+ const row = this.db.prepare(`
716
+ SELECT live_status FROM bot_chat_settings
717
+ WHERE bot_name = ? AND chat_id = ?
718
+ `).get(botName, chatId);
719
+ return row ? row.live_status !== 0 : true;
720
+ }
697
721
  setBotMode(botName, chatId, mode) {
698
722
  const freeDiscussion = mode === "free" ? 1 : 0;
699
723
  this.db.prepare(`
@@ -1,5 +1,17 @@
1
1
  import { OpenClawConfig } from "./config.js";
2
2
  import { ChatMessage } from "./message-store.js";
3
+ export type ProgressEvent = {
4
+ kind: "tool";
5
+ phase: "start" | "end" | "error";
6
+ name: string;
7
+ text: string;
8
+ } | {
9
+ kind: "assistant_note";
10
+ text: string;
11
+ } | {
12
+ kind: "lifecycle";
13
+ text: string;
14
+ };
3
15
  export type ChatAttachment = {
4
16
  type?: string;
5
17
  mimeType?: string;
@@ -26,6 +38,7 @@ export declare class OpenClawClient {
26
38
  private toolEventCallbacks;
27
39
  private lastToolNamesByItemId;
28
40
  private sessionMessageCallbacks;
41
+ private progressCallbacks;
29
42
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
30
43
  private verboseTranscriptSessions;
31
44
  private verboseAssistantTimers;
@@ -138,6 +151,7 @@ export declare class OpenClawClient {
138
151
  onSendAttempt?: () => void | Promise<void>;
139
152
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the user message. */
140
153
  onSubmitted?: (runId: string) => void | Promise<void>;
154
+ onProgress?: (event: ProgressEvent) => void | Promise<void>;
141
155
  }): Promise<string>;
142
156
  private shouldInjectBridgeAttachmentHint;
143
157
  private bridgeAttachmentHint;
@@ -170,6 +184,7 @@ export declare class OpenClawClient {
170
184
  onSendAttempt?: () => void | Promise<void>;
171
185
  /** Called after chat.send RPC succeeds and OpenClaw has accepted the combined message. */
172
186
  onSubmitted?: (runId: string) => void | Promise<void>;
187
+ onProgress?: (event: ProgressEvent) => void | Promise<void>;
173
188
  }): Promise<string>;
174
189
  private formatContextLines;
175
190
  private writeContextSyncFile;
@@ -29,6 +29,7 @@ export class OpenClawClient {
29
29
  toolEventCallbacks = new Map();
30
30
  lastToolNamesByItemId = new Map();
31
31
  sessionMessageCallbacks = new Map();
32
+ progressCallbacks = new Map();
32
33
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
33
34
  verboseTranscriptSessions = new Set();
34
35
  verboseAssistantTimers = new Map();
@@ -239,11 +240,17 @@ export class OpenClawClient {
239
240
  this.flushVerboseAssistantState(rawKey);
240
241
  this.clearVerboseAssistantState(rawKey);
241
242
  }
242
- if (toolCb && data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
243
+ if (data.kind === "tool" && data.name && (data.phase === "start" || data.phase === "end" || data.phase === "error")) {
243
244
  const phase = data.phase || "event";
244
245
  const meta = data.meta || data.input || data.args || "";
245
246
  const output = data.output || data.result || data.error || "";
246
- toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
247
+ if (toolCb)
248
+ toolCb(`${data.name} ${phase}`.trim(), String(meta || ""), String(output || ""));
249
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
250
+ if (progressCb) {
251
+ const detail = phase === "start" ? String(meta || "") : String(output || meta || "");
252
+ void progressCb({ kind: "tool", phase, name: String(data.name), text: `${data.name} ${phase}${detail ? `: ${detail}` : ""}` });
253
+ }
247
254
  }
248
255
  }
249
256
  if (frame.event === "agent" && frame.payload?.stream === "command_output") {
@@ -348,11 +355,12 @@ export class OpenClawClient {
348
355
  }
349
356
  }
350
357
  flushVerboseAssistantState(rawKey) {
351
- if (!this.isVerboseTranscriptEnabled(rawKey))
352
- return false;
353
358
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
354
359
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
355
- if (!cb)
360
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
361
+ if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
362
+ return false;
363
+ if (!cb && !progressCb)
356
364
  return false;
357
365
  const key = rawKey;
358
366
  const latest = this.verboseAssistantLatest.get(key)?.trim() || "";
@@ -368,16 +376,22 @@ export class OpenClawClient {
368
376
  if (!toSend)
369
377
  return false;
370
378
  this.verboseAssistantSent.set(key, latest);
371
- cb(toSend, { sourceType: "verbose_transcript" });
379
+ if (this.isVerboseTranscriptEnabled(rawKey) && cb)
380
+ cb(toSend, { sourceType: "verbose_transcript" });
381
+ if (progressCb)
382
+ void progressCb({ kind: "assistant_note", text: toSend });
372
383
  return true;
373
384
  }
374
385
  handleVerboseAssistantStream(payload) {
375
386
  const rawKey = payload?.sessionKey || "";
376
- if (!rawKey || !this.isVerboseTranscriptEnabled(rawKey))
387
+ if (!rawKey)
377
388
  return;
378
389
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
379
390
  const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
380
- if (!cb)
391
+ const progressCb = this.progressCallbacks.get(rawKey) || this.progressCallbacks.get(shortKey);
392
+ if (!this.isVerboseTranscriptEnabled(rawKey) && !progressCb)
393
+ return;
394
+ if (!cb && !progressCb)
381
395
  return;
382
396
  const data = payload.data || {};
383
397
  const fullText = typeof data.text === "string" ? data.text : typeof data.deltaText === "string" && data.replace ? data.deltaText : "";
@@ -1090,6 +1104,11 @@ export class OpenClawClient {
1090
1104
  }
1091
1105
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
1092
1106
  await params.onSubmitted?.(result.runId);
1107
+ if (params.onProgress) {
1108
+ this.progressCallbacks.set(sk, params.onProgress);
1109
+ this.progressCallbacks.set(fullSessionKey, params.onProgress);
1110
+ void params.onProgress({ kind: "lifecycle", text: "等待 OpenClaw 回复" });
1111
+ }
1093
1112
  return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
1094
1113
  }
1095
1114
  finally {
@@ -1097,6 +1116,8 @@ export class OpenClawClient {
1097
1116
  // collectReply returns. Keep a short grace window so normal chat replies
1098
1117
  // are not delivered twice via the proactive-message path. Cron/LMA runs
1099
1118
  // are unaffected because they do not go through chatSend.
1119
+ this.progressCallbacks.delete(sk);
1120
+ this.progressCallbacks.delete(fullSessionKey);
1100
1121
  this.releaseSuppressedSessionKeysAfter(suppressedKeys, 30000);
1101
1122
  this.releaseOwnedDeliverySessionKeysAfter(suppressedKeys, 30000);
1102
1123
  }
@@ -1149,6 +1170,7 @@ export class OpenClawClient {
1149
1170
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
1150
1171
  onSendAttempt: params.onSendAttempt,
1151
1172
  onSubmitted: params.onSubmitted,
1173
+ onProgress: params.onProgress,
1152
1174
  });
1153
1175
  }
1154
1176
  // Build context block + actual message in one chat.send, or write the
@@ -1184,6 +1206,7 @@ export class OpenClawClient {
1184
1206
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
1185
1207
  onSendAttempt: params.onSendAttempt,
1186
1208
  onSubmitted: params.onSubmitted,
1209
+ onProgress: params.onProgress,
1187
1210
  });
1188
1211
  }
1189
1212
  formatContextLines(messages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
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": {