openclaw-lark-multi-agent 1.0.20 → 1.0.21

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,6 +73,9 @@ 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 getUnhealthySessionStatus;
77
+ private recoverFromUnhealthySession;
78
+ private withSessionHealthMonitor;
76
79
  /**
77
80
  * Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
78
81
  * Loops until no more unsynced human messages remain.
@@ -106,6 +109,17 @@ export declare class FeishuBot {
106
109
  private dispatchPendingDeliveries;
107
110
  private hasFreeModeBot;
108
111
  private mentionedBotNames;
112
+ private mentionedBotNamesFromChairmanText;
113
+ /**
114
+ * Resolve the target bot name(s) for a /chairman command using every signal
115
+ * available, in priority order:
116
+ * 1. Feishu mention metadata (app_id / open_id / name).
117
+ * 2. Text fallback: "@Bot" or "Bot" written in the message body.
118
+ *
119
+ * This keeps /chairman working even when a Feishu client or bridge omits
120
+ * mention metadata. Returns a de-duplicated, order-preserving list.
121
+ */
122
+ private resolveChairmanTargets;
109
123
  private isDiscussionCoordinator;
110
124
  private getDiscussionParticipants;
111
125
  private getChairmanParticipant;
@@ -14,6 +14,15 @@ const MAX_MERGED_TRIGGER_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT
14
14
  const MAX_MERGED_TRIGGER_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_MERGED_TRIGGER_BYTES || 128 * 1024);
15
15
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
16
16
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
17
+ const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
18
+ class UnhealthySessionError extends Error {
19
+ status;
20
+ constructor(status) {
21
+ super(`unhealthy session: ${status}`);
22
+ this.status = status;
23
+ this.name = "UnhealthySessionError";
24
+ }
25
+ }
17
26
  /**
18
27
  * Manages a single Feishu bot instance.
19
28
  *
@@ -261,6 +270,10 @@ export class FeishuBot {
261
270
  */
262
271
  async drainOnStartup() {
263
272
  try {
273
+ const restoredDeliveries = this.store.resetStaleDeliveries(this.config.name, 5 * 60_000, 5);
274
+ if (restoredDeliveries.restored > 0 || restoredDeliveries.failed > 0) {
275
+ console.warn(`[${this.config.name}] Startup delivery recovery: restored=${restoredDeliveries.restored}, failed=${restoredDeliveries.failed}`);
276
+ }
264
277
  const chats = this.store.getAllChatInfo();
265
278
  const drainTasks = [];
266
279
  for (const chat of chats) {
@@ -273,6 +286,12 @@ export class FeishuBot {
273
286
  // Drain only messages that were explicitly marked as reply triggers.
274
287
  // Context-only messages should not start an OpenClaw run after restart.
275
288
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
289
+ const pendingDeliveries = this.store.getPendingDeliveries(chat.chatId, this.config.name, 1);
290
+ if (pendingDeliveries.length > 0) {
291
+ drainTasks.push(this.dispatchPendingDeliveries(chat.chatId).catch((err) => {
292
+ console.warn(`[${this.config.name}] Startup delivery drain failed for ${chat.chatId.slice(-8)}:`, err.message);
293
+ }));
294
+ }
276
295
  if (pendingTriggerIds.size > 0) {
277
296
  console.log(`[${this.config.name}] Startup drain: ${pendingTriggerIds.size} pending trigger(s) in ${chat.chatName || chat.chatId.slice(-8)}`);
278
297
  // Do not let one slow/stuck chat block startup drain for all other chats.
@@ -350,8 +369,9 @@ export class FeishuBot {
350
369
  }
351
370
  // --- Extract text content based on message type ---
352
371
  let cleanText = "";
372
+ let rawText = "";
353
373
  if (messageType === "text") {
354
- const rawText = content.text || "";
374
+ rawText = content.text || "";
355
375
  cleanText = this.cleanMentions(rawText);
356
376
  // A mention-only text message is still a valid routing trigger. Feishu may
357
377
  // expose mentions as display text like "@万万(Claude)" rather than @_user_xxx,
@@ -467,9 +487,12 @@ export class FeishuBot {
467
487
  const isModelCommand = commandName === "/model";
468
488
  const rejectModelAll = chatType !== "p2p" && isModelCommand && routing.isAllMention;
469
489
  if (chatType !== "p2p") {
490
+ const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
491
+ const singleBotGroup = groupBotCount === 1;
470
492
  if (isModelCommand) {
471
493
  // Model switching is per-bot. @all is rejected by one coordinator;
472
- // otherwise group /model must explicitly target this bot.
494
+ // otherwise group /model must explicitly target this bot. A single-bot
495
+ // group treats the sole bot as the implicit target.
473
496
  if (routing.isAllMention) {
474
497
  if (!this.isDiscussionCoordinator())
475
498
  return;
@@ -478,12 +501,58 @@ export class FeishuBot {
478
501
  if (!routing.isCurrentBotMentioned)
479
502
  return;
480
503
  }
504
+ else if (!singleBotGroup) {
505
+ if (!this.isDiscussionCoordinator())
506
+ return;
507
+ }
508
+ }
509
+ else if (isChairmanCommand) {
510
+ // /chairman @Bot is owned by the mentioned bot itself. This avoids
511
+ // requiring a coordinator to parse another bot's mention metadata,
512
+ // which can vary across Feishu clients/bridges. Untargeted/status,
513
+ // @all, and clear/off remain coordinator-owned group commands.
514
+ //
515
+ // Target resolution uses both Feishu mention metadata AND a text
516
+ // fallback (parsing "@Bot" / "Bot" from the raw message), so a
517
+ // /chairman that arrives without mention metadata still routes to
518
+ // the intended bot instead of silently failing.
519
+ const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
520
+ const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
521
+ const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
522
+ const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
523
+ // A pure status query: no clear keyword, no mention metadata, and no
524
+ // textual argument at all. Anything else is a set/route attempt.
525
+ const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
526
+ if (isClearArg || isStatusArg) {
527
+ // Group-level status/clear: one coordinator handles it.
528
+ if (!this.isDiscussionCoordinator())
529
+ return;
530
+ }
531
+ else if (targets.length >= 1) {
532
+ // Targets resolved: only the (single) targeted bot acts. If
533
+ // multiple distinct targets resolved, the coordinator handles
534
+ // the "only one chairman" error once.
535
+ const uniqueTargets = Array.from(new Set(targets));
536
+ const targetedSelf = uniqueTargets.includes(this.config.name);
537
+ if (uniqueTargets.length > 1) {
538
+ if (!this.isDiscussionCoordinator())
539
+ return;
540
+ }
541
+ else if (!targetedSelf) {
542
+ return;
543
+ }
544
+ }
481
545
  else {
482
- return;
546
+ // A set attempt whose target could not be resolved at all (missing
547
+ // metadata AND no readable name in text): coordinator handles the
548
+ // fallback so the user still gets an actionable reply.
549
+ if (!this.isDiscussionCoordinator())
550
+ return;
483
551
  }
484
552
  }
485
- else if (isChairmanCommand || isLocaleCommand) {
486
- // Group-level settings; one coordinator handles them.
553
+ else if (isLocaleCommand && !routing.hasTargetedMention) {
554
+ // Untargeted or @all locale is a group-level setting; one coordinator handles it.
555
+ // If the user explicitly @s a bot, the targeted bot should execute and answer.
487
556
  if (!this.isDiscussionCoordinator())
488
557
  return;
489
558
  }
@@ -497,8 +566,9 @@ export class FeishuBot {
497
566
  if (!routing.isCurrentBotMentioned)
498
567
  return;
499
568
  }
500
- else if (!routing.isAllMention) {
501
- // Other bridge commands in a group need an explicit target or @all.
569
+ else if (!routing.isAllMention && !singleBotGroup) {
570
+ // Other bridge commands in a multi-bot group need an explicit target or @all.
571
+ // A single-bot group treats the sole bot as the implicit target.
502
572
  return;
503
573
  }
504
574
  }
@@ -583,6 +653,14 @@ export class FeishuBot {
583
653
  return;
584
654
  }
585
655
  if (commandName === "/model") {
656
+ if (chatType !== "p2p" && !routing.isAllMention && !routing.hasTargetedMention) {
657
+ const groupBotCount = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store).length;
658
+ if (groupBotCount > 1) {
659
+ await this.replyMessage(messageId, "❌ /model 是单 bot 设置,请明确 @ 一个 bot,例如:@Claude /model provider/model-id");
660
+ markCommandSynced();
661
+ return;
662
+ }
663
+ }
586
664
  await this.handleModelCommand(chatId, messageId, cleanText.trim());
587
665
  markCommandSynced();
588
666
  return;
@@ -665,7 +743,7 @@ export class FeishuBot {
665
743
  return;
666
744
  }
667
745
  if (commandName === "/chairman") {
668
- await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim());
746
+ await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
669
747
  markCommandSynced();
670
748
  return;
671
749
  }
@@ -687,7 +765,8 @@ export class FeishuBot {
687
765
  const chairman = this.getChairmanParticipant(chatId);
688
766
  const participants = this.getDiscussionParticipants(chatId);
689
767
  if (participants.length > 0 || chairman) {
690
- discussionManager.startIfAbsent({
768
+ const preempted = discussionManager.status(chatId);
769
+ const started = discussionManager.startIfAbsent({
691
770
  chatId,
692
771
  rootMessageId: messageId,
693
772
  topic: cleanText,
@@ -705,6 +784,11 @@ export class FeishuBot {
705
784
  }
706
785
  },
707
786
  });
787
+ if (started && preempted) {
788
+ await this.sendMessage(chatId, this.isEn(chatId)
789
+ ? `💬 New topic received; stopped the previous Discuss session and started a new one. Previous topic: ${preempted.topic.slice(0, 80)}`
790
+ : `💬 收到新话题,已停止上一轮 Discuss 并开启新讨论。上一话题:${preempted.topic.slice(0, 80)}`);
791
+ }
708
792
  }
709
793
  else if (this.isDiscussionCoordinator()) {
710
794
  await this.sendMessage(chatId, this.isEn(chatId)
@@ -735,6 +819,11 @@ export class FeishuBot {
735
819
  }
736
820
  // Track this message for reaction status updates
737
821
  const pending = this.pendingAckMessages.get(chatId) || [];
822
+ const unhealthy = await this.getUnhealthySessionStatus(chatId);
823
+ if (unhealthy) {
824
+ await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
825
+ return;
826
+ }
738
827
  // Anti-loop
739
828
  const streak = this.store.getBotStreak(chatId, this.config.name);
740
829
  if (streak >= MAX_BOT_STREAK) {
@@ -771,6 +860,65 @@ export class FeishuBot {
771
860
  console.error(`[${this.config.name}] Error:`, err);
772
861
  }
773
862
  }
863
+ async getUnhealthySessionStatus(chatId) {
864
+ const sessionKey = this.getSessionKey(chatId);
865
+ try {
866
+ const resp = await this.openclawClient.getSessionInfo(sessionKey);
867
+ const status = String(resp?.session?.status || "").toLowerCase();
868
+ if (!status)
869
+ return null;
870
+ if (["killed", "kill", "dead", "crashed", "crash", "failed", "failure", "error", "errored", "invalid"].some((bad) => status.includes(bad))) {
871
+ return status;
872
+ }
873
+ }
874
+ catch {
875
+ // Absence/transient describe failures should not block a new run; chat.send
876
+ // will create/report the session as usual.
877
+ }
878
+ return null;
879
+ }
880
+ async recoverFromUnhealthySession(chatId, messageId, insertedId, status) {
881
+ const sessionKey = this.getSessionKey(chatId);
882
+ await this.openclawClient.abortChat(sessionKey).catch(() => { });
883
+ 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
+ const acks = this.pendingAckMessages.get(chatId) || [];
888
+ for (const ack of acks) {
889
+ await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
890
+ await this.addReaction(ack.messageId, "FAIL").catch(() => { });
891
+ }
892
+ this.pendingAckMessages.set(chatId, []);
893
+ await this.addReaction(messageId, "FAIL").catch(() => { });
894
+ 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)}`);
898
+ }
899
+ async withSessionHealthMonitor(chatId, messageId, insertedId, work) {
900
+ let done = false;
901
+ const monitoredWork = work.finally(() => { done = true; });
902
+ const monitor = (async () => {
903
+ while (!done) {
904
+ await new Promise((resolve) => setTimeout(resolve, SESSION_HEALTH_POLL_MS));
905
+ if (done)
906
+ break;
907
+ const unhealthy = await this.getUnhealthySessionStatus(chatId);
908
+ if (unhealthy) {
909
+ await this.recoverFromUnhealthySession(chatId, messageId, insertedId, unhealthy);
910
+ throw new UnhealthySessionError(unhealthy);
911
+ }
912
+ }
913
+ return undefined;
914
+ })();
915
+ try {
916
+ return await Promise.race([monitoredWork, monitor]);
917
+ }
918
+ finally {
919
+ done = true;
920
+ }
921
+ }
774
922
  /**
775
923
  * Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
776
924
  * Loops until no more unsynced human messages remain.
@@ -798,6 +946,12 @@ export class FeishuBot {
798
946
  if (pendingHumanTriggers.length === 0) {
799
947
  break;
800
948
  }
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
+ }
801
955
  this.busyChats.set(chatId, Date.now());
802
956
  // Pick the batch to process this loop. Consecutive plain human messages are
803
957
  // merged into a single run; native escaped commands (//x) are processed on
@@ -850,6 +1004,17 @@ export class FeishuBot {
850
1004
  console.warn(`[${this.config.name}] Dropped ${droppedMergedTriggers.length} older pending trigger(s) for ${chatId.slice(-8)} due to merge limits (kept=${mergedTriggers.length}, bytes=${bytes}): ${summaries}`);
851
1005
  }
852
1006
  }
1007
+ const deliveredTriggerIds = mergedTriggers
1008
+ .map((m) => m.id || 0)
1009
+ .filter((id) => id > 0 && this.store.hasDeliveredReply(this.config.name, chatId, id));
1010
+ if (deliveredTriggerIds.length > 0) {
1011
+ console.warn(`[${this.config.name}] Clearing ${deliveredTriggerIds.length} stale delivered pending trigger(s) for ${chatId.slice(-8)}: ${deliveredTriggerIds.join(",")}`);
1012
+ for (const id of deliveredTriggerIds)
1013
+ this.store.clearPendingTrigger(this.config.name, chatId, id);
1014
+ mergedTriggers = mergedTriggers.filter((m) => !m.id || !deliveredTriggerIds.includes(m.id));
1015
+ if (mergedTriggers.length === 0)
1016
+ continue;
1017
+ }
853
1018
  const isNativeCommandTrigger = firstIsNative;
854
1019
  const lastHuman = mergedTriggers[mergedTriggers.length - 1];
855
1020
  const triggerId = lastHuman.id || 0;
@@ -934,7 +1099,7 @@ export class FeishuBot {
934
1099
  }
935
1100
  };
936
1101
  try {
937
- reply = await this.openclawClient.chatSendWithContext({
1102
+ reply = await this.withSessionHealthMonitor(chatId, lastHuman.messageId || "", triggerId, this.openclawClient.chatSendWithContext({
938
1103
  sessionKey,
939
1104
  unsyncedMessages: contextMsgs,
940
1105
  currentMessage: mergedContent,
@@ -958,7 +1123,7 @@ export class FeishuBot {
958
1123
  // INSERT OR IGNORE in message_sync.
959
1124
  markSubmittedBatch(`submitted:${runId}`);
960
1125
  },
961
- });
1126
+ }));
962
1127
  }
963
1128
  finally {
964
1129
  // OpenClaw may emit the final assistant session.message just after
@@ -1088,6 +1253,10 @@ export class FeishuBot {
1088
1253
  this.pendingAckMessages.set(chatId, remainingAcks);
1089
1254
  }
1090
1255
  catch (err) {
1256
+ if (err instanceof UnhealthySessionError) {
1257
+ console.warn(`[${this.config.name}] processQueue stopped because session became unhealthy: ${err.status}`);
1258
+ break;
1259
+ }
1091
1260
  console.error(`[${this.config.name}] processQueue error:`, err);
1092
1261
  const errorText = this.formatUserVisibleError(err);
1093
1262
  if (lastHuman.messageId) {
@@ -1185,7 +1354,7 @@ export class FeishuBot {
1185
1354
  return mentions.some((m) => this.mentionedBotName(m) === this.config.name);
1186
1355
  }
1187
1356
  isAllMention(rawText, mentions = []) {
1188
- if (rawText && (rawText.includes("@_all") || rawText.includes("@all") || rawText.includes("@所有人")))
1357
+ if (rawText && /(^|\s)@(?:_all|all|所有人)(?=\s|$)/i.test(rawText))
1189
1358
  return true;
1190
1359
  return mentions.some((m) => this.isAllMentionItem(m));
1191
1360
  }
@@ -1227,7 +1396,7 @@ export class FeishuBot {
1227
1396
  return null;
1228
1397
  }
1229
1398
  escapeRegExp(value) {
1230
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1399
+ return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
1231
1400
  }
1232
1401
  resolveBotName(sender) {
1233
1402
  const openId = sender?.sender_id?.open_id;
@@ -1390,12 +1559,6 @@ export class FeishuBot {
1390
1559
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
1391
1560
  return;
1392
1561
  }
1393
- else if (sourceType === "assistant_visible") {
1394
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["assistant_visible"]))
1395
- return;
1396
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 30, ["assistant_visible"]))
1397
- return;
1398
- }
1399
1562
  const deliveryId = this.store.enqueueDelivery({
1400
1563
  sessionKey: this.deliverySessionKey(chatId),
1401
1564
  chatId,
@@ -1473,6 +1636,49 @@ export class FeishuBot {
1473
1636
  .filter((name) => Boolean(name));
1474
1637
  return Array.from(new Set(names));
1475
1638
  }
1639
+ mentionedBotNamesFromChairmanText(...texts) {
1640
+ const normalizedTexts = texts
1641
+ .filter(Boolean)
1642
+ .map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
1643
+ .filter(Boolean)
1644
+ .map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
1645
+ if (normalizedTexts.length === 0)
1646
+ return [];
1647
+ const names = [];
1648
+ const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
1649
+ for (const raw of normalizedTexts) {
1650
+ for (const bot of candidates) {
1651
+ const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
1652
+ const displayNames = [
1653
+ botName,
1654
+ `@${botName}`,
1655
+ `万万(${botName})`,
1656
+ `@万万(${botName})`,
1657
+ `万万(${botName})`,
1658
+ `@万万(${botName})`,
1659
+ ];
1660
+ if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
1661
+ names.push(bot.config.name);
1662
+ }
1663
+ }
1664
+ }
1665
+ return Array.from(new Set(names));
1666
+ }
1667
+ /**
1668
+ * Resolve the target bot name(s) for a /chairman command using every signal
1669
+ * available, in priority order:
1670
+ * 1. Feishu mention metadata (app_id / open_id / name).
1671
+ * 2. Text fallback: "@Bot" or "Bot" written in the message body.
1672
+ *
1673
+ * This keeps /chairman working even when a Feishu client or bridge omits
1674
+ * mention metadata. Returns a de-duplicated, order-preserving list.
1675
+ */
1676
+ resolveChairmanTargets(mentions, text, rawText = "") {
1677
+ const fromMeta = this.mentionedBotNames(mentions);
1678
+ if (fromMeta.length > 0)
1679
+ return fromMeta;
1680
+ return this.mentionedBotNamesFromChairmanText(text, rawText);
1681
+ }
1476
1682
  isDiscussionCoordinator() {
1477
1683
  const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
1478
1684
  if (bots.length === 0)
@@ -1879,7 +2085,7 @@ export class FeishuBot {
1879
2085
  }
1880
2086
  if (action === "rounds") {
1881
2087
  const n = Number.parseInt(parts[2] || "", 10);
1882
- if (!Number.isFinite(n)) {
2088
+ if (!Number.isInteger(n) || n < 1 || n > 10) {
1883
2089
  await this.replyMessage(messageId, "❌ Usage: /discuss rounds <1-10>");
1884
2090
  return;
1885
2091
  }
@@ -1906,32 +2112,43 @@ export class FeishuBot {
1906
2112
  active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
1907
2113
  ].join("\n"));
1908
2114
  }
1909
- async handleChairmanCommand(chatId, chatType, messageId, mentions, text) {
2115
+ async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
1910
2116
  if (chatType === "p2p") {
1911
2117
  await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
1912
2118
  return;
1913
2119
  }
1914
2120
  const parts = text.split(/\s+/).filter(Boolean);
1915
- const action = (parts[1] || "status").toLowerCase();
2121
+ const action = (parts[1] || "").toLowerCase();
1916
2122
  if (["off", "clear", "none"].includes(action)) {
1917
2123
  const previous = this.store.getChairmanBot(chatId);
1918
2124
  this.store.clearChairmanBot(chatId);
1919
2125
  await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
1920
2126
  return;
1921
2127
  }
1922
- const botNames = this.mentionedBotNames(mentions);
1923
- if (botNames.length === 0) {
1924
- const current = this.store.getChairmanBot(chatId);
1925
- await this.replyMessage(messageId, current
1926
- ? `👑 当前 Chairman:${current}\n作用:普通消息无人 free 时由 TA 回答;Discuss 模式下负责主持、调停和最终总结。`
1927
- : "👑 当前没有 Chairman\n用法:/chairman @某个Bot");
2128
+ const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
2129
+ if (resolvedBotNames.length === 0) {
2130
+ // /chairman is intentionally not a status query. Status lives in /status;
2131
+ // this command is only for switching or clearing the Chairman. Keeping the
2132
+ // two meanings separate prevents failed mention parsing from being masked
2133
+ // as a harmless "current status" response.
2134
+ const groupBots = Array.from(FeishuBot.allBots.values())
2135
+ .filter((bot) => bot.store === this.store)
2136
+ .map((bot) => bot.config.name);
2137
+ const uniqueGroupBots = Array.from(new Set(groupBots));
2138
+ const choices = uniqueGroupBots.length > 0
2139
+ ? uniqueGroupBots.map((name) => `/chairman ${name}`).join("\n")
2140
+ : "/chairman @某个Bot";
2141
+ await this.replyMessage(messageId, `❌ /chairman 只用于设置/切换 Chairman,不再用于状态查询。\n` +
2142
+ `查看当前 Chairman 请用 /status。\n` +
2143
+ `切换请 @ 一个 bot,或直接发(可复制):\n${choices}\n` +
2144
+ `清除请用:/chairman off`);
1928
2145
  return;
1929
2146
  }
1930
- if (botNames.length > 1) {
2147
+ if (resolvedBotNames.length > 1) {
1931
2148
  await this.replyMessage(messageId, "❌ 一个群只能设置一个 Chairman。请只 @ 一个 bot。");
1932
2149
  return;
1933
2150
  }
1934
- const next = botNames[0];
2151
+ const next = resolvedBotNames[0];
1935
2152
  const previous = this.store.getChairmanBot(chatId);
1936
2153
  this.store.setChairmanBot(chatId, next);
1937
2154
  const mode = this.store.getBotMode(next, chatId);
@@ -2113,7 +2330,7 @@ export class FeishuBot {
2113
2330
  chatName: "私聊",
2114
2331
  members: "",
2115
2332
  memberNames: "",
2116
- ownerBot: this.config.name,
2333
+ ownerBot: existing?.ownerBot || this.config.name,
2117
2334
  freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
2118
2335
  verbose: this.store.getChatInfo(chatId)?.verbose || false,
2119
2336
  discuss: this.store.getChatInfo(chatId)?.discuss || false,
@@ -72,6 +72,10 @@ export declare class MessageStore {
72
72
  getPendingDeliveries(chatId?: string, botName?: string, maxCount?: number): DeliveryOutboxItem[];
73
73
  hasRecentSimilarDelivery(botName: string, chatId: string, contentHash: string, windowMs: number, sourceTypes?: string[]): boolean;
74
74
  hasRecentOverlappingDelivery(botName: string, chatId: string, content: string, attachmentsJson: string, windowMs: number, minShortLength?: number, sourceTypes?: string[]): boolean;
75
+ resetStaleDeliveries(botName: string, staleMs: number, maxAttempts: number): {
76
+ restored: number;
77
+ failed: number;
78
+ };
75
79
  claimDelivery(id: number): boolean;
76
80
  markDeliveryDelivered(id: number): void;
77
81
  markDeliveryFailed(id: number): void;
@@ -436,6 +436,20 @@ export class MessageStore {
436
436
  }
437
437
  return false;
438
438
  }
439
+ resetStaleDeliveries(botName, staleMs, maxAttempts) {
440
+ const cutoff = Date.now() - staleMs;
441
+ const fail = this.db.prepare(`
442
+ UPDATE delivery_outbox
443
+ SET status = 'failed', updated_at = ?
444
+ WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts >= ?
445
+ `).run(Date.now(), botName, cutoff, maxAttempts);
446
+ const restore = this.db.prepare(`
447
+ UPDATE delivery_outbox
448
+ SET status = 'pending', updated_at = ?
449
+ WHERE bot_name = ? AND status = 'delivering' AND updated_at < ? AND attempts < ?
450
+ `).run(Date.now(), botName, cutoff, maxAttempts);
451
+ return { restored: restore.changes, failed: fail.changes };
452
+ }
439
453
  claimDelivery(id) {
440
454
  const result = this.db.prepare(`
441
455
  UPDATE delivery_outbox SET status = 'delivering', attempts = attempts + 1, updated_at = ? WHERE id = ? AND status = 'pending'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
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": {