openclaw-lark-multi-agent 1.0.22 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,14 @@ 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 sendTextMessage;
130
151
  private replyTextMessage;
131
152
  private editTextMessage;
132
153
  private deleteMessageById;
@@ -137,6 +158,8 @@ export declare class FeishuBot {
137
158
  private inferFeishuFileType;
138
159
  private isImagePath;
139
160
  private errorSummary;
161
+ private isOutOfChatError;
162
+ private markCurrentBotUnavailable;
140
163
  private stripReadOnlyDocxFields;
141
164
  private createFeishuDocFromMarkdown;
142
165
  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;
@@ -1140,6 +1191,9 @@ export class FeishuBot {
1140
1191
  })
1141
1192
  : undefined;
1142
1193
  liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
1194
+ const activeTargetForStatus = this.activeDeliveryTargets.get(chatId);
1195
+ if (activeTargetForStatus?.triggerId === triggerId && liveStatus)
1196
+ activeTargetForStatus.liveStatus = liveStatus;
1143
1197
  try {
1144
1198
  reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
1145
1199
  sessionKey,
@@ -1259,6 +1313,7 @@ export class FeishuBot {
1259
1313
  if ((shouldReply || hasAttachments) && lastHuman.messageId && !isRuntimeFailure) {
1260
1314
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
1261
1315
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
1316
+ await liveStatus?.complete().catch(() => { });
1262
1317
  }
1263
1318
  else {
1264
1319
  try {
@@ -1283,7 +1338,7 @@ export class FeishuBot {
1283
1338
  }
1284
1339
  }
1285
1340
  if (isRuntimeFailure && lastHuman.messageId) {
1286
- liveStatus?.dispose();
1341
+ await liveStatus?.fail().catch(() => { });
1287
1342
  this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
1288
1343
  }
1289
1344
  else if (!shouldReply && !hasAttachments) {
@@ -1604,6 +1659,10 @@ export class FeishuBot {
1604
1659
  deliverySourceId(kind, text) {
1605
1660
  return `${kind}:${this.stableHash(text)}`;
1606
1661
  }
1662
+ async sendSystemDelivery(chatId, text) {
1663
+ const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
1664
+ await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
1665
+ }
1607
1666
  async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey) {
1608
1667
  if (!text.trim() && attachments.length === 0)
1609
1668
  return;
@@ -1672,6 +1731,8 @@ export class FeishuBot {
1672
1731
  this.store.markDeliveryDelivered(item.id);
1673
1732
  }
1674
1733
  catch (err) {
1734
+ if (this.isOutOfChatError(err))
1735
+ this.markCurrentBotUnavailable(chatId, err);
1675
1736
  if (item.id)
1676
1737
  this.store.markDeliveryFailed(item.id);
1677
1738
  const errorText = `⚠️ 附件发送失败:${this.errorSummary(err)}`;
@@ -1749,17 +1810,42 @@ export class FeishuBot {
1749
1810
  return true;
1750
1811
  return bots[0] === this;
1751
1812
  }
1813
+ static markBotSeenInChat(botName, chatId) {
1814
+ let set = FeishuBot.seenBotChats.get(botName);
1815
+ if (!set) {
1816
+ set = new Set();
1817
+ FeishuBot.seenBotChats.set(botName, set);
1818
+ }
1819
+ set.add(chatId);
1820
+ }
1821
+ markCurrentBotSeenInChat(chatId) {
1822
+ FeishuBot.markBotSeenInChat(this.config.name, chatId);
1823
+ this.store.markBotSeenInChat(this.config.name, chatId);
1824
+ }
1825
+ isBotAvailableInChat(bot, chatId) {
1826
+ // A bot can be globally configured but not actually installed in this Feishu
1827
+ // group. Discuss participants must be restricted to bots that either have
1828
+ // received an event in this chat during this process, or have previously
1829
+ // delivered a message successfully in this chat. Otherwise LMA may generate
1830
+ // "ghost" participants and Feishu will reject delivery with 230002
1831
+ // (Bot/User can NOT be out of the chat).
1832
+ if (this.store.isBotUnavailableInChat(bot.config.name, chatId))
1833
+ return false;
1834
+ return FeishuBot.seenBotChats.get(bot.config.name)?.has(chatId)
1835
+ || this.store.hasBotSeenInChat(bot.config.name, chatId)
1836
+ || this.store.hasAnyDeliveredToChat(bot.config.name, chatId);
1837
+ }
1752
1838
  getDiscussionParticipants(chatId) {
1753
1839
  const chairman = this.store.getChairmanBot(chatId);
1754
1840
  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")
1841
+ .filter((bot) => bot.store === this.store && bot.config.name !== chairman && bot.store.getBotMode(bot.config.name, chatId) !== "mute" && this.isBotAvailableInChat(bot, chatId))
1756
1842
  .map((bot) => this.asDiscussionParticipant(bot, chatId));
1757
1843
  }
1758
1844
  getChairmanParticipant(chatId) {
1759
1845
  const chairman = this.store.getChairmanBot(chatId);
1760
1846
  if (!chairman)
1761
1847
  return undefined;
1762
- const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman);
1848
+ const bot = Array.from(FeishuBot.allBots.values()).find((candidate) => candidate.store === this.store && candidate.config.name === chairman && this.isBotAvailableInChat(candidate, chatId));
1763
1849
  return bot ? this.asDiscussionParticipant(bot, chatId) : undefined;
1764
1850
  }
1765
1851
  asDiscussionParticipant(bot, chatId) {
@@ -1771,6 +1857,21 @@ export class FeishuBot {
1771
1857
  async runDiscussionTurn(chatId, prompt, meta) {
1772
1858
  const sessionKey = await this.ensureSession(chatId);
1773
1859
  const releaseProactiveMute = this.openclawClient.muteProactiveDelivery(sessionKey);
1860
+ const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
1861
+ const liveStatus = liveStatusEnabled
1862
+ ? new LiveStatusController({
1863
+ create: (text) => this.sendOrdered(chatId, () => this.sendTextMessage(chatId, text)),
1864
+ edit: (messageId, text) => this.sendOrdered(chatId, () => this.editTextMessage(messageId, text)),
1865
+ warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
1866
+ }, {
1867
+ botName: this.config.name,
1868
+ locale: this.isEn(chatId) ? "en" : "zh",
1869
+ disableHint: this.isEn(chatId)
1870
+ ? `Tip: send /livestatus off to hide this status message`
1871
+ : `提示:调用 /livestatus off 关闭该状态提示`,
1872
+ })
1873
+ : undefined;
1874
+ liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
1774
1875
  let reply;
1775
1876
  try {
1776
1877
  reply = await this.openclawClient.chatSendWithContext({
@@ -1781,8 +1882,13 @@ export class FeishuBot {
1781
1882
  deliver: false,
1782
1883
  timeoutMs: 1_800_000,
1783
1884
  emptyFinalAsNoReply: true,
1885
+ onProgress: (event) => { void liveStatus?.progress(event).catch((err) => console.warn(`[${this.config.name}] live status progress failed:`, err instanceof Error ? err.message : err)); },
1784
1886
  });
1785
1887
  }
1888
+ catch (err) {
1889
+ await liveStatus?.fail().catch(() => { });
1890
+ throw err;
1891
+ }
1786
1892
  finally {
1787
1893
  // OpenClaw can emit the final assistant session.message shortly after
1788
1894
  // chatSend/collectReply returns. Keep discussion proactive muted briefly;
@@ -1815,10 +1921,36 @@ export class FeishuBot {
1815
1921
  content: storedContent,
1816
1922
  timestamp: Date.now(),
1817
1923
  });
1818
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
1924
+ try {
1925
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments);
1926
+ }
1927
+ catch (err) {
1928
+ await liveStatus?.fail().catch(() => { });
1929
+ throw err;
1930
+ }
1819
1931
  }
1932
+ await liveStatus?.complete().catch(() => { });
1820
1933
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
1821
1934
  }
1935
+ async sendTextMessage(chatId, text) {
1936
+ try {
1937
+ const res = await this.client.im.message.create({
1938
+ params: { receive_id_type: "chat_id" },
1939
+ data: {
1940
+ receive_id: chatId,
1941
+ content: JSON.stringify({ text }),
1942
+ msg_type: "text",
1943
+ },
1944
+ });
1945
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
1946
+ return res?.data?.message_id || res?.message_id;
1947
+ }
1948
+ catch (err) {
1949
+ if (this.isOutOfChatError(err))
1950
+ this.markCurrentBotUnavailable(chatId, err);
1951
+ throw err;
1952
+ }
1953
+ }
1822
1954
  async replyTextMessage(messageId, text) {
1823
1955
  const res = await this.client.im.message.reply({
1824
1956
  path: { message_id: messageId },
@@ -1868,7 +2000,11 @@ export class FeishuBot {
1868
2000
  }
1869
2001
  extractBridgeAttachments(reply) {
1870
2002
  const attachments = [];
1871
- const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)<\/LMA_BRIDGE_ATTACHMENTS>/g;
2003
+ // Some models occasionally corrupt the closing tag from
2004
+ // </LMA_BRIDGE_ATTACHMENTS> to </parameter> while preserving the exact JSON
2005
+ // payload. Treat that as a recoverable marker shape; otherwise the whole
2006
+ // marker leaks as user-visible text and attachments are not sent.
2007
+ const markerPattern = /<LMA_BRIDGE_ATTACHMENTS>([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
1872
2008
  let text = reply.replace(markerPattern, (_match, jsonText) => {
1873
2009
  try {
1874
2010
  const parsed = JSON.parse(String(jsonText).trim());
@@ -1938,6 +2074,16 @@ export class FeishuBot {
1938
2074
  const msg = data?.msg || data?.message || err?.message || String(err);
1939
2075
  return `${code}${msg}`.slice(0, 800);
1940
2076
  }
2077
+ isOutOfChatError(err) {
2078
+ const data = err?.response?.data || err?.data;
2079
+ const code = data?.code;
2080
+ const msg = data?.msg || data?.message || err?.message || String(err);
2081
+ return Number(code) === 230002 || /Bot\/User can NOT be out of the chat/i.test(String(msg));
2082
+ }
2083
+ markCurrentBotUnavailable(chatId, err) {
2084
+ this.store.markBotUnavailableInChat(this.config.name, chatId, this.errorSummary(err));
2085
+ console.warn(`[${this.config.name}] Marked unavailable in chat ${chatId.slice(-8)} after Feishu out-of-chat error`);
2086
+ }
1941
2087
  stripReadOnlyDocxFields(value) {
1942
2088
  if (Array.isArray(value))
1943
2089
  return value.map((item) => this.stripReadOnlyDocxFields(item));
@@ -2052,10 +2198,13 @@ export class FeishuBot {
2052
2198
  msg_type: "interactive",
2053
2199
  },
2054
2200
  });
2201
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2055
2202
  return res?.data?.message_id || res?.message_id;
2056
2203
  }
2057
2204
  catch (err) {
2058
2205
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
2206
+ if (this.isOutOfChatError(err))
2207
+ this.markCurrentBotUnavailable(chatId, err);
2059
2208
  // Fallback to plain text
2060
2209
  try {
2061
2210
  const res = await this.client.im.message.create({
@@ -2066,10 +2215,13 @@ export class FeishuBot {
2066
2215
  msg_type: "text",
2067
2216
  },
2068
2217
  });
2218
+ this.store.clearBotUnavailableInChat(this.config.name, chatId);
2069
2219
  return res?.data?.message_id || res?.message_id;
2070
2220
  }
2071
2221
  catch (fallbackErr) {
2072
2222
  console.warn(`[${this.config.name}] sendMessage text failed:`, JSON.stringify(fallbackErr?.response?.data || fallbackErr?.data || { message: fallbackErr.message }));
2223
+ if (this.isOutOfChatError(fallbackErr))
2224
+ this.markCurrentBotUnavailable(chatId, fallbackErr);
2073
2225
  throw fallbackErr;
2074
2226
  }
2075
2227
  }
@@ -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;
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.0.23",
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": {