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.
- package/README.md +14 -6
- package/README.zh-CN.md +16 -8
- package/dist/discussion-manager.js +7 -0
- package/dist/feishu-bot.d.ts +8 -11
- package/dist/feishu-bot.js +303 -126
- package/dist/message-store.d.ts +30 -0
- package/dist/message-store.js +100 -1
- package/dist/openclaw-client.d.ts +8 -4
- package/dist/openclaw-client.js +94 -20
- package/dist/terminal-errors.d.ts +49 -0
- package/dist/terminal-errors.js +116 -0
- package/package.json +1 -1
package/dist/feishu-bot.js
CHANGED
|
@@ -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
|
-
//
|
|
418
|
-
|
|
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
|
-
//
|
|
593
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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 (
|
|
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
|
|
1706
|
-
|
|
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
|
-
//
|
|
1724
|
-
if (routing.
|
|
1725
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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 =
|
|
2259
|
-
?
|
|
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 (
|
|
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
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
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
|
-
|
|
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
|