openclaw-lark-multi-agent 1.4.11 → 1.4.13

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.
@@ -6,6 +6,7 @@ import { InactiveRunObservation } from "./openclaw-client.js";
6
6
  import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
7
7
  import { LiveStatusController } from "./live-status.js";
8
8
  import { CompactProgressController } from "./compact-progress.js";
9
+ import { errorIdentity } from "./terminal-errors.js";
9
10
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
10
11
  import { basename, extname, join, resolve } from "path";
11
12
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
@@ -117,6 +118,8 @@ export class FeishuBot {
117
118
  * message falls back to a normal run, cancel these before submission so a
118
119
  * later identical steer cannot consume the stale observer/correlation. */
119
120
  pendingSteerCleanups = new Map();
121
+ errorNoticeTimers = new Map();
122
+ errorNoticeChecks = new Map();
120
123
  adminOpenId;
121
124
  locale;
122
125
  configPath;
@@ -249,6 +252,19 @@ export class FeishuBot {
249
252
  this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
250
253
  await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
251
254
  try {
255
+ let provenance = meta?.error;
256
+ if (!provenance && meta?.genericErrorCandidate && meta.runId && meta.sessionKey) {
257
+ provenance = this.store.getErrorNotice(this.config.name, chatId, errorIdentity(meta.sessionKey, meta.runId))?.provenance;
258
+ }
259
+ if (meta?.sourceType === "run_error" || provenance) {
260
+ if (provenance) {
261
+ this.store.observeRunError(this.config.name, chatId, provenance);
262
+ await this.reconcileErrorNotices(chatId);
263
+ }
264
+ else
265
+ console.warn(`[${this.config.name}] Uncorrelated run_error retained in logs; no finality evidence`);
266
+ return; // errors never cancel pending results or complete live cards
267
+ }
252
268
  const sourceType = meta?.sourceType === "verbose_transcript" ? "verbose_transcript" : "assistant_visible";
253
269
  console.log(`[${this.config.name}] ${sourceType === "verbose_transcript" ? "Verbose transcript" : "Proactive message"} for ${chatId.slice(-8)}`);
254
270
  const parsed = this.extractBridgeAttachments(text);
@@ -260,7 +276,7 @@ export class FeishuBot {
260
276
  ? candidateTarget
261
277
  : undefined);
262
278
  try {
263
- 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, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model);
279
+ 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, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model, meta);
264
280
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
265
281
  await activeTarget?.liveStatus?.complete().catch(() => { });
266
282
  if (meta?.runId && runTarget)
@@ -363,6 +379,7 @@ export class FeishuBot {
363
379
  }
364
380
  // Re-subscribe to existing sessions
365
381
  await this.ensureSession(chat.chatId);
382
+ drainTasks.push(this.reconcileErrorNotices(chat.chatId));
366
383
  // Drain only messages that were explicitly marked as reply triggers.
367
384
  // Context-only messages should not start an OpenClaw run after restart.
368
385
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chat.chatId);
@@ -414,15 +431,8 @@ export class FeishuBot {
414
431
  const messageType = message.message_type;
415
432
  const messageId = message.message_id;
416
433
  const isBot = sender?.sender_type === "app";
417
- // Extract bot open_id from mentions
418
- if (message.mentions) {
419
- for (const m of message.mentions) {
420
- const bot = FeishuBot.allBots.get(m.id?.app_id || "");
421
- if (bot && m.id?.open_id) {
422
- bot.botOpenId = m.id.open_id;
423
- }
424
- }
425
- }
434
+ // Identity comes from the startup probe, never from message mentions:
435
+ // a conflicting app_id/open_id pair must not overwrite a known identity.
426
436
  if (messageType !== "text" && messageType !== "image" && messageType !== "file" && messageType !== "audio" && messageType !== "sticker" && messageType !== "post" && messageType !== "share_doc")
427
437
  return;
428
438
  // --- Dedup: atomically claim this message for this bot before any await.
@@ -512,7 +522,7 @@ export class FeishuBot {
512
522
  cleanText = await this.hydrateFeishuDocsInMessage(cleanText, content, messageId);
513
523
  if (!cleanText.trim())
514
524
  return;
515
- const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "");
525
+ const routing = this.getRoutingIntent(chatType, message, messageType === "text" ? (content.text || "") : "", content);
516
526
  // Commands may be prefixed by @all / @bot in group chats. Strip those
517
527
  // leading routing mentions before deciding whether this is a bridge command
518
528
  // or an escaped OpenClaw command.
@@ -551,6 +561,10 @@ export class FeishuBot {
551
561
  });
552
562
  if (insertedId < 0)
553
563
  insertedId = this.store.getMessageId(messageId) || -1;
564
+ // Target exclusivity precedes ALL group command/coordinator, broadcast,
565
+ // single-bot, mute, Free and Discuss paths. Unknown targets are not self.
566
+ if (chatType !== "p2p" && routing.hasTargetedMention && !routing.isCurrentBotMentioned)
567
+ return;
554
568
  // --- Commands: in p2p always respond; in group, check shouldRespond first ---
555
569
  // Single slash commands are handled by the bridge. Double slash commands were
556
570
  // already unescaped above and should pass through to OpenClaw instead.
@@ -589,35 +603,28 @@ export class FeishuBot {
589
603
  }
590
604
  }
591
605
  else if (isChairmanCommand) {
592
- // /chairman @Bot is owned by the mentioned bot itself. This avoids
593
- // requiring a coordinator to parse another bot's mention metadata,
594
- // which can vary across Feishu clients/bridges. Untargeted/status,
595
- // @all, and clear/off remain coordinator-owned group commands.
596
- //
597
- // Target resolution uses both Feishu mention metadata AND a text
598
- // fallback (parsing "@Bot" / "Bot" from the raw message), so a
599
- // /chairman that arrives without mention metadata still routes to
600
- // the intended bot instead of silently failing.
606
+ // Verified mentions belong to the targeted bot. Bare configured
607
+ // names remain local management arguments, not mention identity.
601
608
  const chairmanArg = this.stripLeadingCommandMentions(this.cleanMentions(rawText)).replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
602
609
  const isClearArg = ["off", "clear", "none"].includes(chairmanArg.split(/\s+/)[0] || "");
603
610
  const hasMentionMeta = (message.mentions || []).some((m) => !this.isAllMentionItem(m));
604
- const targets = this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
611
+ const targets = routing.hasTargetedMention ? routing.targetedBotNames : this.resolveChairmanTargets(message.mentions || [], cleanText, rawText);
605
612
  // A pure status query: no clear keyword, no mention metadata, and no
606
613
  // textual argument at all. Anything else is a set/route attempt.
607
614
  const isStatusArg = !isClearArg && !hasMentionMeta && chairmanArg.length === 0;
608
615
  if (isClearArg || isStatusArg) {
609
- // Group-level status/clear: one coordinator handles it.
610
- if (!this.isDiscussionCoordinator())
616
+ // One eligible owner: first verified target, or bare coordinator.
617
+ if (routing.hasTargetedMention ? routing.targetedBotNames[0] !== this.config.name : !this.isDiscussionCoordinator())
611
618
  return;
612
619
  }
613
620
  else if (targets.length >= 1) {
614
621
  // Targets resolved: only the (single) targeted bot acts. If
615
- // multiple distinct targets resolved, the coordinator handles
616
- // the "only one chairman" error once.
622
+ // multiple distinct targets resolved, the first target handles
623
+ // the "only one chairman" error once (never an unmentioned bot).
617
624
  const uniqueTargets = Array.from(new Set(targets));
618
625
  const targetedSelf = uniqueTargets.includes(this.config.name);
619
626
  if (uniqueTargets.length > 1) {
620
- if (!this.isDiscussionCoordinator())
627
+ if (uniqueTargets[0] !== this.config.name)
621
628
  return;
622
629
  }
623
630
  else if (!targetedSelf) {
@@ -800,6 +807,7 @@ export class FeishuBot {
800
807
  if (next === "free") {
801
808
  await this.replyMessage(messageId, `🔓 ${this.config.name} 已切换到 free 模式
802
809
  不需要 @ 也可以回复普通人类消息;如果消息明确 @ 了其他 bot 或普通人,我不会抢答。
810
+ 需先设置本群 Chairman,Free 才会回答无 @ 的普通消息。
803
811
  如需多轮自动讨论,请使用群级命令 /discuss on。`);
804
812
  }
805
813
  else {
@@ -845,7 +853,7 @@ export class FeishuBot {
845
853
  return;
846
854
  }
847
855
  if (commandName === "/chairman") {
848
- await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText);
856
+ await this.handleChairmanCommand(chatId, chatType, messageId, message.mentions || [], cleanText.trim(), rawText, routing.hasTargetedMention ? routing.targetedBotNames : undefined);
849
857
  markCommandSynced();
850
858
  return;
851
859
  }
@@ -855,11 +863,16 @@ export class FeishuBot {
855
863
  return;
856
864
  }
857
865
  }
866
+ // Local management commands above can restore Chairman. Only NEW
867
+ // unmentioned human triggers are gated; existing queue/delivery owners stay.
868
+ if (chatType !== "p2p" && !isBot && !routing.hasTargetedMention && !routing.isAllMention
869
+ && !this.store.getChairmanBot(chatId))
870
+ return;
858
871
  // --- Discuss mode: group-level multi-bot round scheduler. It takes over
859
872
  // plain human messages so normal Free mode does not duplicate Round 1.
860
873
  // Targeted mentions must fall through to normal routing so @GPT still
861
874
  // works while discuss mode is enabled.
862
- if (chatType !== "p2p" && !isBot && this.store.getChatInfo(chatId)?.discuss) {
875
+ if (chatType !== "p2p" && !isBot && this.store.getChairmanBot(chatId) && this.store.getChatInfo(chatId)?.discuss) {
863
876
  // Discuss mode owns ordinary and @all human messages. Explicit @bot/@human
864
877
  // falls through to normal targeted routing.
865
878
  if (!routing.hasTargetedMention) {
@@ -1702,10 +1715,8 @@ export class FeishuBot {
1702
1715
  return !isBot;
1703
1716
  if (isBot)
1704
1717
  return false;
1705
- const mentions = message.mentions || [];
1706
- if (this.isAllMention(rawText, mentions))
1707
- return true;
1708
- return this.isMentioned(mentions);
1718
+ const routing = this.getRoutingIntent(chatType, message, rawText);
1719
+ return routing.hasTargetedMention ? routing.isCurrentBotMentioned : routing.isAllMention;
1709
1720
  }
1710
1721
  shouldRespond(chatType, _message, isBot, chatId, routing) {
1711
1722
  if (chatType === "p2p")
@@ -1713,17 +1724,16 @@ export class FeishuBot {
1713
1724
  // Bot messages: only respond if this bot is explicitly mentioned.
1714
1725
  if (isBot)
1715
1726
  return routing.isCurrentBotMentioned;
1716
- // @all is an explicit broadcast to all non-muted bots.
1717
- if (routing.isAllMention)
1718
- return true;
1719
1727
  // Explicit targeted mentions are exclusive. If this bot is not one of the
1720
1728
  // mentioned bots, free/chairman must not steal the turn.
1721
1729
  if (routing.hasTargetedMention)
1722
1730
  return routing.isCurrentBotMentioned;
1723
- // Human-only mentions are also exclusive; free/chairman should not jump in.
1724
- if (routing.hasHumanMention)
1725
- return false;
1731
+ // @all alone is an explicit broadcast, not an unmentioned message.
1732
+ if (routing.isAllMention)
1733
+ return true;
1726
1734
  if (chatId) {
1735
+ if (!this.store.getChairmanBot(chatId))
1736
+ return false;
1727
1737
  if (this.store.getBotMode(this.config.name, chatId) === "free")
1728
1738
  return true;
1729
1739
  // Chairman fallback: if nobody is in free mode, the unique chairman
@@ -1734,12 +1744,46 @@ export class FeishuBot {
1734
1744
  }
1735
1745
  return false;
1736
1746
  }
1737
- getRoutingIntent(chatType, message, rawText) {
1738
- const mentions = message.mentions || [];
1747
+ getRoutingIntent(chatType, message, rawText = "", content) {
1748
+ const metadata = message.mentions || [];
1749
+ const mentions = [...metadata];
1750
+ const mentionTexts = [rawText];
1751
+ if (message.message_type === "post") {
1752
+ // Only structured post nodes carry mention semantics; do not interpret
1753
+ // arbitrary JSON strings as identity or broaden attachment handling.
1754
+ const posts = [content, ...Object.values(content || {})];
1755
+ for (const post of posts) {
1756
+ if (!Array.isArray(post?.content))
1757
+ continue;
1758
+ if (typeof post.title === "string")
1759
+ mentionTexts.push(post.title);
1760
+ for (const paragraph of post.content) {
1761
+ if (!Array.isArray(paragraph))
1762
+ continue;
1763
+ for (const node of paragraph) {
1764
+ if (node?.tag === "text" && typeof node.text === "string")
1765
+ mentionTexts.push(node.text);
1766
+ if (node?.tag !== "at")
1767
+ continue;
1768
+ // Rich-post user_id is matched only to an already probed open_id.
1769
+ mentions.push({ id: { open_id: node.user_id }, name: node.user_name });
1770
+ }
1771
+ }
1772
+ }
1773
+ }
1739
1774
  const isAllMention = chatType !== "p2p" && this.isAllMention(rawText, mentions);
1740
- const targetedBotNames = this.mentionedBotNames(mentions);
1775
+ // Structured nodes fill missing metadata, not override conflicting metadata.
1776
+ const targetedBotNames = this.mentionedBotNames(metadata.length ? metadata : mentions);
1777
+ // Missing metadata must not turn a platform placeholder or standalone @name
1778
+ // into an unmentioned Free/Chairman trigger. This grants NO identity. Emails
1779
+ // and embedded @ characters remain ordinary text, not commands or mentions.
1780
+ const unresolvedTextMention = mentionTexts.some(text => {
1781
+ const withoutAllPlaceholders = text.replace(/@_user_\d+/g, key => metadata.some(m => m.key === key && this.isAllMentionItem(m)) ? " " : key);
1782
+ return /@_user_\d+/.test(withoutAllPlaceholders)
1783
+ || /(^|\s)@(?!(?:_all|all|所有人)(?=\s|$))[^\s@]+/iu.test(withoutAllPlaceholders);
1784
+ });
1741
1785
  const hasHumanMention = mentions.some((m) => !this.isAllMentionItem(m) && !this.mentionedBotName(m));
1742
- const hasTargetedMention = targetedBotNames.length > 0 || hasHumanMention;
1786
+ const hasTargetedMention = targetedBotNames.length > 0 || hasHumanMention || unresolvedTextMention;
1743
1787
  return {
1744
1788
  isAllMention,
1745
1789
  targetedBotNames,
@@ -1757,44 +1801,32 @@ export class FeishuBot {
1757
1801
  return mentions.some((m) => this.isAllMentionItem(m));
1758
1802
  }
1759
1803
  isAllMentionItem(mention) {
1760
- return mention.key === "all" || mention.key === "@_all" || mention.id?.user_id === "all" || mention.id?.open_id === "all" || mention.name === "所有人";
1804
+ const id = mention.id || {};
1805
+ // A display name cannot override a concrete non-broadcast identity.
1806
+ if (id.app_id || (id.open_id && id.open_id !== "all") || (id.user_id && id.user_id !== "all"))
1807
+ return false;
1808
+ return mention.key === "all" || mention.key === "@_all" || id.user_id === "all" || id.open_id === "all";
1761
1809
  }
1762
1810
  mentionedBotName(mention) {
1763
1811
  if (this.isAllMentionItem(mention))
1764
1812
  return null;
1765
- const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this)];
1766
- for (const bot of candidates) {
1767
- if (mention.id?.app_id && mention.id.app_id === bot.config.appId)
1768
- return bot.config.name;
1769
- if (bot.botOpenId && mention.id?.open_id && mention.id.open_id === bot.botOpenId)
1770
- return bot.config.name;
1771
- }
1772
- // Name is only a fallback because Feishu should normally provide app_id/open_id.
1773
- // Keep it exact to avoid shared-prefix bots like 万万(GPT) / 万万(Claude)
1774
- // stealing each other's mentions.
1775
- if (typeof mention.name === "string") {
1776
- const raw = mention.name.trim().replace(/^@+/, "").replace(/\s+/g, "").toLowerCase();
1777
- for (const bot of candidates) {
1778
- const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
1779
- const exactNames = [
1780
- botName,
1781
- `万万(${botName})`,
1782
- `万万(${botName})`,
1783
- ];
1784
- if (exactNames.includes(raw))
1785
- return bot.config.name;
1786
- // Generic display-name fallback: many deployments name bots like
1787
- // "光子 (Claude)" or "万万(GPT)". Match only when the parenthesized
1788
- // suffix is exactly the configured bot name, avoiding loose substring
1789
- // matches that caused shared-prefix bots to steal each other's mentions.
1790
- if (new RegExp(`^[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw))
1791
- return bot.config.name;
1792
- }
1793
- }
1794
- return null;
1795
- }
1796
- escapeRegExp(value) {
1797
- return value.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
1813
+ const id = mention.id || {};
1814
+ const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
1815
+ const matches = candidates.filter((bot) => {
1816
+ // Known conflicting app/open credentials veto, regardless of order. An
1817
+ // unprobed open_id cannot establish identity, but a matching app_id can.
1818
+ if (id.app_id && id.app_id !== bot.config.appId)
1819
+ return false;
1820
+ if (id.open_id && bot.botOpenId && id.open_id !== bot.botOpenId)
1821
+ return false;
1822
+ if (id.open_id && candidates.some((other) => other !== bot && other.botOpenId === id.open_id))
1823
+ return false;
1824
+ return (id.app_id && id.app_id === bot.config.appId)
1825
+ || (id.open_id && bot.botOpenId && id.open_id === bot.botOpenId);
1826
+ });
1827
+ // Names (including exact/parenthesized suffixes) and unverified user/union
1828
+ // IDs never establish bot identity. Ambiguous matches fail closed.
1829
+ return matches.length === 1 ? matches[0].config.name : null;
1798
1830
  }
1799
1831
  resolveBotName(sender) {
1800
1832
  const openId = sender?.sender_id?.open_id;
@@ -2248,23 +2280,33 @@ export class FeishuBot {
2248
2280
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
2249
2281
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
2250
2282
  }
2251
- async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel) {
2283
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId) {
2252
2284
  if (!text.trim() && attachments.length === 0)
2253
2285
  return;
2254
2286
  const attachmentsJson = JSON.stringify(attachments);
2255
2287
  const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
2256
2288
  const contentHash = this.stableHash(normalizedPayload);
2257
2289
  const finalDeliveryKey = deliveryKey || sourceId;
2258
- const deliveryMetaJson = finalReplyModel?.trim()
2259
- ? JSON.stringify({ model: finalReplyModel.trim() })
2260
- : "{}";
2290
+ const deliveryMetaJson = JSON.stringify({
2291
+ ...(finalReplyModel?.trim() ? { model: finalReplyModel.trim() } : {}),
2292
+ ...(provenance ? { provenance } : {}), ...(errorNoticeId ? { errorNoticeId } : {}),
2293
+ });
2294
+ const correlatedFinal = sourceType === "assistant_visible" && provenance?.final && Boolean(provenance.runId && provenance.sessionKey);
2295
+ if (correlatedFinal) {
2296
+ const identity = { sessionKey: provenance.sessionKey, runId: provenance.runId };
2297
+ this.store.promoteExactRunFinal(this.config.name, chatId, identity.sessionKey, identity.runId, contentHash, text, attachmentsJson);
2298
+ if (!deliveryKey && this.store.hasExactRunFinalPayload(this.config.name, chatId, identity, text, attachmentsJson)) {
2299
+ await this.dispatchPendingDeliveries(chatId);
2300
+ return;
2301
+ }
2302
+ }
2261
2303
  if (sourceType === "verbose_transcript") {
2262
2304
  if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
2263
2305
  return;
2264
2306
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
2265
2307
  return;
2266
2308
  }
2267
- else if (!deliveryKey) {
2309
+ else if (!deliveryKey && !correlatedFinal) {
2268
2310
  if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
2269
2311
  return;
2270
2312
  if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
@@ -2291,6 +2333,15 @@ export class FeishuBot {
2291
2333
  // answer again on a second card. The original outbox row remains the sole
2292
2334
  // owner of text delivery; a different newly-frozen card is only collapsed.
2293
2335
  const existing = this.store.getDeliveryByKey(this.config.name, chatId, finalDeliveryKey);
2336
+ if (sourceType === "run_error") {
2337
+ // A withdrawn unsent notice may become terminal later. Keep its one
2338
+ // task key; never use the result recovery/correction/attachment paths.
2339
+ if (existing?.status === "failed" && !existing.textDelivered && existing.attempts < DELIVERY_MAX_ATTEMPTS) {
2340
+ this.store.renewUnsentErrorDelivery(existing.id, text, contentHash, deliveryMetaJson);
2341
+ }
2342
+ await this.dispatchPendingDeliveries(chatId);
2343
+ return;
2344
+ }
2294
2345
  // A failed owner must not permanently poison a logical trigger. Re-enqueue
2295
2346
  // the payload under a deterministic recovery key so a later authoritative
2296
2347
  // delivery gets a fresh retry budget.
@@ -2393,6 +2444,124 @@ export class FeishuBot {
2393
2444
  this.lastRealDeliveryAt.set(chatId, Date.now());
2394
2445
  }
2395
2446
  }
2447
+ async inspectErrorNotice(record) {
2448
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
2449
+ return { state: "suppressed", reason: "exact_run_final_platform_delivery_confirmed" };
2450
+ }
2451
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
2452
+ return { state: "pending", reason: "exact_run_final_delivery_pending" };
2453
+ }
2454
+ let verdict;
2455
+ try {
2456
+ verdict = await this.openclawClient.inspectRunError(record.provenance);
2457
+ }
2458
+ catch {
2459
+ verdict = { state: "pending", reason: "inspection_unavailable" };
2460
+ }
2461
+ // A stop or final may have arrived during the read-only lookup.
2462
+ const latest = this.store.getErrorNotice(this.config.name, record.chatId, record.noticeId);
2463
+ if (latest?.provenance.userStop) {
2464
+ record.provenance.userStop = true;
2465
+ return { state: "stopped", reason: "explicit_bridge_stop" };
2466
+ }
2467
+ if (latest && (latest.provenance.source !== record.provenance.source || latest.provenance.state !== record.provenance.state || latest.provenance.stopReason !== record.provenance.stopReason)) {
2468
+ record.provenance = latest.provenance;
2469
+ record.checks = latest.checks;
2470
+ return { state: "pending", reason: "stronger_terminal_evidence_observed" };
2471
+ }
2472
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
2473
+ return { state: "suppressed", reason: "exact_run_final_platform_delivery_confirmed" };
2474
+ }
2475
+ if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
2476
+ return { state: "pending", reason: "exact_run_final_delivery_pending" };
2477
+ }
2478
+ return verdict;
2479
+ }
2480
+ deferErrorNotice(record, verdict) {
2481
+ const delays = [15_000, 60_000, 120_000, 300_000, FOREGROUND_WAIT_MS];
2482
+ record.verdict = verdict;
2483
+ // Known-live task/delivery state is not an unknown diagnosis. Continue
2484
+ // low-frequency checks across the Gateway's 30-minute retry window, bounded
2485
+ // by the existing 24-hour observation horizon. Unknown evidence stays capped.
2486
+ if (verdict.reason === "matching_task_or_delivery_continuing")
2487
+ record.hasObservedContinuingTask = true;
2488
+ const continuing = record.hasObservedContinuingTask === true;
2489
+ const budget = continuing ? 300 : 6;
2490
+ record.status = record.checks >= budget || Date.now() - record.createdAt >= 24 * 60 * 60_000 ? "parked" : "pending";
2491
+ record.nextCheckAt = Date.now() + (continuing && record.checks >= 4 ? 300_000 : (delays[Math.max(0, record.checks - 1)] || FOREGROUND_WAIT_MS));
2492
+ this.store.saveErrorNotice(record);
2493
+ this.scheduleErrorNoticeCheck(record.chatId);
2494
+ }
2495
+ scheduleErrorNoticeCheck(chatId) {
2496
+ const previous = this.errorNoticeTimers.get(chatId);
2497
+ if (previous)
2498
+ clearTimeout(previous);
2499
+ this.errorNoticeTimers.delete(chatId);
2500
+ const next = this.store.listPendingErrorNotices(this.config.name, chatId)[0];
2501
+ if (!next)
2502
+ return;
2503
+ const timer = setTimeout(() => {
2504
+ this.errorNoticeTimers.delete(chatId);
2505
+ void this.reconcileErrorNotices(chatId).catch(err => console.warn(`[${this.config.name}] Error notice check unavailable:`, this.errorSummary(err)));
2506
+ }, Math.max(1000, next.nextCheckAt - Date.now()));
2507
+ timer.unref?.();
2508
+ this.errorNoticeTimers.set(chatId, timer);
2509
+ }
2510
+ /** Bounded read-only diagnosis; known live tasks retain low-frequency observation. */
2511
+ async reconcileErrorNotices(chatId) {
2512
+ const running = this.errorNoticeChecks.get(chatId);
2513
+ if (running)
2514
+ return running;
2515
+ const work = async () => {
2516
+ const timer = this.errorNoticeTimers.get(chatId);
2517
+ if (timer)
2518
+ clearTimeout(timer);
2519
+ this.errorNoticeTimers.delete(chatId);
2520
+ for (const snapshot of this.store.listPendingErrorNotices(this.config.name, chatId)) {
2521
+ // Earlier awaits may have observed a stop/stronger envelope for this row.
2522
+ const record = this.store.getErrorNotice(this.config.name, chatId, snapshot.noticeId);
2523
+ if (!record || record.status !== "pending")
2524
+ continue;
2525
+ if (record.nextCheckAt > Date.now())
2526
+ continue;
2527
+ if (record.checks >= (record.hasObservedContinuingTask || record.verdict?.reason === "matching_task_or_delivery_continuing" ? 300 : 6)) {
2528
+ // Normal exhausted checks are already parked. A still-pending row at
2529
+ // the cap can be a crash after budget reservation / before verdict or
2530
+ // outbox creation. Reserve one durable recovery probe, never per boot.
2531
+ if (record.recoveryProbeUsed) {
2532
+ this.deferErrorNotice(record, record.verdict || { state: "pending", reason: "budget_exhausted" });
2533
+ continue;
2534
+ }
2535
+ record.recoveryProbeUsed = true;
2536
+ }
2537
+ record.checks++;
2538
+ // Reserve the budget before awaiting RPC, including crash/disconnect.
2539
+ this.store.saveErrorNotice(record);
2540
+ const verdict = await this.inspectErrorNotice(record);
2541
+ console.log(`[${this.config.name}] Error notice ${record.noticeId}: ${verdict.state}/${verdict.reason}`);
2542
+ if (verdict.state === "pending") {
2543
+ this.deferErrorNotice(record, verdict);
2544
+ continue;
2545
+ }
2546
+ record.verdict = verdict;
2547
+ this.store.saveErrorNotice(record);
2548
+ if (verdict.state === "terminal" && verdict.terminalKey && verdict.text) {
2549
+ // Independent identity: never take the result's trigger/final key.
2550
+ await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
2551
+ // The send-time recheck may have parked or suppressed the observation.
2552
+ const refreshed = this.store.getErrorNotice(this.config.name, chatId, record.noticeId);
2553
+ if (refreshed?.verdict && ["pending", "suppressed", "stopped"].includes(refreshed.verdict.state))
2554
+ continue;
2555
+ }
2556
+ record.status = verdict.state;
2557
+ this.store.saveErrorNotice(record);
2558
+ }
2559
+ this.scheduleErrorNoticeCheck(chatId);
2560
+ };
2561
+ const promise = work().finally(() => { this.errorNoticeChecks.delete(chatId); });
2562
+ this.errorNoticeChecks.set(chatId, promise);
2563
+ return promise;
2564
+ }
2396
2565
  scheduleDeliveryRetry(chatId, attempt) {
2397
2566
  if (this.deliveryRetryTimers.has(chatId))
2398
2567
  return;
@@ -2439,7 +2608,32 @@ export class FeishuBot {
2439
2608
  const finalStatus = needsStatus ? formatSessionFooter(await this.readSessionStatus(chatId)) : undefined;
2440
2609
  if (needsStatus && !finalModel)
2441
2610
  finalModel = this.config.model;
2442
- if (shouldReplyToSource) {
2611
+ if (item.sourceType === "run_error") {
2612
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2613
+ const record = this.store.getErrorNotice(this.config.name, chatId, meta.errorNoticeId || "");
2614
+ const verdict = record ? await this.inspectErrorNotice(record) : { state: "pending", reason: "missing_persisted_provenance" };
2615
+ if (verdict.state !== "terminal" || verdict.terminalKey !== item.deliveryKey || verdict.text !== item.content) {
2616
+ this.store.withdrawErrorDelivery(item.id);
2617
+ if (record) {
2618
+ if (verdict.state === "pending" || verdict.state === "terminal")
2619
+ this.deferErrorNotice(record, { ...verdict, state: "pending" });
2620
+ else {
2621
+ record.status = verdict.state;
2622
+ record.verdict = verdict;
2623
+ this.store.saveErrorNotice(record);
2624
+ }
2625
+ }
2626
+ return;
2627
+ }
2628
+ record.verdict = verdict;
2629
+ this.store.saveErrorNotice(record);
2630
+ }
2631
+ if (item.sourceType === "run_error") {
2632
+ // Exactly one platform operation per guarded attempt. A card/text
2633
+ // fallback must not bypass a fresh finality check.
2634
+ await this.sendErrorNotice(chatId, item.content);
2635
+ }
2636
+ else if (shouldReplyToSource) {
2443
2637
  try {
2444
2638
  await this.replyFinalMessage(replyTarget, item.content, finalModel, finalStatus);
2445
2639
  }
@@ -2500,6 +2694,8 @@ export class FeishuBot {
2500
2694
  return;
2501
2695
  }
2502
2696
  this.store.markDeliveryFailed(item.id);
2697
+ if (item.sourceType === "run_error")
2698
+ return;
2503
2699
  const label = failureStage === "attachment" ? "附件发送失败" : "最终回复发送失败";
2504
2700
  const errorText = `⚠️ ${label}(已重试 ${DELIVERY_MAX_ATTEMPTS} 次):${this.errorSummary(err)}`;
2505
2701
  const replyTarget = item.replyToMessageId || replyToMessageId;
@@ -2531,48 +2727,17 @@ export class FeishuBot {
2531
2727
  .filter((name) => Boolean(name));
2532
2728
  return Array.from(new Set(names));
2533
2729
  }
2534
- mentionedBotNamesFromChairmanText(...texts) {
2535
- const normalizedTexts = texts
2536
- .filter(Boolean)
2537
- .map((text) => text.replace(/^\s*\/chairman\b/i, "").trim())
2538
- .filter(Boolean)
2539
- .map((text) => text.replace(/^@+/, "").replace(/\s+/g, "").toLowerCase());
2540
- if (normalizedTexts.length === 0)
2541
- return [];
2542
- const names = [];
2543
- const candidates = [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)];
2544
- for (const raw of normalizedTexts) {
2545
- for (const bot of candidates) {
2546
- const botName = bot.config.name.trim().replace(/\s+/g, "").toLowerCase();
2547
- const displayNames = [
2548
- botName,
2549
- `@${botName}`,
2550
- `万万(${botName})`,
2551
- `@万万(${botName})`,
2552
- `万万(${botName})`,
2553
- `@万万(${botName})`,
2554
- ];
2555
- if (displayNames.includes(raw) || new RegExp(`^@?[^()()]+[((]${this.escapeRegExp(botName)}[))]$`, "i").test(raw)) {
2556
- names.push(bot.config.name);
2557
- }
2558
- }
2559
- }
2560
- return Array.from(new Set(names));
2561
- }
2562
- /**
2563
- * Resolve the target bot name(s) for a /chairman command using every signal
2564
- * available, in priority order:
2565
- * 1. Feishu mention metadata (app_id / open_id / name).
2566
- * 2. Text fallback: "@Bot" or "Bot" written in the message body.
2567
- *
2568
- * This keeps /chairman working even when a Feishu client or bridge omits
2569
- * mention metadata. Returns a de-duplicated, order-preserving list.
2570
- */
2571
2730
  resolveChairmanTargets(mentions, text, rawText = "") {
2572
- const fromMeta = this.mentionedBotNames(mentions);
2573
- if (fromMeta.length > 0)
2574
- return fromMeta;
2575
- return this.mentionedBotNamesFromChairmanText(text, rawText);
2731
+ if (mentions.some((m) => !this.isAllMentionItem(m)))
2732
+ return this.mentionedBotNames(mentions);
2733
+ // Only a bare, exact configured name is a local management argument. Never
2734
+ // reinterpret an unresolved @name/placeholder as authority to target a bot.
2735
+ const argument = text.replace(/^\s*\/chairman\b/i, "").trim().toLowerCase();
2736
+ if (/(^|\s)@(?!(?:_all|all|所有人)(?=\s|$))[^\s@]+/iu.test(rawText) || argument.includes("@"))
2737
+ return [];
2738
+ return [this, ...Array.from(FeishuBot.allBots.values()).filter((bot) => bot !== this && bot.store === this.store)]
2739
+ .filter((bot) => bot.config.name.toLowerCase() === argument)
2740
+ .map((bot) => bot.config.name);
2576
2741
  }
2577
2742
  isDiscussionCoordinator() {
2578
2743
  const bots = Array.from(FeishuBot.allBots.values()).filter((bot) => bot.store === this.store);
@@ -3296,6 +3461,16 @@ export class FeishuBot {
3296
3461
  /**
3297
3462
  * Send a proactive message to a chat (not a reply).
3298
3463
  */
3464
+ async sendErrorNotice(chatId, text) {
3465
+ const res = await this.client.im.message.create({
3466
+ params: { receive_id_type: "chat_id" },
3467
+ data: { receive_id: chatId, msg_type: "text", content: JSON.stringify({ text }) },
3468
+ });
3469
+ const id = res?.data?.message_id || res?.message_id;
3470
+ if (!id)
3471
+ throw new Error("Error notice send returned no confirmed message id");
3472
+ return id;
3473
+ }
3299
3474
  async sendFinalMessage(chatId, text, model, status) {
3300
3475
  if (!model?.trim())
3301
3476
  return this.sendMessage(chatId, text);
@@ -3480,7 +3655,7 @@ export class FeishuBot {
3480
3655
  active ? `运行中:第 ${active.currentRound}/${active.maxRounds} 轮,topic=${active.topic.slice(0, 80)}` : "运行中:无",
3481
3656
  ].join("\n"));
3482
3657
  }
3483
- async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "") {
3658
+ async handleChairmanCommand(chatId, chatType, messageId, mentions, text, rawText = "", routedTargets) {
3484
3659
  if (chatType === "p2p") {
3485
3660
  await this.replyMessage(messageId, "❌ Chairman 只在群聊中可用");
3486
3661
  return;
@@ -3490,10 +3665,12 @@ export class FeishuBot {
3490
3665
  if (["off", "clear", "none"].includes(action)) {
3491
3666
  const previous = this.store.getChairmanBot(chatId);
3492
3667
  this.store.clearChairmanBot(chatId);
3493
- await this.replyMessage(messageId, previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman");
3668
+ discussionManager.stop(chatId); // Stop future turns; never abort in-flight agents.
3669
+ await this.replyMessage(messageId, (previous ? `✅ 已清除当前群 Chairman(原 ${previous})` : "✅ 当前群没有 Chairman")
3670
+ + "\nDiscuss 已关闭;无 @ 的普通新消息不再触发回复(含 Free)。这不是 mute,明确 @ 仍按原规则处理;已执行任务不被中止。");
3494
3671
  return;
3495
3672
  }
3496
- const resolvedBotNames = this.resolveChairmanTargets(mentions, text, rawText);
3673
+ const resolvedBotNames = routedTargets ?? this.resolveChairmanTargets(mentions, text, rawText);
3497
3674
  if (resolvedBotNames.length === 0) {
3498
3675
  // /chairman is intentionally not a status query. Status lives in /status;
3499
3676
  // this command is only for switching or clearing the Chairman. Keeping the