openclaw-lark-multi-agent 1.0.22 → 1.1.0

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.
@@ -40,6 +40,8 @@ export declare class FeishuBot {
40
40
  private locale;
41
41
  private configPath?;
42
42
  private static allBots;
43
+ /** Chats where this bot app has actually received an event in this process. */
44
+ private static seenBotChats;
43
45
  constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string, configPath?: string);
44
46
  private handleMessageRecalled;
45
47
  register(): void;
@@ -75,6 +77,20 @@ export declare class FeishuBot {
75
77
  private handleMessage;
76
78
  private shouldUseLiveStatus;
77
79
  private getUnhealthySessionStatus;
80
+ /**
81
+ * Distinguish SESSION death from RUN failure. `sessions.describe` reports the
82
+ * status of the most recent run, not whether the session is usable. A run can
83
+ * end as `aborted`/`error`/`failed`/`timeout` (e.g. an LLM idle timeout) while
84
+ * the session itself stays perfectly usable for the next message — confirmed in
85
+ * the wild: LMA reported "killed" but `/status` immediately showed running.
86
+ *
87
+ * Only treat the session as dead for statuses that mean the session itself is
88
+ * gone and a new run cannot start without /reset. Use word-boundary matching so
89
+ * a run-level "error" substring never trips this. Run-level failures are NOT
90
+ * surfaced here: the work promise will either return the (possibly empty) reply
91
+ * or throw, and that is handled by the normal delivery/error path.
92
+ */
93
+ private isSessionDeadStatus;
78
94
  private warnUnhealthySessionAndContinue;
79
95
  private stopForUnhealthySession;
80
96
  private withSessionHealthMonitor;
@@ -107,6 +123,7 @@ export declare class FeishuBot {
107
123
  private deliverySessionKey;
108
124
  private stableHash;
109
125
  private deliverySourceId;
126
+ private sendSystemDelivery;
110
127
  private enqueueAndDispatchDelivery;
111
128
  private dispatchPendingDeliveries;
112
129
  private hasFreeModeBot;
@@ -123,10 +140,20 @@ export declare class FeishuBot {
123
140
  */
124
141
  private resolveChairmanTargets;
125
142
  private isDiscussionCoordinator;
143
+ private static markBotSeenInChat;
144
+ private markCurrentBotSeenInChat;
145
+ private isBotAvailableInChat;
126
146
  private getDiscussionParticipants;
127
147
  private getChairmanParticipant;
128
148
  private asDiscussionParticipant;
129
149
  private runDiscussionTurn;
150
+ private buildLiveStatusCard;
151
+ /** Minimal escaping so activity text does not break card markdown. */
152
+ private escapeCardText;
153
+ private sendLiveStatusCard;
154
+ private replyLiveStatusCard;
155
+ private patchLiveStatusCard;
156
+ private sendTextMessage;
130
157
  private replyTextMessage;
131
158
  private editTextMessage;
132
159
  private deleteMessageById;
@@ -137,6 +164,8 @@ export declare class FeishuBot {
137
164
  private inferFeishuFileType;
138
165
  private isImagePath;
139
166
  private errorSummary;
167
+ private isOutOfChatError;
168
+ private markCurrentBotUnavailable;
140
169
  private stripReadOnlyDocxFields;
141
170
  private createFeishuDocFromMarkdown;
142
171
  private sendBridgeAttachment;
@@ -16,6 +16,7 @@ const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MA
16
16
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
17
17
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
18
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);
19
20
  class UnhealthySessionError extends Error {
20
21
  status;
21
22
  constructor(status) {
@@ -63,6 +64,8 @@ export class FeishuBot {
63
64
  locale;
64
65
  configPath;
65
66
  static allBots = new Map();
67
+ /** Chats where this bot app has actually received an event in this process. */
68
+ static seenBotChats = new Map();
66
69
  constructor(config, openclawClient, store, adminOpenId, configPath) {
67
70
  this.config = config;
68
71
  this.openclawClient = openclawClient;
@@ -192,7 +195,17 @@ export class FeishuBot {
192
195
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
193
196
  this.cancelDelayedFailure(chatId);
194
197
  const activeTarget = this.activeDeliveryTargets.get(chatId);
195
- 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
+ }
196
209
  }
197
210
  catch (err) {
198
211
  console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
@@ -330,6 +343,7 @@ export class FeishuBot {
330
343
  const message = event.message;
331
344
  const sender = event.sender;
332
345
  const chatId = message.chat_id;
346
+ this.markCurrentBotSeenInChat(chatId);
333
347
  const chatType = message.chat_type;
334
348
  const messageType = message.message_type;
335
349
  const messageId = message.message_id;
@@ -486,6 +500,7 @@ export class FeishuBot {
486
500
  const isChairmanCommand = commandName === "/chairman";
487
501
  const isLocaleCommand = commandName === "/locale";
488
502
  const isModelCommand = commandName === "/model";
503
+ const isHelpCommand = commandName === "/help";
489
504
  const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
490
505
  if (chatType !== "p2p") {
491
506
  const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
@@ -557,6 +572,13 @@ export class FeishuBot {
557
572
  if (!this.isDiscussionCoordinator())
558
573
  return;
559
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
+ }
560
582
  else if (isDiscussCommand && !routing.hasTargetedMention) {
561
583
  // Untargeted or @all /discuss is group-level; one coordinator handles it.
562
584
  if (!this.isDiscussionCoordinator())
@@ -787,25 +809,25 @@ export class FeishuBot {
787
809
  maxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
788
810
  participants,
789
811
  chairman,
790
- sendSystemMessage: async (text) => { await this.sendMessage(chatId, text); },
812
+ sendSystemMessage: async (text) => { await this.sendSystemDelivery(chatId, text); },
791
813
  locale: discussionLocale,
792
814
  onComplete: async (event) => {
793
815
  if (event.reason === "chairman_final") {
794
816
  this.store.setDiscussMode(event.chatId, false);
795
- await this.sendMessage(event.chatId, this.isEn(event.chatId)
817
+ await this.sendSystemDelivery(event.chatId, this.isEn(event.chatId)
796
818
  ? `💬 Discuss ended: Chairman ${event.chairmanName || ""} completed the final summary. Discuss mode has been turned off automatically.`
797
819
  : `💬 Discuss 已结束:Chairman ${event.chairmanName || ""} 已完成总结,已自动关闭 Discuss 模式。`);
798
820
  }
799
821
  },
800
822
  });
801
823
  if (started && preempted) {
802
- await this.sendMessage(chatId, this.isEn(chatId)
824
+ await this.sendSystemDelivery(chatId, this.isEn(chatId)
803
825
  ? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
804
826
  : `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
805
827
  }
806
828
  }
807
829
  else if (this.isDiscussionCoordinator()) {
808
- await this.sendMessage(chatId, this.isEn(chatId)
830
+ await this.sendSystemDelivery(chatId, this.isEn(chatId)
809
831
  ? "💬 Discuss is on, but there is no available participant. Unmute at least one bot or set a Chairman."
810
832
  : "💬 Discuss 已开启,但当前没有可参与者。请至少解除一个 bot 的禁言,或设置 Chairman。");
811
833
  }
@@ -891,9 +913,8 @@ export class FeishuBot {
891
913
  const status = String(resp?.session?.status || "").toLowerCase();
892
914
  if (!status)
893
915
  return null;
894
- if (["killed", "kill", "dead", "crashed", "crash", "failed", "failure", "error", "errored", "invalid"].some((bad) => status.includes(bad))) {
916
+ if (this.isSessionDeadStatus(status))
895
917
  return status;
896
- }
897
918
  }
898
919
  catch {
899
920
  // Absence/transient describe failures should not block a new run; chat.send
@@ -901,6 +922,26 @@ export class FeishuBot {
901
922
  }
902
923
  return null;
903
924
  }
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
+ }
904
945
  async warnUnhealthySessionAndContinue(chatId, messageId, status) {
905
946
  await this.replyMessage(messageId, this.isEn(chatId)
906
947
  ? `⚠️ ${this.config.name} session is currently unhealthy (${status}). I will still try this message. If it keeps failing, send /reset and retry.`
@@ -934,8 +975,18 @@ export class FeishuBot {
934
975
  break;
935
976
  const unhealthy = await this.getUnhealthySessionStatus(chatId);
936
977
  if (unhealthy) {
937
- await this.stopForUnhealthySession(chatId, messageId, unhealthy);
938
- 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
+ }
939
990
  }
940
991
  }
941
992
  return undefined;
@@ -1128,18 +1179,19 @@ export class FeishuBot {
1128
1179
  };
1129
1180
  liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
1130
1181
  ? new LiveStatusController({
1131
- create: (text) => this.sendOrdered(chatId, () => this.replyTextMessage(lastHuman.messageId || "", text)),
1132
- edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
1182
+ create: (view) => this.sendOrdered(chatId, () => this.replyLiveStatusCard(lastHuman.messageId || "", view, chatId)),
1183
+ edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
1133
1184
  warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
1134
1185
  }, {
1135
1186
  botName: this.config.name,
1187
+ model: this.config.model,
1136
1188
  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
1189
  })
1141
1190
  : undefined;
1142
1191
  liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
1192
+ const activeTargetForStatus = this.activeDeliveryTargets.get(chatId);
1193
+ if (activeTargetForStatus?.triggerId === triggerId && liveStatus)
1194
+ activeTargetForStatus.liveStatus = liveStatus;
1143
1195
  try {
1144
1196
  reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
1145
1197
  sessionKey,
@@ -1188,7 +1240,7 @@ export class FeishuBot {
1188
1240
  // submitted far enough that auto-replaying risks duplicate delivery.
1189
1241
  // The user can resend a new message if they want to retry.
1190
1242
  markSubmittedBatch("empty-returned");
1191
- liveStatus?.dispose();
1243
+ await liveStatus?.noReply().catch(() => { });
1192
1244
  console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
1193
1245
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1194
1246
  const remainingAcks = [];
@@ -1259,6 +1311,7 @@ export class FeishuBot {
1259
1311
  if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
1260
1312
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
1261
1313
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
1314
+ await liveStatus?.complete().catch(() => { });
1262
1315
  }
1263
1316
  else {
1264
1317
  try {
@@ -1283,11 +1336,14 @@ export class FeishuBot {
1283
1336
  }
1284
1337
  }
1285
1338
  if (isRuntimeFailure && lastHuman.messageId) {
1286
- liveStatus?.dispose();
1339
+ await liveStatus?.fail().catch(() => { });
1287
1340
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1288
1341
  }
1289
1342
  else if (!shouldReply && !hasAttachments) {
1290
- liveStatus?.dispose();
1343
+ // Explicit NO_REPLY (or empty visible text): the model finished without
1344
+ // producing a user-visible reply. Mark the status card done with a
1345
+ // "no content" summary instead of leaving it stuck on "正在执行".
1346
+ await liveStatus?.noReply().catch(() => { });
1291
1347
  }
1292
1348
  console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
1293
1349
  // Replace ack reactions with DONE only for trigger messages actually
@@ -1604,6 +1660,10 @@ export class FeishuBot {
1604
1660
  deliverySourceId(kind, text) {
1605
1661
  return `${kind}:${this.stableHash(text)}`;
1606
1662
  }
1663
+ async sendSystemDelivery(chatId, text) {
1664
+ const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
1665
+ await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
1666
+ }
1607
1667
  async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
1608
1668
  if (!text.trim() && attachments.length === 0)
1609
1669
  return;
@@ -1672,6 +1732,8 @@ export class FeishuBot {
1672
1732
  this.store.markDeliveryDelivered(item.id);
1673
1733
  }
1674
1734
  catch (err) {
1735
+ if (this.isOutOfChatError(err))
1736
+ this.markCurrentBotUnavailable(chatId, err);
1675
1737
  if (item.id)
1676
1738
  this.store.markDeliveryFailed(item.id);
1677
1739
  const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
@@ -1749,17 +1811,42 @@ export class FeishuBot {
1749
1811
  return true;
1750
1812
  return bots[0] === this;
1751
1813
  }
1814
+ static markBotSeenInChat(botName, chatId) {
1815
+ let set = FeishuBot.seenBotChats.get(botName);
1816
+ if (!set) {
1817
+ set = new Set();
1818
+ FeishuBot.seenBotChats.set(botName, set);
1819
+ }
1820
+ set.add(chatId);
1821
+ }
1822
+ markCurrentBotSeenInChat(chatId) {
1823
+ FeishuBot.markBotSeenInChat(this.config.name, chatId);
1824
+ this.store.markBotSeenInChat(this.config.name, chatId);
1825
+ }
1826
+ isBotAvailableInChat(bot, chatId) {
1827
+ // A bot can be globally configured but not actually installed in this Feishu
1828
+ // group. Discuss participants must be restricted to bots that either have
1829
+ // received an event in this chat during this process, or have previously
1830
+ // delivered a message successfully in this chat. Otherwise LMA may generate
1831
+ // "ghost" participants and Feishu will reject delivery with 230002
1832
+ // (Bot/User can NOT be out of the chat).
1833
+ if (this.store.isBotUnavailableInChat(bot.config.name, chatId))
1834
+ return false;
1835
+ return FeishuBot.seenBotChats.get(bot.config.name)?.has(chatId)
1836
+ || this.store.hasBotSeenInChat(bot.config.name, chatId)
1837
+ || this.store.hasAnyDeliveredToChat(bot.config.name, chatId);
1838
+ }
1752
1839
  getDiscussionParticipants(chatId) {
1753
1840
  const chairman = this.store.getChairmanBot(chatId);
1754
1841
  return Array.from(FeishuBot.allBots.values())
1755
- .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute")
1842
+ .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute" && this.isBotAvailableInChat(bot, chatId))
1756
1843
  .map((bot) => this.asDiscussionParticipant(bot, chatId));
1757
1844
  }
1758
1845
  getChairmanParticipant(chatId) {
1759
1846
  const chairman = this.store.getChairmanBot(chatId);
1760
1847
  if (!chairman)
1761
1848
  return undefined;
1762
- const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman);
1849
+ const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman && this.isBotAvailableInChat(candidate, chatId));
1763
1850
  return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
1764
1851
  }
1765
1852
  asDiscussionParticipant(bot, chatId) {
@@ -1771,6 +1858,19 @@ export class FeishuBot {
1771
1858
  async runDiscussionTurn(chatId, prompt, meta) {
1772
1859
  const sessionKey = await this.ensureSession(chatId);
1773
1860
  const releaseProactiveMute = this.openclawClient.muteProactiveDelivery(sessionKey);
1861
+ const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
1862
+ const liveStatus = liveStatusEnabled
1863
+ ? new LiveStatusController({
1864
+ create: (view) => this.sendOrdered(chatId, () => this.sendLiveStatusCard(chatId, view)),
1865
+ edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
1866
+ warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
1867
+ }, {
1868
+ botName: this.config.name,
1869
+ model: this.config.model,
1870
+ locale: this.isEn(chatId) ? "en" : "zh",
1871
+ })
1872
+ : undefined;
1873
+ liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
1774
1874
  let reply;
1775
1875
  try {
1776
1876
  reply = await this.openclawClient.chatSendWithContext({
@@ -1781,8 +1881,13 @@ export class FeishuBot {
1781
1881
  deliver: false,
1782
1882
  timeoutMs: 1_800_000,
1783
1883
  emptyFinalAsNoReply: true,
1884
+ onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
1784
1885
  });
1785
1886
  }
1887
+ catch (err) {
1888
+ await liveStatus?.fail().catch(() => { });
1889
+ throw err;
1890
+ }
1786
1891
  finally {
1787
1892
  // OpenClaw can emit the final assistant session.message shortly after
1788
1893
  // chatSend/collectReply returns. Keep discussion proactive muted briefly;
@@ -1815,10 +1920,127 @@ export class FeishuBot {
1815
1920
  content: storedContent,
1816
1921
  timestamp: Date.now(),
1817
1922
  });
1818
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
1923
+ try {
1924
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
1925
+ }
1926
+ catch (err) {
1927
+ await liveStatus?.fail().catch(() => { });
1928
+ throw err;
1929
+ }
1930
+ }
1931
+ // Mirror the normal path: a NO_REPLY/empty discussion turn should finish the
1932
+ // status card with a "no content" summary, not a plain done summary.
1933
+ if (!isVisible && parsedReply.attachments.length === 0) {
1934
+ await liveStatus?.noReply().catch(() => { });
1935
+ }
1936
+ else {
1937
+ await liveStatus?.complete().catch(() => { });
1819
1938
  }
1820
1939
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
1821
1940
  }
1941
+ buildLiveStatusCard(view, chatId) {
1942
+ const en = this.isEn(chatId);
1943
+ const elements = [];
1944
+ // Content area. While running: recent activity lines (each prefixed with the
1945
+ // relative time mm:ss since the run started). When finished: a single run
1946
+ // summary line (total tool calls + elapsed) instead of recent messages.
1947
+ if (view.lines.length > 0) {
1948
+ const iconFor = (kind) => kind === "tool_start" ? "▸"
1949
+ : kind === "tool_end" ? "✓"
1950
+ : kind === "lifecycle" ? "⋯"
1951
+ : kind === "summary" ? "📊"
1952
+ : "•";
1953
+ const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
1954
+ const content = view.lines
1955
+ .map((l) => {
1956
+ const stamp = l.kind === "summary" ? "" : `\`${fmtAt(l.at)}\` `;
1957
+ return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(l.text)}`;
1958
+ })
1959
+ .join("\n");
1960
+ elements.push({ tag: "markdown", content });
1961
+ }
1962
+ else {
1963
+ elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
1964
+ }
1965
+ elements.push({ tag: "hr" });
1966
+ // Footer: elapsed time + model name.
1967
+ const footerBits = [];
1968
+ footerBits.push(en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`);
1969
+ if (view.model)
1970
+ footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
1971
+ elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
1972
+ // Title color reflects state.
1973
+ const template = view.state === "done" ? "green" : view.state === "failed" ? "orange" : "blue";
1974
+ return {
1975
+ schema: "2.0",
1976
+ // update_multi is REQUIRED for im.message.patch to update the card; Feishu
1977
+ // rejects patches on cards that did not declare it before and after.
1978
+ config: { update_multi: true, width_mode: "fill" },
1979
+ header: {
1980
+ title: { tag: "plain_text", content: view.title },
1981
+ template,
1982
+ },
1983
+ body: { elements },
1984
+ };
1985
+ }
1986
+ /** Minimal escaping so activity text does not break card markdown. */
1987
+ escapeCardText(text) {
1988
+ return String(text || "").replace(/</g, "\uff1c").replace(/>/g, "\uff1e");
1989
+ }
1990
+ async sendLiveStatusCard(chatId, view) {
1991
+ try {
1992
+ const res = await this.client.im.message.create({
1993
+ params: { receive_id_type: "chat_id" },
1994
+ data: {
1995
+ receive_id: chatId,
1996
+ content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
1997
+ msg_type: "interactive",
1998
+ },
1999
+ });
2000
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2001
+ return res?.data?.message_id || res?.message_id;
2002
+ }
2003
+ catch (err) {
2004
+ if (this.isOutOfChatError(err))
2005
+ this.markCurrentBotUnavailable(chatId, err);
2006
+ throw err;
2007
+ }
2008
+ }
2009
+ async replyLiveStatusCard(messageId, view, chatId) {
2010
+ const res = await this.client.im.message.reply({
2011
+ path: { message_id: messageId },
2012
+ data: {
2013
+ content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
2014
+ msg_type: "interactive",
2015
+ },
2016
+ });
2017
+ return res?.data?.message_id || res?.message_id;
2018
+ }
2019
+ async patchLiveStatusCard(messageId, view, chatId) {
2020
+ await this.client.im.message.patch({
2021
+ path: { message_id: messageId },
2022
+ data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
2023
+ });
2024
+ }
2025
+ async sendTextMessage(chatId, text) {
2026
+ try {
2027
+ const res = await this.client.im.message.create({
2028
+ params: { receive_id_type: "chat_id" },
2029
+ data: {
2030
+ receive_id: chatId,
2031
+ content: JSON.stringify({ text }),
2032
+ msg_type: "text",
2033
+ },
2034
+ });
2035
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2036
+ return res?.data?.message_id || res?.message_id;
2037
+ }
2038
+ catch (err) {
2039
+ if (this.isOutOfChatError(err))
2040
+ this.markCurrentBotUnavailable(chatId, err);
2041
+ throw err;
2042
+ }
2043
+ }
1822
2044
  async replyTextMessage(messageId, text) {
1823
2045
  const res = await this.client.im.message.reply({
1824
2046
  path: { message_id: messageId },
@@ -1868,7 +2090,12 @@ export class FeishuBot {
1868
2090
  }
1869
2091
  extractBridgeAttachments(reply) {
1870
2092
  const attachments = [];
1871
- const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)<\/LMA_BRIDGE_ATTACHMENTS>/g;
2093
+ // Some models occasionally corrupt the attachment marker while preserving
2094
+ // the exact JSON payload: the closing tag can become </parameter>, or the
2095
+ // opening "<LMA" prefix can be truncated leaving "_BRIDGE_ATTACHMENTS>".
2096
+ // Treat these as recoverable shapes; otherwise the marker leaks as
2097
+ // user-visible text and attachments are not sent.
2098
+ const markerPattern = /(?:<LMA_BRIDGE_ATTACHMENTS>|_BRIDGE_ATTACHMENTS>)([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
1872
2099
  let text = reply.replace(markerPattern, (_match, jsonText) => {
1873
2100
  try {
1874
2101
  const parsed = JSON.parse(String(jsonText).trim());
@@ -1938,6 +2165,16 @@ export class FeishuBot {
1938
2165
  const msg = data?.msg || data?.message || err?.message || String(err);
1939
2166
  return `${code}${msg}`.slice(0, 800);
1940
2167
  }
2168
+ isOutOfChatError(err) {
2169
+ const data = err?.response?.data || err?.data;
2170
+ const code = data?.code;
2171
+ const msg = data?.msg || data?.message || err?.message || String(err);
2172
+ return Number(code) === 230002 || /Bot\/User can NOT be out of the chat/i.test(String(msg));
2173
+ }
2174
+ markCurrentBotUnavailable(chatId, err) {
2175
+ this.store.markBotUnavailableInChat(this.config.name, chatId, this.errorSummary(err));
2176
+ console.warn(`[${this.config.name}] Marked unavailable in chat ${chatId.slice(-8)} after Feishu out-of-chat error`);
2177
+ }
1941
2178
  stripReadOnlyDocxFields(value) {
1942
2179
  if (Array.isArray(value))
1943
2180
  return value.map((item) => this.stripReadOnlyDocxFields(item));
@@ -2052,10 +2289,13 @@ export class FeishuBot {
2052
2289
  msg_type: "interactive",
2053
2290
  },
2054
2291
  });
2292
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2055
2293
  return res?.data?.message_id || res?.message_id;
2056
2294
  }
2057
2295
  catch (err) {
2058
2296
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
2297
+ if (this.isOutOfChatError(err))
2298
+ this.markCurrentBotUnavailable(chatId, err);
2059
2299
  // Fallback to plain text
2060
2300
  try {
2061
2301
  const res = await this.client.im.message.create({
@@ -2066,10 +2306,13 @@ export class FeishuBot {
2066
2306
  msg_type: "text",
2067
2307
  },
2068
2308
  });
2309
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2069
2310
  return res?.data?.message_id || res?.message_id;
2070
2311
  }
2071
2312
  catch (fallbackErr) {
2072
2313
  console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
2314
+ if (this.isOutOfChatError(fallbackErr))
2315
+ this.markCurrentBotUnavailable(chatId, fallbackErr);
2073
2316
  throw fallbackErr;
2074
2317
  }
2075
2318
  }
@@ -1,7 +1,29 @@
1
1
  import type { ProgressEvent } from "./openclaw-client";
2
+ /** One activity line shown in the card content area. */
3
+ export type LiveStatusLine = {
4
+ kind: "tool_start" | "tool_end" | "text" | "lifecycle" | "summary";
5
+ text: string;
6
+ /** Relative time (seconds since run start) when this line was produced. */
7
+ at: number;
8
+ };
9
+ /** Structured snapshot the renderer turns into a Feishu interactive card. */
10
+ export type LiveStatusView = {
11
+ /** Card title, e.g. "Claude 正在执行" / "✅ Claude 已完成". */
12
+ title: string;
13
+ /** Activity lines (oldest first); when finished this holds a summary instead. */
14
+ lines: LiveStatusLine[];
15
+ /** Footer: elapsed time like "0:42". */
16
+ elapsed: string;
17
+ /** Footer: model name, e.g. "phgeek-gw/claude-opus-4.8". */
18
+ model?: string;
19
+ /** Terminal state: running / done / failed (lets renderer pick color). */
20
+ state: "running" | "done" | "failed";
21
+ };
2
22
  export type LiveStatusCallbacks = {
3
- create: (text: string) => Promise<string | undefined>;
4
- edit: (messageId: string, text: string) => Promise<void>;
23
+ /** Create the status card; returns the new message id. */
24
+ create: (view: LiveStatusView) => Promise<string | undefined>;
25
+ /** Patch the existing status card. */
26
+ edit: (messageId: string, view: LiveStatusView) => Promise<void>;
5
27
  warn?: (message: string, err?: unknown) => void;
6
28
  };
7
29
  export type LiveStatusOptions = {
@@ -9,48 +31,67 @@ export type LiveStatusOptions = {
9
31
  locale?: "zh" | "en";
10
32
  delayMs?: number;
11
33
  maxChars?: number;
12
- maxEdits?: number;
13
- disableHint?: string;
34
+ /** Model name shown in the footer. */
35
+ model?: string;
36
+ /** Number of recent activity lines kept in the content area. */
37
+ historySize?: number;
38
+ /** Footer/auto-refresh cadence in ms. */
39
+ tickMs?: number;
14
40
  };
15
41
  export declare class LiveStatusController {
16
42
  private readonly callbacks;
17
43
  private readonly opts;
18
44
  private messageId?;
19
- private lastSentText;
20
- private detail;
21
- /** Number of edits already issued against the live message (excludes create). */
22
- private editCount;
45
+ private lastSentSignature;
46
+ /** Recent activity lines (most-recent last), capped at historySize. */
47
+ private lines;
48
+ private startedAt;
23
49
  private createTimer?;
50
+ private tickTimer?;
24
51
  private createPromise?;
25
52
  private finalized;
26
53
  private disabled;
27
- /** True after the running-state budget is spent; future progress is ignored. */
28
- private runningLimitReached;
54
+ private state;
55
+ /** Total tool calls (tool_start events) seen during the run, for the summary. */
56
+ private toolCallCount;
57
+ /** True when finished via noReply(): show a "no content" summary. */
58
+ private noReplyResult;
29
59
  constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
30
60
  get id(): string | undefined;
31
61
  start(initialDetail?: string): void;
32
62
  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
63
  complete(): Promise<void>;
64
+ fail(): Promise<void>;
41
65
  /**
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.
66
+ * Finish when the model explicitly produced no reply (NO_REPLY) or an empty
67
+ * final. Mark the card done but show a "no content" summary instead of the
68
+ * activity window, so the user understands the run finished without output.
45
69
  */
46
- fail(): Promise<void>;
70
+ noReply(): Promise<void>;
47
71
  dispose(): void;
72
+ /**
73
+ * Intermediate assistant text arrives as incremental fragments (each flush
74
+ * sends only the newly-added slice). Consecutive fragments with no tool event
75
+ * in between belong to the same thought, so merge them into ONE line instead
76
+ * of showing each fragment (or each table row) as a separate message. A tool
77
+ * event resets this, so the next text starts a fresh line.
78
+ */
79
+ private appendText;
80
+ private normalizeText;
81
+ private clip;
82
+ private pushLine;
48
83
  private stopTimers;
49
84
  private ensureCreatedNow;
50
85
  private ensureCreated;
51
- private editRunningStatus;
86
+ /** Refresh the elapsed-time footer periodically even with no new activity. */
87
+ private startTicker;
52
88
  private safeEdit;
53
- private formatToolStart;
54
- private formatStatus;
55
- private formatLimitNotice;
89
+ /** Dedupe key includes elapsed so the footer timer ticks even without new
90
+ * activity, while identical content within the same second is still skipped. */
91
+ private signature;
92
+ private isNoReplyText;
93
+ private formatTool;
94
+ private formatElapsed;
95
+ private buildView;
96
+ private buildSummaryLine;
56
97
  }
@@ -1,23 +1,29 @@
1
1
  const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
2
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);
3
+ // How many recent activity lines to keep in the card content area.
4
+ const DEFAULT_HISTORY = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_HISTORY || 6);
5
+ // How often to refresh the elapsed-time footer even when no new activity arrives,
6
+ // so the "已用时间" keeps advancing. Card patch has no 20-edit cap (unlike text
7
+ // edit), so a modest cadence is safe.
8
+ const DEFAULT_TICK_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_TICK_MS || 3000);
7
9
  export class LiveStatusController {
8
10
  callbacks;
9
11
  opts;
10
12
  messageId;
11
- lastSentText = "";
12
- detail = "";
13
- /** Number of edits already issued against the live message (excludes create). */
14
- editCount = 0;
13
+ lastSentSignature = "";
14
+ /** Recent activity lines (most-recent last), capped at historySize. */
15
+ lines = [];
16
+ startedAt = Date.now();
15
17
  createTimer;
18
+ tickTimer;
16
19
  createPromise;
17
20
  finalized = false;
18
21
  disabled = false;
19
- /** True after the running-state budget is spent; future progress is ignored. */
20
- runningLimitReached = false;
22
+ state = "running";
23
+ /** Total tool calls (tool_start events) seen during the run, for the summary. */
24
+ toolCallCount = 0;
25
+ /** True when finished via noReply(): show a "no content" summary. */
26
+ noReplyResult = false;
21
27
  constructor(callbacks, opts) {
22
28
  this.callbacks = callbacks;
23
29
  this.opts = opts;
@@ -26,78 +32,138 @@ export class LiveStatusController {
26
32
  start(initialDetail) {
27
33
  if (this.disabled || this.finalized || this.createTimer || this.messageId)
28
34
  return;
29
- this.detail = initialDetail || "";
35
+ this.startedAt = Date.now();
36
+ if (initialDetail && initialDetail.trim()) {
37
+ this.pushLine("lifecycle", initialDetail.trim());
38
+ }
30
39
  this.createTimer = setTimeout(() => {
31
40
  this.createTimer = undefined;
32
41
  this.createPromise = this.ensureCreated();
33
42
  }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
34
43
  }
35
44
  async progress(event) {
36
- if (this.disabled || this.finalized || this.runningLimitReached)
45
+ if (this.disabled || this.finalized)
37
46
  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")
47
+ // Card patch has no 20-edit cap (verified against Feishu im.message.patch),
48
+ // so we can show a small rolling window of recent activity: tool start, tool
49
+ // end, and intermediate assistant text each count as one line. We ignore
50
+ // tool "error" (the error is delivered through the normal message path) and
51
+ // lifecycle ticks after the first.
52
+ if (typeof event === "string") {
53
+ const t = event.trim();
54
+ if (!t || this.isNoReplyText(t))
55
+ return;
56
+ this.appendText(t);
57
+ }
58
+ else if (event.kind === "tool") {
59
+ if (event.phase === "start") {
60
+ this.toolCallCount++;
61
+ this.pushLine("tool_start", this.formatTool(event));
62
+ }
63
+ else if (event.phase === "end")
64
+ this.pushLine("tool_end", this.formatTool(event));
65
+ else
66
+ return; // tool error -> normal error path
67
+ }
68
+ else if (event.kind === "assistant_note") {
69
+ const t = (event.text || "").trim();
70
+ if (!t || this.isNoReplyText(t))
43
71
  return;
44
- this.detail = this.formatToolStart(event);
72
+ this.appendText(t);
45
73
  }
46
74
  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();
75
+ return; // lifecycle ticks: ignore (footer timer already advances)
50
76
  }
51
- if (!this.detail)
52
- return;
53
77
  await this.ensureCreatedNow();
54
- await this.editRunningStatus();
78
+ await this.safeEdit(this.buildView());
55
79
  }
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
80
  async complete() {
81
+ this.state = "done";
64
82
  this.finalized = true;
65
83
  this.stopTimers();
66
84
  if (this.createPromise)
67
85
  await this.createPromise.catch(() => { });
68
86
  if (!this.messageId || this.disabled)
69
87
  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);
88
+ await this.safeEdit(this.buildView(), true);
89
+ }
90
+ async fail() {
91
+ this.state = "failed";
92
+ this.finalized = true;
93
+ this.stopTimers();
94
+ if (this.createPromise)
95
+ await this.createPromise.catch(() => { });
96
+ if (!this.messageId || this.disabled)
97
+ return;
98
+ await this.safeEdit(this.buildView(), true);
74
99
  }
75
100
  /**
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.
101
+ * Finish when the model explicitly produced no reply (NO_REPLY) or an empty
102
+ * final. Mark the card done but show a "no content" summary instead of the
103
+ * activity window, so the user understands the run finished without output.
79
104
  */
80
- async fail() {
105
+ async noReply() {
106
+ this.state = "done";
107
+ this.noReplyResult = true;
81
108
  this.finalized = true;
82
109
  this.stopTimers();
83
110
  if (this.createPromise)
84
111
  await this.createPromise.catch(() => { });
85
112
  if (!this.messageId || this.disabled)
86
113
  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);
114
+ await this.safeEdit(this.buildView(), true);
91
115
  }
92
116
  dispose() {
93
117
  this.finalized = true;
94
118
  this.stopTimers();
95
119
  }
120
+ /**
121
+ * Intermediate assistant text arrives as incremental fragments (each flush
122
+ * sends only the newly-added slice). Consecutive fragments with no tool event
123
+ * in between belong to the same thought, so merge them into ONE line instead
124
+ * of showing each fragment (or each table row) as a separate message. A tool
125
+ * event resets this, so the next text starts a fresh line.
126
+ */
127
+ appendText(fragment) {
128
+ const last = this.lines[this.lines.length - 1];
129
+ if (last && last.kind === "text") {
130
+ const merged = `${last.text} ${this.normalizeText(fragment)}`.replace(/\s+/g, " ").trim();
131
+ last.text = this.clip(merged);
132
+ return;
133
+ }
134
+ this.pushLine("text", fragment);
135
+ }
136
+ normalizeText(text) {
137
+ return text.replace(/\s+/g, " ").trim();
138
+ }
139
+ clip(text) {
140
+ const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
141
+ return text.length > maxChars ? `${text.slice(0, Math.max(0, maxChars - 1))}\u2026` : text;
142
+ }
143
+ pushLine(kind, text) {
144
+ const clean = this.normalizeText(text);
145
+ if (!clean)
146
+ return;
147
+ const clipped = this.clip(clean);
148
+ // Collapse consecutive identical lines (e.g. repeated "等待 OpenClaw").
149
+ const last = this.lines[this.lines.length - 1];
150
+ if (last && last.kind === kind && last.text === clipped)
151
+ return;
152
+ const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
153
+ this.lines.push({ kind, text: clipped, at });
154
+ const limit = this.opts.historySize ?? DEFAULT_HISTORY;
155
+ if (this.lines.length > limit)
156
+ this.lines = this.lines.slice(this.lines.length - limit);
157
+ }
96
158
  stopTimers() {
97
159
  if (this.createTimer) {
98
160
  clearTimeout(this.createTimer);
99
161
  this.createTimer = undefined;
100
162
  }
163
+ if (this.tickTimer) {
164
+ clearInterval(this.tickTimer);
165
+ this.tickTimer = undefined;
166
+ }
101
167
  }
102
168
  async ensureCreatedNow() {
103
169
  if (this.createTimer) {
@@ -111,45 +177,45 @@ export class LiveStatusController {
111
177
  async ensureCreated() {
112
178
  if (this.disabled || this.finalized || this.messageId)
113
179
  return;
114
- const text = this.formatStatus(true);
180
+ const view = this.buildView();
115
181
  try {
116
- const id = await this.callbacks.create(text);
182
+ const id = await this.callbacks.create(view);
117
183
  if (!id) {
118
184
  this.disabled = true;
119
185
  return;
120
186
  }
121
187
  this.messageId = id;
122
- this.lastSentText = text;
188
+ this.lastSentSignature = this.signature(view);
189
+ this.startTicker();
123
190
  }
124
191
  catch (err) {
125
192
  this.disabled = true;
126
193
  this.callbacks.warn?.("live status create failed", err);
127
194
  }
128
195
  }
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)
196
+ /** Refresh the elapsed-time footer periodically even with no new activity. */
197
+ startTicker() {
198
+ if (this.tickTimer || this.disabled || this.finalized)
135
199
  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;
200
+ const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
201
+ this.tickTimer = setInterval(() => {
202
+ if (this.finalized || this.disabled || !this.messageId)
203
+ return;
204
+ void this.safeEdit(this.buildView());
205
+ }, tickMs);
206
+ this.tickTimer.unref?.();
141
207
  }
142
- async safeEdit(text, force = false) {
208
+ async safeEdit(view, force = false) {
143
209
  if (!this.messageId || this.disabled)
144
210
  return false;
145
211
  if (!force && this.finalized)
146
212
  return false;
147
- if (!force && text === this.lastSentText)
213
+ const sig = this.signature(view);
214
+ if (!force && sig === this.lastSentSignature)
148
215
  return true;
149
216
  try {
150
- await this.callbacks.edit(this.messageId, text);
151
- this.lastSentText = text;
152
- this.editCount++;
217
+ await this.callbacks.edit(this.messageId, view);
218
+ this.lastSentSignature = sig;
153
219
  return true;
154
220
  }
155
221
  catch (err) {
@@ -159,26 +225,62 @@ export class LiveStatusController {
159
225
  return false;
160
226
  }
161
227
  }
162
- formatToolStart(event) {
228
+ /** Dedupe key includes elapsed so the footer timer ticks even without new
229
+ * activity, while identical content within the same second is still skipped. */
230
+ signature(view) {
231
+ return `${view.state}|${view.title}|${view.elapsed}|${view.lines.map((l) => `${l.kind}@${l.at}:${l.text}`).join("|")}`;
232
+ }
233
+ isNoReplyText(text) {
234
+ return text.trim().toUpperCase() === "NO_REPLY";
235
+ }
236
+ formatTool(event) {
163
237
  const raw = event.text || event.name || "";
164
- const prefix = `${event.name} start`;
238
+ const prefix = `${event.name} ${event.phase}`;
165
239
  let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
166
240
  if (detail.startsWith(":"))
167
241
  detail = detail.slice(1).trim();
168
242
  return detail ? `${event.name}: ${detail}` : event.name;
169
243
  }
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`;
244
+ formatElapsed() {
245
+ const totalSec = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
246
+ const m = Math.floor(totalSec / 60);
247
+ const s = totalSec % 60;
248
+ return `${m}:${String(s).padStart(2, "0")}`;
249
+ }
250
+ buildView() {
251
+ const en = this.opts.locale === "en";
252
+ let title;
253
+ if (this.state === "done")
254
+ title = en ? `\u2705 ${this.opts.botName} done` : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
255
+ else if (this.state === "failed")
256
+ title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
257
+ else
258
+ title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
259
+ // When finished, replace the recent-activity window with a run summary
260
+ // (total tool calls + total time) instead of the last few messages.
261
+ const lines = this.state === "running"
262
+ ? [...this.lines]
263
+ : [this.buildSummaryLine(en)];
264
+ return {
265
+ title,
266
+ lines,
267
+ elapsed: this.formatElapsed(),
268
+ model: this.opts.model,
269
+ state: this.state,
270
+ };
271
+ }
272
+ buildSummaryLine(en) {
273
+ const elapsed = this.formatElapsed();
274
+ const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
275
+ if (this.noReplyResult) {
276
+ const text = en
277
+ ? `Model returned no content \u00b7 ${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
278
+ : `\u6a21\u578b\u6ca1\u6709\u56de\u590d\u5185\u5bb9 \u00b7 \u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
279
+ return { kind: "summary", text, at };
280
+ }
281
+ const text = en
282
+ ? `${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
283
+ : `\u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
284
+ return { kind: "summary", text, at };
183
285
  }
184
286
  }
@@ -70,6 +70,12 @@ export declare class MessageStore {
70
70
  enqueueDelivery(item: Omit<DeliveryOutboxItem, "id" | "status" | "attempts" | "createdAt" | "updatedAt">): number | null;
71
71
  getDeliveryBySource(sessionKey: string, sourceType: string, sourceId: string): DeliveryOutboxItem | null;
72
72
  getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
73
+ markBotSeenInChat(botName: string, chatId: string): void;
74
+ hasBotSeenInChat(botName: string, chatId: string): boolean;
75
+ markBotUnavailableInChat(botName: string, chatId: string, reason?: string): void;
76
+ clearBotUnavailableInChat(botName: string, chatId: string): void;
77
+ isBotUnavailableInChat(botName: string, chatId: string): boolean;
78
+ hasAnyDeliveredToChat(botName: string, chatId: string): boolean;
73
79
  hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
74
80
  hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
75
81
  resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
@@ -111,6 +111,27 @@ export class MessageStore {
111
111
  PRIMARY KEY (bot_name, chat_id)
112
112
  );
113
113
 
114
+ -- Durable positive membership signal. Feishu does not reliably expose bot
115
+ -- members via chat_members, so receiving an event in a chat is the strongest
116
+ -- proof that this bot app is actually installed in that group.
117
+ CREATE TABLE IF NOT EXISTS bot_chat_seen (
118
+ bot_name TEXT NOT NULL,
119
+ chat_id TEXT NOT NULL,
120
+ updated_at INTEGER NOT NULL,
121
+ PRIMARY KEY (bot_name, chat_id)
122
+ );
123
+
124
+ -- Durable negative membership cache. Feishu message create returns 230002
125
+ -- when a bot app is not in a chat; cache that so discussion scheduling and
126
+ -- later proactive deliveries do not keep generating ghost participants.
127
+ CREATE TABLE IF NOT EXISTS bot_chat_unavailable (
128
+ bot_name TEXT NOT NULL,
129
+ chat_id TEXT NOT NULL,
130
+ reason TEXT NOT NULL DEFAULT '',
131
+ updated_at INTEGER NOT NULL,
132
+ PRIMARY KEY (bot_name, chat_id)
133
+ );
134
+
114
135
  -- Durable delivery ledger for user-visible assistant outputs. All final
115
136
  -- text/attachment outputs should be inserted here first and dispatched
116
137
  -- once, regardless of whether they came from chat final, proactive
@@ -409,6 +430,42 @@ export class MessageStore {
409
430
  `).all(maxCount);
410
431
  return rows.map((r) => this.mapDelivery(r));
411
432
  }
433
+ markBotSeenInChat(botName, chatId) {
434
+ this.db.prepare(`
435
+ INSERT INTO bot_chat_seen(bot_name, chat_id, updated_at)
436
+ VALUES (?, ?, ?)
437
+ ON CONFLICT(bot_name, chat_id) DO UPDATE SET updated_at = excluded.updated_at
438
+ `).run(botName, chatId, Date.now());
439
+ // If the bot is receiving events from this chat again, it is no longer out
440
+ // of the chat. This recovers automatically when a bot is re-added to a group.
441
+ this.clearBotUnavailableInChat(botName, chatId);
442
+ }
443
+ hasBotSeenInChat(botName, chatId) {
444
+ const row = this.db.prepare(`SELECT 1 FROM bot_chat_seen WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
445
+ return !!row;
446
+ }
447
+ markBotUnavailableInChat(botName, chatId, reason = '') {
448
+ this.db.prepare(`
449
+ INSERT INTO bot_chat_unavailable(bot_name, chat_id, reason, updated_at)
450
+ VALUES (?, ?, ?, ?)
451
+ ON CONFLICT(bot_name, chat_id) DO UPDATE SET reason = excluded.reason, updated_at = excluded.updated_at
452
+ `).run(botName, chatId, reason, Date.now());
453
+ }
454
+ clearBotUnavailableInChat(botName, chatId) {
455
+ this.db.prepare(`DELETE FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ?`).run(botName, chatId);
456
+ }
457
+ isBotUnavailableInChat(botName, chatId) {
458
+ const row = this.db.prepare(`SELECT 1 FROM bot_chat_unavailable WHERE bot_name = ? AND chat_id = ? LIMIT 1`).get(botName, chatId);
459
+ return !!row;
460
+ }
461
+ hasAnyDeliveredToChat(botName, chatId) {
462
+ const row = this.db.prepare(`
463
+ SELECT 1 FROM delivery_outbox
464
+ WHERE bot_name = ? AND chat_id = ? AND status = 'delivered'
465
+ LIMIT 1
466
+ `).get(botName, chatId);
467
+ return !!row;
468
+ }
412
469
  hasRecentSimilarDelivery(botName, chatId, contentHash, windowMs, sourceTypes) {
413
470
  if (!contentHash)
414
471
  return false;
@@ -90,6 +90,15 @@ export declare class OpenClawClient {
90
90
  * 30-minute safety net only for catastrophic WS disconnection.
91
91
  */
92
92
  private collectReply;
93
+ /**
94
+ * Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
95
+ * (it auto-compacts and keeps running the same run, then emits a real final).
96
+ * For these we must not reject collectReply immediately, or the bridge reports
97
+ * "没有完成回复" while the agent is still working. Currently this covers
98
+ * context-overflow / prompt-too-large, which OpenClaw recovers from via its
99
+ * context-overflow-recovery + auto-compaction path.
100
+ */
101
+ private isRecoverableAgentError;
93
102
  private pickBestCollectedText;
94
103
  createSession(params: {
95
104
  key: string;
@@ -810,13 +810,29 @@ export class OpenClawClient {
810
810
  return;
811
811
  }
812
812
  if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
813
+ const errText = String(ev.data?.error || "unknown");
814
+ // Some agent errors are recoverable: OpenClaw emits a lifecycle
815
+ // error (e.g. "Context overflow: prompt too large") and then
816
+ // auto-compacts and KEEPS RUNNING the same run, eventually emitting
817
+ // a real final. If we reject immediately the user sees a scary
818
+ // "没有完成回复" while the agent is in fact still working. So for
819
+ // recoverable errors we defer: record the failure text and keep
820
+ // waiting. Subsequent delta/item events reset the idle timer, so a
821
+ // real final wins; if the run truly stalls, the idle timeout falls
822
+ // back to this text.
823
+ if (this.isRecoverableAgentError(errText)) {
824
+ pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
825
+ resetIdleTimer();
826
+ console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
827
+ return;
828
+ }
813
829
  clearTimeout(idleTimer);
814
830
  clearInterval(poller);
815
831
  if (chatFinalTimer)
816
832
  clearTimeout(chatFinalTimer);
817
833
  if (lifecycleEndTimer)
818
834
  clearTimeout(lifecycleEndTimer);
819
- reject(new Error(`Agent error: ${ev.data?.error || "unknown"}`));
835
+ reject(new Error(`Agent error: ${errText}`));
820
836
  return;
821
837
  }
822
838
  }
@@ -824,6 +840,22 @@ export class OpenClawClient {
824
840
  }, 50);
825
841
  });
826
842
  }
843
+ /**
844
+ * Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
845
+ * (it auto-compacts and keeps running the same run, then emits a real final).
846
+ * For these we must not reject collectReply immediately, or the bridge reports
847
+ * "没有完成回复" while the agent is still working. Currently this covers
848
+ * context-overflow / prompt-too-large, which OpenClaw recovers from via its
849
+ * context-overflow-recovery + auto-compaction path.
850
+ */
851
+ isRecoverableAgentError(errText) {
852
+ const t = (errText || "").toLowerCase();
853
+ return t.includes("context overflow")
854
+ || t.includes("prompt too large")
855
+ || t.includes("context length")
856
+ || t.includes("too many tokens")
857
+ || t.includes("maximum context");
858
+ }
827
859
  pickBestCollectedText(chatFinalText, assistantText, chatDeltaText, transcriptAssistantText) {
828
860
  if (chatFinalText)
829
861
  return chatFinalText;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.22",
3
+ "version": "1.1.0",
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": {