openclaw-lark-multi-agent 1.8.19 → 1.8.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.
- package/dist/config.d.ts +15 -1
- package/dist/config.js +14 -2
- package/dist/feishu-bot.d.ts +47 -7
- package/dist/feishu-bot.js +423 -163
- package/dist/go-controller.d.ts +2 -0
- package/dist/go-controller.js +24 -8
- package/dist/input-status-display.d.ts +45 -0
- package/dist/input-status-display.js +67 -0
- package/dist/live-status.d.ts +22 -3
- package/dist/live-status.js +93 -25
- package/dist/message-store.d.ts +68 -2
- package/dist/message-store.js +249 -13
- package/dist/openclaw-client.d.ts +0 -6
- package/dist/openclaw-client.js +178 -67
- package/dist/queued-presentation.d.ts +8 -1
- package/dist/queued-presentation.js +109 -4
- package/dist/terminal-errors.d.ts +12 -0
- package/dist/terminal-errors.js +23 -3
- package/docs/releases/1.8.20.md +30 -0
- package/package.json +4 -3
package/dist/feishu-bot.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { INPUT_DISPLAY_LIMITS, LIVE_CARD_BYTES, displayClip, hintDetail, hintLabel, inertInputText, inputPreview, visibleInputPreview, selectInputHints } from "./input-status-display.js";
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
3
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
4
|
import * as lark from "@larksuiteoapi/node-sdk";
|
|
@@ -7,11 +8,11 @@ import { getI18n, normalizeLocale } from "./i18n.js";
|
|
|
7
8
|
import { InactiveRunObservation, AdmissionUnconfirmed, CollectorStalledError, FollowupAdmissionRefused } from "./openclaw-client.js";
|
|
8
9
|
import { SessionWaitPaused, isWaitTimeout, normalizeSessionRuntimeStatus, formatSessionFooter, FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
9
10
|
import { SupersededObservation } from "./openclaw-client.js";
|
|
10
|
-
import { LiveStatusController, LiveStatusEffectRejectedError, LiveStatusEffectUnknownError } from "./live-status.js";
|
|
11
|
+
import { LiveStatusController, LiveStatusEffectRejectedError, LiveStatusEffectUnknownError, LiveStatusEffectSuppressedError } from "./live-status.js";
|
|
11
12
|
import { QueuedPresentations } from "./queued-presentation.js";
|
|
12
13
|
import { CompactProgressController } from "./compact-progress.js";
|
|
13
14
|
import { GoController } from "./go-controller.js";
|
|
14
|
-
import { errorIdentity } from "./terminal-errors.js";
|
|
15
|
+
import { RunTerminalError, closedStopReason, errorNoticeIdentity, errorIdentity } from "./terminal-errors.js";
|
|
15
16
|
import { formatProviderError, safeErrorSummary } from "./provider-errors.js";
|
|
16
17
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
17
18
|
import { basename, extname, join, resolve } from "path";
|
|
@@ -31,7 +32,21 @@ export class SendDeadlineError extends Error {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
const nonemptyReceipt = (value) => typeof value === 'string' && !!value.trim();
|
|
34
|
-
|
|
35
|
+
/** Resolve the PHYSICAL session key for a logical LMA key.
|
|
36
|
+
*
|
|
37
|
+
* A bot may be bound to a specific OpenClaw profile. The profile selects the
|
|
38
|
+
* agent that owns the session, so the physical key carries it as an
|
|
39
|
+
* `agent:<profile>:` prefix. A key that already carries an explicit
|
|
40
|
+
* `agent:<id>:` prefix is left untouched.
|
|
41
|
+
*
|
|
42
|
+
* With the default profile this is byte-identical to the historical
|
|
43
|
+
* `agent:main:<key>`, so existing durable records keep matching. */
|
|
44
|
+
const canonicalPhysicalSessionKey = (key, profile) => {
|
|
45
|
+
if (/^agent:[^:]+:/.test(key))
|
|
46
|
+
return key;
|
|
47
|
+
const agent = String(profile || "main").trim() || "main";
|
|
48
|
+
return `agent:${agent}:${key}`;
|
|
49
|
+
};
|
|
35
50
|
const require = createRequire(import.meta.url);
|
|
36
51
|
const LMA_VERSION = require("../package.json").version;
|
|
37
52
|
const MAX_BOT_STREAK = 10;
|
|
@@ -328,8 +343,6 @@ export class FeishuBot {
|
|
|
328
343
|
deliveryRetryTimers = new Map();
|
|
329
344
|
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
330
345
|
lastRealDeliveryAt = new Map();
|
|
331
|
-
/** Per-chat: whether we've already sent the high-context alert this cycle. */
|
|
332
|
-
contextAlerted = new Map();
|
|
333
346
|
/** Active pre-admission target for the current chat operation. */
|
|
334
347
|
activeDeliveryTargets = new Map();
|
|
335
348
|
/** Stable targets for concurrent runs in one chat. A steer admission can
|
|
@@ -386,10 +399,11 @@ export class FeishuBot {
|
|
|
386
399
|
// Go / Go-Checker supervision (design: lma-go-checker-design-20260917).
|
|
387
400
|
this.goController = new GoController({
|
|
388
401
|
botName: config.name,
|
|
389
|
-
fallbackModel: config.model,
|
|
402
|
+
fallbackModel: config.model || "",
|
|
403
|
+
profile: this.profile,
|
|
390
404
|
store,
|
|
391
405
|
client: openclawClient,
|
|
392
|
-
executorSessionKeyFor: (chatId) => this.
|
|
406
|
+
executorSessionKeyFor: (chatId) => this.physicalSessionKey(chatId),
|
|
393
407
|
notify: (chatId, text) => this.sendOrdered(chatId, () => this.sendMessage(chatId, text)),
|
|
394
408
|
reply: (messageId, text) => this.replyMessage(messageId, text),
|
|
395
409
|
deliverExecutorResult: (chatId, result, goId) => this.deliverGoExecutorResult(chatId, result, goId),
|
|
@@ -404,7 +418,7 @@ export class FeishuBot {
|
|
|
404
418
|
if (typeof exactKey !== "string" || typeof sessionId !== "string" || typeof runId !== "string")
|
|
405
419
|
return;
|
|
406
420
|
for (const job of this.store.listActiveGoJobs(this.config.name)) {
|
|
407
|
-
if (exactKey !== job.executorSessionKey && exactKey !==
|
|
421
|
+
if (exactKey !== job.executorSessionKey && exactKey !== canonicalPhysicalSessionKey(job.executorSessionKey, this.profile))
|
|
408
422
|
continue;
|
|
409
423
|
if (sessionId !== job.executorSessionId)
|
|
410
424
|
continue;
|
|
@@ -444,6 +458,10 @@ export class FeishuBot {
|
|
|
444
458
|
if (!rowId)
|
|
445
459
|
return;
|
|
446
460
|
this.store.clearPendingTrigger(this.config.name, chatId, rowId);
|
|
461
|
+
// Privacy invalidation starts before reaction SDK I/O: unrelated lookup
|
|
462
|
+
// latency must not retain a recalled preview on a frozen/terminal card.
|
|
463
|
+
void this.queuedPresentations.refreshRecalledInput(chatId, rowId, messageId)
|
|
464
|
+
.catch(err => console.warn(`[${this.config.name}] Recalled-input UI redaction unavailable:`, this.errorSummary(err)));
|
|
447
465
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
448
466
|
const remainingAcks = [];
|
|
449
467
|
for (const ack of pendingAcks) {
|
|
@@ -464,9 +482,27 @@ export class FeishuBot {
|
|
|
464
482
|
* Get the session key for a specific chat.
|
|
465
483
|
* Format: lma-<botname>-<chatId>
|
|
466
484
|
*/
|
|
485
|
+
/** OpenClaw profile that owns this bot's sessions.
|
|
486
|
+
*
|
|
487
|
+
* Defaults to "main" so a configuration written before profiles existed
|
|
488
|
+
* keeps its historical behaviour exactly. */
|
|
489
|
+
get profile() {
|
|
490
|
+
return String(this.config.profile || "main").trim() || "main";
|
|
491
|
+
}
|
|
467
492
|
getSessionKey(chatId) {
|
|
468
493
|
return `lma-${this.config.name.toLowerCase()}-${chatId}`;
|
|
469
494
|
}
|
|
495
|
+
/** Physical session key for this bot: the logical key addressed under the
|
|
496
|
+
* bot's OpenClaw profile.
|
|
497
|
+
*
|
|
498
|
+
* The LOGICAL key (getSessionKey) stays profile-free so durable records keep
|
|
499
|
+
* their historical shape and never need a migration. Every key that is
|
|
500
|
+
* actually handed to the Gateway goes through this method, so a bot bound to
|
|
501
|
+
* a non-default profile owns its sessions under that profile (its own
|
|
502
|
+
* workspace, memory and identity) while the stored records stay compatible. */
|
|
503
|
+
physicalSessionKey(chatId) {
|
|
504
|
+
return canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile);
|
|
505
|
+
}
|
|
470
506
|
chatLocale(chatId) {
|
|
471
507
|
return normalizeLocale(chatId ? this.store.getChatLocale(chatId) || this.locale : this.locale);
|
|
472
508
|
}
|
|
@@ -490,7 +526,11 @@ export class FeishuBot {
|
|
|
490
526
|
* Lazy: only creates on first message in that chat.
|
|
491
527
|
*/
|
|
492
528
|
async ensureSession(chatId, setupFence) {
|
|
493
|
-
|
|
529
|
+
// The cache is keyed by the LOGICAL key so it stays profile-free and
|
|
530
|
+
// consistent with the rest of the durable bookkeeping; the Gateway is
|
|
531
|
+
// always addressed with the PHYSICAL key (profile included).
|
|
532
|
+
const logicalKey = this.getSessionKey(chatId);
|
|
533
|
+
const sessionKey = this.physicalSessionKey(chatId);
|
|
494
534
|
await this.assertIdentityEffect();
|
|
495
535
|
const held = this.identityEffects?.getStore();
|
|
496
536
|
const expectedSessionId = held?.item.target.sessionId || setupFence?.sessionId;
|
|
@@ -501,9 +541,12 @@ export class FeishuBot {
|
|
|
501
541
|
if (info?.session?.sessionId !== setupFence.sessionId || info.session.archived)
|
|
502
542
|
throw new SupersededObservation('retained setup physical target changed');
|
|
503
543
|
}
|
|
504
|
-
if (this.initializedSessions.has(
|
|
544
|
+
if (this.initializedSessions.has(logicalKey)) {
|
|
505
545
|
// Already initialized this process lifetime, just ensure model
|
|
506
|
-
|
|
546
|
+
// No configured model means the bot follows its profile's own model.
|
|
547
|
+
const corrected = this.config.model
|
|
548
|
+
? (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model))
|
|
549
|
+
: false;
|
|
507
550
|
setupGuard();
|
|
508
551
|
if (corrected) {
|
|
509
552
|
console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
|
|
@@ -519,7 +562,9 @@ export class FeishuBot {
|
|
|
519
562
|
if (existing?.session) {
|
|
520
563
|
// Session exists — preserve it, only ensure model is correct
|
|
521
564
|
console.log(`[${this.config.name}] Session exists: ${sessionKey} (tokens: ${existing.session.totalTokens || 0})`);
|
|
522
|
-
const corrected =
|
|
565
|
+
const corrected = this.config.model
|
|
566
|
+
? (expectedSessionId ? await this.openclawClient.ensureModel(sessionKey, this.config.model, { sessionId: expectedSessionId, guard: setupGuard }) : await this.openclawClient.ensureModel(sessionKey, this.config.model))
|
|
567
|
+
: false;
|
|
523
568
|
setupGuard();
|
|
524
569
|
if (corrected) {
|
|
525
570
|
console.log(`[${this.config.name}] Model auto-corrected to ${this.config.model}`);
|
|
@@ -529,11 +574,13 @@ export class FeishuBot {
|
|
|
529
574
|
// Session doesn't exist — create new
|
|
530
575
|
await this.openclawClient.createSession({
|
|
531
576
|
key: sessionKey,
|
|
532
|
-
model: this.config.model,
|
|
577
|
+
model: this.config.model || "",
|
|
533
578
|
label: `LMA: ${this.config.name} [${chatId.slice(-8)}]`,
|
|
534
579
|
});
|
|
535
|
-
//
|
|
536
|
-
|
|
580
|
+
// Only patch when a model is configured; otherwise the profile's own
|
|
581
|
+
// model must be left untouched.
|
|
582
|
+
if (this.config.model)
|
|
583
|
+
await this.openclawClient.patchSession({ key: sessionKey, model: this.config.model });
|
|
537
584
|
await this.injectBridgePolicy(sessionKey);
|
|
538
585
|
console.log(`[${this.config.name}] Session created: ${sessionKey} (model: ${this.config.model})`);
|
|
539
586
|
}
|
|
@@ -545,7 +592,7 @@ export class FeishuBot {
|
|
|
545
592
|
throw err;
|
|
546
593
|
}
|
|
547
594
|
setupGuard();
|
|
548
|
-
this.initializedSessions.add(
|
|
595
|
+
this.initializedSessions.add(logicalKey);
|
|
549
596
|
// Subscribe to session events (proactive messages + tool calls)
|
|
550
597
|
this.openclawClient.setVerboseTranscriptDelivery(sessionKey, this.store.getBotVerbose(this.config.name, chatId));
|
|
551
598
|
await this.openclawClient.subscribeSession(sessionKey, async (text, meta) => {
|
|
@@ -561,7 +608,7 @@ export class FeishuBot {
|
|
|
561
608
|
}
|
|
562
609
|
if (meta?.sourceType === "run_error" || provenance) {
|
|
563
610
|
if (provenance) {
|
|
564
|
-
this.store.observeRunError(this.config.name, chatId, provenance);
|
|
611
|
+
this.store.observeRunError(this.config.name, chatId, this.scopeRunError(chatId, provenance));
|
|
565
612
|
await this.reconcileErrorNotices(chatId);
|
|
566
613
|
}
|
|
567
614
|
else
|
|
@@ -599,7 +646,7 @@ export class FeishuBot {
|
|
|
599
646
|
console.error(`[${this.config.name}] Failed to deliver proactive msg:`, err.message);
|
|
600
647
|
}
|
|
601
648
|
}, async (meta) => {
|
|
602
|
-
if (!meta.runId || this.store.isStoppedObservation(
|
|
649
|
+
if (!meta.runId || this.store.isStoppedObservation(canonicalPhysicalSessionKey(sessionKey.replace(/^agent:[^:]+:/, ''), this.profile), meta.runId))
|
|
603
650
|
return;
|
|
604
651
|
if (this.store.hasUnconfirmedRunDelivery(this.config.name, chatId, meta.runId))
|
|
605
652
|
return;
|
|
@@ -655,7 +702,7 @@ export class FeishuBot {
|
|
|
655
702
|
await this.wsClient.start({ eventDispatcher: this.eventDispatcher });
|
|
656
703
|
if (this.identityClosed)
|
|
657
704
|
return;
|
|
658
|
-
console.log(`[${this.config.name}] Bot started (model: ${this.config.model}, open_id: ${this.botOpenId || "unknown"})`);
|
|
705
|
+
console.log(`[${this.config.name}] Bot started (model: ${this.config.model || `profile ${this.profile}`}, open_id: ${this.botOpenId || "unknown"})`);
|
|
659
706
|
// Drain any unsynced messages left from previous run
|
|
660
707
|
this.drainOnStartup();
|
|
661
708
|
void this.drainIdentityIngress().catch(err => console.warn(`[${this.config.name}] Identity ingress recovery failed:`, err.message));
|
|
@@ -688,14 +735,14 @@ export class FeishuBot {
|
|
|
688
735
|
});
|
|
689
736
|
}
|
|
690
737
|
captureIdentityTarget(messageId, chatId) {
|
|
691
|
-
const key = this.
|
|
738
|
+
const key = this.physicalSessionKey(chatId);
|
|
692
739
|
const work = (async () => {
|
|
693
740
|
const info = await this.identitySessionInfo(key);
|
|
694
741
|
if (this.identityClosed)
|
|
695
742
|
return;
|
|
696
743
|
const session = info?.session;
|
|
697
744
|
const target = typeof session?.sessionId === 'string' && session.sessionId.trim() && session.sessionId.length <= 256 && session.archived !== true
|
|
698
|
-
&& (!session.key || typeof session.key === 'string' && canonicalPhysicalSessionKey(session.key) === canonicalPhysicalSessionKey(key))
|
|
745
|
+
&& (!session.key || typeof session.key === 'string' && canonicalPhysicalSessionKey(session.key, this.profile) === canonicalPhysicalSessionKey(key, this.profile))
|
|
699
746
|
? { state: 'bound', sessionKey: key, sessionId: session.sessionId } : { state: 'unbound', sessionKey: key };
|
|
700
747
|
if (target.state === 'unbound')
|
|
701
748
|
console.warn(`[${this.config.name}] Identity input ${messageId} has no proven original physical session; retained without retargeting`);
|
|
@@ -875,7 +922,12 @@ export class FeishuBot {
|
|
|
875
922
|
if (this.identityTargetRepairs.has(item.messageId))
|
|
876
923
|
return;
|
|
877
924
|
item.target = this.store.getIdentityTarget(this.config.name, this.config.appId, item.messageId);
|
|
878
|
-
|
|
925
|
+
// An identity target may hold the physical key (current records) or the
|
|
926
|
+
// logical key (records written before profiles existed). Both denote the
|
|
927
|
+
// SAME session, so both are accepted here; comparing against only one
|
|
928
|
+
// form silently skips identity recovery and the message is never sent.
|
|
929
|
+
if (item.target.state !== 'bound' || !item.target.sessionId ||
|
|
930
|
+
(item.target.sessionKey !== this.physicalSessionKey(item.chatId) && item.target.sessionKey !== this.getSessionKey(item.chatId)))
|
|
879
931
|
continue;
|
|
880
932
|
const info = await this.identitySessionInfo(item.target.sessionKey);
|
|
881
933
|
if (this.identityClosed)
|
|
@@ -915,7 +967,7 @@ export class FeishuBot {
|
|
|
915
967
|
const drainTasks = [];
|
|
916
968
|
for (const chat of chats) {
|
|
917
969
|
// Skip p2p chats that belong to other bots
|
|
918
|
-
if (chat.chatType === "p2p" && chat.
|
|
970
|
+
if (chat.chatType === "p2p" && !this.store.claimP2pOwner(chat.chatId, this.config.name, this.config.appId)) {
|
|
919
971
|
continue;
|
|
920
972
|
}
|
|
921
973
|
// Re-subscribe to existing sessions
|
|
@@ -973,7 +1025,9 @@ export class FeishuBot {
|
|
|
973
1025
|
if ((message.mentions || []).some((mention) => mention?.id && Object.hasOwn(mention.id, 'open_id') &&
|
|
974
1026
|
(typeof mention.id.open_id !== 'string' || !mention.id.open_id.trim())))
|
|
975
1027
|
return;
|
|
976
|
-
|
|
1028
|
+
// Private duplicates must reach the stable app/chat fence so an old
|
|
1029
|
+
// name-scoped tombstone cannot disappear across a later bot rename.
|
|
1030
|
+
if (message.chat_type !== "p2p" && this.store.hasBotProcessed(this.config.name, message.message_id))
|
|
977
1031
|
return;
|
|
978
1032
|
// A duplicate must honor the original journal owner, even if its envelope
|
|
979
1033
|
// differs or identity has resolved since the first receipt.
|
|
@@ -1031,7 +1085,9 @@ export class FeishuBot {
|
|
|
1031
1085
|
// OpenClaw twice.
|
|
1032
1086
|
if (this.identityClosed)
|
|
1033
1087
|
return;
|
|
1034
|
-
if (!identityItem && !
|
|
1088
|
+
if (!identityItem && !(chatType === "p2p"
|
|
1089
|
+
? this.store.tryMarkP2pProcessed(this.config.appId, chatId, this.config.name, messageId)
|
|
1090
|
+
: this.store.tryMarkBotProcessed(this.config.name, messageId)))
|
|
1035
1091
|
return;
|
|
1036
1092
|
const identityCurrent = () => {
|
|
1037
1093
|
if (this.identityClosed)
|
|
@@ -1051,11 +1107,8 @@ export class FeishuBot {
|
|
|
1051
1107
|
if (!identityCurrent())
|
|
1052
1108
|
return;
|
|
1053
1109
|
// --- P2P isolation: only the owning bot processes p2p messages ---
|
|
1054
|
-
if (chatType === "p2p") {
|
|
1055
|
-
|
|
1056
|
-
if (chatInfo?.ownerBot && chatInfo.ownerBot !== this.config.name) {
|
|
1057
|
-
return; // This p2p chat belongs to another bot
|
|
1058
|
-
}
|
|
1110
|
+
if (chatType === "p2p" && !this.store.claimP2pOwner(chatId, this.config.name, this.config.appId)) {
|
|
1111
|
+
return; // A display-name match cannot override a conflicting app pin.
|
|
1059
1112
|
}
|
|
1060
1113
|
let content;
|
|
1061
1114
|
try {
|
|
@@ -1065,6 +1118,14 @@ export class FeishuBot {
|
|
|
1065
1118
|
console.warn(`[${this.config.name}] Failed to parse message content, skipping`);
|
|
1066
1119
|
return;
|
|
1067
1120
|
}
|
|
1121
|
+
// Original visible input for UI only; never use hydrated media paths,
|
|
1122
|
+
// synthetic routing prompts or model/history text as its body.
|
|
1123
|
+
const inputCaption = [content.text, content.caption].find(value => typeof value === 'string') || '';
|
|
1124
|
+
const inputDisplayText = messageType === 'text' ? (typeof content.text === 'string' ? content.text.replace(/@_user_\d+/g, (key) => { const mention = (message.mentions || []).find((item) => item.key === key); return typeof mention?.name === 'string' ? `@${mention.name}` : key; }) : '') :
|
|
1125
|
+
messageType === 'file' ? `[${this.isEn(chatId) ? 'File' : '文件'}: ${(typeof content.file_name === 'string' ? content.file_name : 'file').split(/[\\/]/).at(-1)}] ${inputCaption}` :
|
|
1126
|
+
messageType === 'image' ? `[${this.isEn(chatId) ? 'Image' : '图片'}] ${inputCaption}` : messageType === 'audio' ? `[${this.isEn(chatId) ? 'Audio' : '语音'}] ${inputCaption}` : messageType === 'sticker' ? (this.isEn(chatId) ? '[Sticker]' : '[贴纸]') :
|
|
1127
|
+
messageType === 'post' ? this.visiblePostInput(content, chatId) :
|
|
1128
|
+
[content.title, content.name, content.text, content.url].find(value => typeof value === 'string') || '';
|
|
1068
1129
|
// --- Extract text content based on message type ---
|
|
1069
1130
|
let cleanText = "";
|
|
1070
1131
|
let rawText = "";
|
|
@@ -1448,7 +1509,7 @@ export class FeishuBot {
|
|
|
1448
1509
|
const isOn = this.store.getBotVerbose(this.config.name, chatId);
|
|
1449
1510
|
const nextVerbose = !isOn;
|
|
1450
1511
|
this.store.setBotVerbose(this.config.name, chatId, nextVerbose);
|
|
1451
|
-
this.openclawClient.setVerboseTranscriptDelivery(this.
|
|
1512
|
+
this.openclawClient.setVerboseTranscriptDelivery(this.physicalSessionKey(chatId), nextVerbose);
|
|
1452
1513
|
if (isOn) {
|
|
1453
1514
|
await this.replyMessage(messageId, `🔇 ${this.config.name} Verbose 已关闭\n只影响当前 Bot 在当前会话的 Tool call 和中间文本显示`);
|
|
1454
1515
|
}
|
|
@@ -1622,7 +1683,7 @@ export class FeishuBot {
|
|
|
1622
1683
|
// No health/setup/model/history await precedes this honest local receipt.
|
|
1623
1684
|
// UI custody cannot reserve dispatch or claim Gateway/model acceptance.
|
|
1624
1685
|
const presentationRoot = this.queueOwners.get(chatId)?.runId || this.store.getDispatchCustody(this.config.name, chatId)?.runId;
|
|
1625
|
-
this.presentation(() => this.queuedPresentations.receive(chatId, insertedId, messageId, isNativeOpenClawCommand, presentationRoot));
|
|
1686
|
+
this.presentation(() => this.queuedPresentations.receive(chatId, insertedId, messageId, isNativeOpenClawCommand, presentationRoot, { source: 'visible', text: inputDisplayText }));
|
|
1626
1687
|
// Reserve BEFORE the first health/reaction await. Otherwise an ending
|
|
1627
1688
|
// owner can ordinary-drain this row while its handler is still waiting,
|
|
1628
1689
|
// and the later steer would submit the same message a second time.
|
|
@@ -1716,9 +1777,23 @@ export class FeishuBot {
|
|
|
1716
1777
|
if (!activity.followupAdmissionEligible || activity.identityConflict || activity.activeRunId && !noEffectRunId || activity.archived || status.archived ||
|
|
1717
1778
|
!status.sessionId || status.sessionId !== activity.sessionId || !activity.expectedLeafEntryId)
|
|
1718
1779
|
return;
|
|
1719
|
-
return { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId)), sessionId: status.sessionId, expectedLeafEntryId: activity.expectedLeafEntryId,
|
|
1780
|
+
return { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile), sessionId: status.sessionId, expectedLeafEntryId: activity.expectedLeafEntryId,
|
|
1720
1781
|
...(noEffectRunId ? { nonInjectableRunId: noEffectRunId } : {}) };
|
|
1721
1782
|
}
|
|
1783
|
+
/** How long an unconfirmed input may stay silent before the user is told.
|
|
1784
|
+
*
|
|
1785
|
+
* A fixed 60s window misfired on every slow session: measured reply latency
|
|
1786
|
+
* in a busy group was p50 3.8min / p90 20.2min / max 135min, so 85% of real
|
|
1787
|
+
* replies exceeded the old threshold and every one of them produced a false
|
|
1788
|
+
* "not confirmed" notice. Scale the window with the session's own observed
|
|
1789
|
+
* latency so a healthy slow run is never reported as stuck. */
|
|
1790
|
+
unconfirmedGraceMs(chatId) {
|
|
1791
|
+
const observed = this.store.observedReplyLatencyMs(this.config.name, chatId);
|
|
1792
|
+
const floor = 60_000;
|
|
1793
|
+
if (!observed || !Number.isFinite(observed) || observed <= 0)
|
|
1794
|
+
return floor;
|
|
1795
|
+
return Math.min(Math.max(floor, Math.ceil(observed * 1.5)), 30 * 60_000);
|
|
1796
|
+
}
|
|
1722
1797
|
/** A positive refusal must be durable AND explained to the sender. */
|
|
1723
1798
|
retainChangedFollowup(chatId, item, reason) {
|
|
1724
1799
|
this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, 'target_changed', { ...item, reason });
|
|
@@ -1735,7 +1810,7 @@ export class FeishuBot {
|
|
|
1735
1810
|
for (const item of this.store.listInsertions(this.config.name, chatId)) {
|
|
1736
1811
|
if (item.state !== 'pending' || item.attempted || item.followupAdmissionGuard || item.ordinaryEligible === false ||
|
|
1737
1812
|
item.ownerId !== saved.id || item.batchId !== saved.batchId || item.stopEpoch !== saved.stopEpoch || item.sessionId !== proof.sessionId ||
|
|
1738
|
-
!item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== proof.sessionKey)
|
|
1813
|
+
!item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== proof.sessionKey)
|
|
1739
1814
|
continue;
|
|
1740
1815
|
const source = this.store.getMessageByMessageId(item.messageId);
|
|
1741
1816
|
if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
|
|
@@ -1759,7 +1834,7 @@ export class FeishuBot {
|
|
|
1759
1834
|
!this.queueOwners.get(chatId)?.triggerIds?.has(rowId);
|
|
1760
1835
|
if (!unsubmitted())
|
|
1761
1836
|
return;
|
|
1762
|
-
await this.openclawClient.waitForSessionStop?.(this.
|
|
1837
|
+
await this.openclawClient.waitForSessionStop?.(this.physicalSessionKey(chatId));
|
|
1763
1838
|
if (!unsubmitted())
|
|
1764
1839
|
return;
|
|
1765
1840
|
// Session creation/model setup is not agent admission. Initialize a truly
|
|
@@ -1785,15 +1860,15 @@ export class FeishuBot {
|
|
|
1785
1860
|
}
|
|
1786
1861
|
if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
|
|
1787
1862
|
return;
|
|
1788
|
-
const status = await this.openclawClient.getSessionRuntimeStatus(this.
|
|
1863
|
+
const status = await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId));
|
|
1789
1864
|
if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
|
|
1790
1865
|
return;
|
|
1791
1866
|
const current = this.store.getInsertion(this.config.name, chatId, rowId);
|
|
1792
1867
|
if (current && (!current.activityUnbound || current.attempted))
|
|
1793
1868
|
return;
|
|
1794
|
-
if (current && (current.stopEpoch !== epoch || !current.sessionKey || canonicalPhysicalSessionKey(current.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId))))
|
|
1869
|
+
if (current && (current.stopEpoch !== epoch || !current.sessionKey || canonicalPhysicalSessionKey(current.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile)))
|
|
1795
1870
|
return;
|
|
1796
|
-
const activity = await this.openclawClient.getSessionDispatchStatus(this.
|
|
1871
|
+
const activity = await this.openclawClient.getSessionDispatchStatus(this.physicalSessionKey(chatId));
|
|
1797
1872
|
if (!unsubmitted() || this.queueRuns.has(chatId) || this.store.getDispatchCustody(this.config.name, chatId))
|
|
1798
1873
|
return;
|
|
1799
1874
|
// Another recovery/ingress lookup may have identified the root and begun
|
|
@@ -1809,6 +1884,13 @@ export class FeishuBot {
|
|
|
1809
1884
|
!!current?.sessionId && !!status.sessionId && current.sessionId !== status.sessionId;
|
|
1810
1885
|
const source = this.store.getMessageByMessageId(messageId);
|
|
1811
1886
|
const ordinaryHuman = source?.id === rowId && source.chatId === chatId && source.senderType === 'human' && source.triggerKind === 'normal';
|
|
1887
|
+
if (current?.followupBlocked && !archived && !generationConflict) {
|
|
1888
|
+
this.store.saveInsertion(this.config.name, chatId, rowId, current.insertionId || JSON.stringify([this.config.appId, chatId, messageId]), 'pending', {
|
|
1889
|
+
...current, followupBlocked: false, followupAdmissionGuard: undefined, followupObservedAt: undefined,
|
|
1890
|
+
reason: 'followup block cleared; awaiting admission re-observation',
|
|
1891
|
+
}, Date.now() + 5000);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1812
1894
|
const proof = ordinaryHuman && compatible && !current?.followupBlocked && !archived ? this.observedFollowupGuard(chatId, status, activity) : undefined;
|
|
1813
1895
|
const previous = current?.followupAdmissionGuard;
|
|
1814
1896
|
if (previous && (archived || generationConflict)) {
|
|
@@ -1857,7 +1939,7 @@ export class FeishuBot {
|
|
|
1857
1939
|
...((archived || generationConflict) ? { followupBlocked: true } : {}),
|
|
1858
1940
|
reason: identified ? 'current Gateway run observed; insertion custody only' : blockerReason,
|
|
1859
1941
|
}, Date.now() + (identified ? 0 : 5000));
|
|
1860
|
-
if (!identified && !proof && current?.unboundSince && Date.now() - current.unboundSince >=
|
|
1942
|
+
if (!identified && !proof && current?.unboundSince && Date.now() - current.unboundSince >= this.unconfirmedGraceMs(chatId) &&
|
|
1861
1943
|
(archived || generationConflict || activity.busyConfirmed || current.restrictedObservation))
|
|
1862
1944
|
this.explainUnconfirmedFollowup(chatId, { ...current, rowId, messageId });
|
|
1863
1945
|
}
|
|
@@ -1872,7 +1954,7 @@ export class FeishuBot {
|
|
|
1872
1954
|
const source = this.store.getMessageByMessageId(item.messageId);
|
|
1873
1955
|
if (item.state !== 'pending' || item.attempted || item.ordinaryEligible === false || !item.sessionId ||
|
|
1874
1956
|
!source || source.id !== item.rowId || source.chatId !== chatId || source.senderType !== 'human' || source.triggerKind !== 'normal' ||
|
|
1875
|
-
!item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
|
|
1957
|
+
!item.sessionKey || canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile))
|
|
1876
1958
|
return false;
|
|
1877
1959
|
const admissionEpoch = this.queueAdmissionEpoch.get(chatId) || 0;
|
|
1878
1960
|
const snapshot = this.store.getInsertion(this.config.name, chatId, item.rowId);
|
|
@@ -1922,14 +2004,14 @@ export class FeishuBot {
|
|
|
1922
2004
|
return (this.queueAdmissionEpoch.get(chatId) || 0) === admissionEpoch && this.stopGeneration(chatId) === epoch && saved.stopEpoch === epoch && JSON.stringify(now) === JSON.stringify(saved) &&
|
|
1923
2005
|
this.queueOwners.get(chatId) === local && (!local || local.batch === batch && local.runId === runId && (local.admitted === true || local.yielded === true) && local.collecting === true);
|
|
1924
2006
|
};
|
|
1925
|
-
const activity = await this.openclawClient.getSessionDispatchStatus?.(this.
|
|
2007
|
+
const activity = await this.openclawClient.getSessionDispatchStatus?.(this.physicalSessionKey(chatId)).catch(() => null);
|
|
1926
2008
|
if (!unchanged() || !activity || activity.archived || activity.sessionId !== saved.sessionId)
|
|
1927
2009
|
return false;
|
|
1928
2010
|
if (activity.activeRunId === saved.runId)
|
|
1929
2011
|
return false; // real foreground keeps SDK priority
|
|
1930
2012
|
if (!activity.idleConfirmed && !activity.followupAdmissionEligible)
|
|
1931
2013
|
return false;
|
|
1932
|
-
const status = await this.openclawClient.getSessionRuntimeStatus(this.
|
|
2014
|
+
const status = await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId)).catch(() => null);
|
|
1933
2015
|
if (!unchanged() || !status || status.archived || status.sessionId !== saved.sessionId)
|
|
1934
2016
|
return false;
|
|
1935
2017
|
if (activity.activeRunId && activity.activeRunId !== saved.runId) {
|
|
@@ -1940,7 +2022,7 @@ export class FeishuBot {
|
|
|
1940
2022
|
for (const item of this.store.listInsertions(this.config.name, chatId)) {
|
|
1941
2023
|
if (item.state !== 'pending' || item.attempted || item.noEffectRunId || item.ordinaryEligible === false || item.ownerId !== saved.id || item.batchId !== saved.batchId ||
|
|
1942
2024
|
item.stopEpoch !== epoch || item.sessionId !== saved.sessionId || !item.sessionKey ||
|
|
1943
|
-
canonicalPhysicalSessionKey(item.sessionKey) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId)))
|
|
2025
|
+
canonicalPhysicalSessionKey(item.sessionKey, this.profile) !== canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile))
|
|
1944
2026
|
continue;
|
|
1945
2027
|
const source = this.store.getMessageByMessageId(item.messageId);
|
|
1946
2028
|
if (source?.senderType !== 'human' || source.triggerKind !== 'normal')
|
|
@@ -1967,7 +2049,7 @@ export class FeishuBot {
|
|
|
1967
2049
|
let followup;
|
|
1968
2050
|
if (!idle || saved.followupAdmissionGuard) {
|
|
1969
2051
|
const proof = this.observedFollowupGuard(chatId, status, activity);
|
|
1970
|
-
if (!proof || !saved.sessionKey || canonicalPhysicalSessionKey(saved.sessionKey) !== proof.sessionKey)
|
|
2052
|
+
if (!proof || !saved.sessionKey || canonicalPhysicalSessionKey(saved.sessionKey, this.profile) !== proof.sessionKey)
|
|
1971
2053
|
return false;
|
|
1972
2054
|
const previous = saved.followupAdmissionGuard;
|
|
1973
2055
|
if (previous && (previous.sessionKey !== proof.sessionKey || previous.sessionId !== proof.sessionId))
|
|
@@ -2000,7 +2082,12 @@ export class FeishuBot {
|
|
|
2000
2082
|
}
|
|
2001
2083
|
}
|
|
2002
2084
|
stopGeneration(chatId) {
|
|
2003
|
-
|
|
2085
|
+
// A peer bot's group-scoped /stop bumps this bot's durable epoch. The
|
|
2086
|
+
// in-memory value can then be stale and would mask the newer durable one,
|
|
2087
|
+
// so take the greater of the two instead of trusting the cache blindly.
|
|
2088
|
+
const cached = this.stopEpoch.get(chatId);
|
|
2089
|
+
const stored = this.store.getStopGeneration(this.config.name, chatId);
|
|
2090
|
+
return cached === undefined ? stored : Math.max(cached, stored);
|
|
2004
2091
|
}
|
|
2005
2092
|
reactionChecks = new Set();
|
|
2006
2093
|
async receiptReaction(chatId, rowId, messageId, desired, runId = '') {
|
|
@@ -2235,7 +2322,7 @@ export class FeishuBot {
|
|
|
2235
2322
|
}
|
|
2236
2323
|
else {
|
|
2237
2324
|
state = 'pending';
|
|
2238
|
-
if (Date.now() - item.noEffectSince >=
|
|
2325
|
+
if (Date.now() - item.noEffectSince >= this.unconfirmedGraceMs(chatId))
|
|
2239
2326
|
this.explainUnconfirmedFollowup(chatId, item);
|
|
2240
2327
|
}
|
|
2241
2328
|
}
|
|
@@ -2253,7 +2340,7 @@ export class FeishuBot {
|
|
|
2253
2340
|
ordinaryEligible: receipt.ordinaryEligible, attempted: valid ? (receipt.attempted ?? true) : true, sessionId: item.sessionId, reason: receipt.reason, unknownChecks,
|
|
2254
2341
|
unknownSince: state === 'unknown' ? (item.state === 'unknown' && Number.isFinite(item.unknownSince) ? item.unknownSince : Date.now()) : undefined };
|
|
2255
2342
|
this.store.saveInsertion(this.config.name, chatId, item.rowId, item.insertionId, state, record, Date.now() + (state === 'unknown' ? 15_000 : 5000));
|
|
2256
|
-
if (state === 'pending' && !record.attempted && Date.now() - (record.pendingSince || Date.now()) >=
|
|
2343
|
+
if (state === 'pending' && !record.attempted && Date.now() - (record.pendingSince || Date.now()) >= this.unconfirmedGraceMs(chatId))
|
|
2257
2344
|
this.explainUnconfirmedFollowup(chatId, record);
|
|
2258
2345
|
if (state === 'ordinary') {
|
|
2259
2346
|
this.store.deletePendingInsertionNotices(this.config.name, chatId, item.insertionId);
|
|
@@ -2309,7 +2396,7 @@ export class FeishuBot {
|
|
|
2309
2396
|
return process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS !== "0";
|
|
2310
2397
|
}
|
|
2311
2398
|
async getUnhealthySessionStatus(chatId) {
|
|
2312
|
-
const sessionKey = this.
|
|
2399
|
+
const sessionKey = this.physicalSessionKey(chatId);
|
|
2313
2400
|
try {
|
|
2314
2401
|
const resp = await this.openclawClient.getSessionInfo(sessionKey);
|
|
2315
2402
|
const status = String(resp?.session?.status || "").toLowerCase();
|
|
@@ -2491,11 +2578,20 @@ export class FeishuBot {
|
|
|
2491
2578
|
*/
|
|
2492
2579
|
forceExpireQueueOwner(chatId, reason, preserveDispatch = false) {
|
|
2493
2580
|
const owner = this.queueRuns.get(chatId);
|
|
2494
|
-
if (!owner)
|
|
2581
|
+
if (!owner) {
|
|
2582
|
+
// No local owner left, but a persisted dispatch_custody row may still
|
|
2583
|
+
// exist for this chat (it is written before the owner is retired). The
|
|
2584
|
+
// old early return stranded that row forever: it never became visible
|
|
2585
|
+
// again, yet it also was never deleted. Clear it explicitly.
|
|
2586
|
+
if (!preserveDispatch && this.store.clearDispatchCustodyForChat(this.config.name, chatId))
|
|
2587
|
+
console.warn(`[${this.config.name}] cleared orphaned dispatch custody for ${chatId.slice(-8)}: ${reason}`);
|
|
2495
2588
|
return false;
|
|
2589
|
+
}
|
|
2496
2590
|
const context = this.queueOwners.get(chatId);
|
|
2497
2591
|
if (context?.batchId && !preserveDispatch)
|
|
2498
2592
|
this.store.clearDispatchCustody(this.config.name, chatId, context.id, context.batchId);
|
|
2593
|
+
else if (!preserveDispatch && !context)
|
|
2594
|
+
this.store.clearDispatchCustodyForChat(this.config.name, chatId);
|
|
2499
2595
|
if (context)
|
|
2500
2596
|
context.revoked = true;
|
|
2501
2597
|
this.busyChats.set(chatId, 0);
|
|
@@ -2717,7 +2813,11 @@ export class FeishuBot {
|
|
|
2717
2813
|
const expectedIdentitySessionId = identityTargets[0]?.target.sessionId;
|
|
2718
2814
|
try {
|
|
2719
2815
|
if (identityTargets.some(({ target }) => target.state !== 'bound' || !target.sessionId || target.sessionId !== expectedIdentitySessionId ||
|
|
2720
|
-
target
|
|
2816
|
+
// An identity target may hold the physical key (current records) or the
|
|
2817
|
+
// logical key (records written before profiles existed). Both denote
|
|
2818
|
+
// the SAME session, so both are accepted; comparing against only one
|
|
2819
|
+
// form would silently drop the message.
|
|
2820
|
+
(target.sessionKey !== this.physicalSessionKey(chatId) && target.sessionKey !== this.getSessionKey(chatId)) || (target.expiresAt ?? 0) <= Date.now()))
|
|
2721
2821
|
throw new SupersededObservation('retained original target unavailable');
|
|
2722
2822
|
sessionKey = await (expectedIdentitySessionId ? this.ensureSession(chatId, { sessionId: expectedIdentitySessionId, guard }) : this.ensureSession(chatId));
|
|
2723
2823
|
guard();
|
|
@@ -2739,7 +2839,7 @@ export class FeishuBot {
|
|
|
2739
2839
|
owner.continuePending = mergedTriggers.some(message => !identityTargets.some(entry => entry.message === message));
|
|
2740
2840
|
break;
|
|
2741
2841
|
}
|
|
2742
|
-
await liveStatus?.pauseForUnconfirmedResult(this.isEn(chatId) ? "Session setup failed before admission" : "会话准备失败,尚未提交 Gateway", this.isEn(chatId) ? `${this.config.name} setup unavailable` : `${this.config.name}
|
|
2842
|
+
await liveStatus?.pauseForUnconfirmedResult(this.isEn(chatId) ? "Session setup failed before admission" : "会话准备失败,尚未提交 Gateway", this.isEn(chatId) ? `${this.config.name} setup unavailable` : `${this.config.name} 会话准备未完成`, "blocked").catch(() => { });
|
|
2743
2843
|
if (batchProof) {
|
|
2744
2844
|
for (const id of mergedTriggerIds) {
|
|
2745
2845
|
const item = this.store.getInsertion(this.config.name, chatId, id);
|
|
@@ -2879,7 +2979,10 @@ export class FeishuBot {
|
|
|
2879
2979
|
if (target?.triggerId === triggerId) {
|
|
2880
2980
|
target.runId = runId;
|
|
2881
2981
|
this.bindRunDeliveryTarget(chatId, runId, target);
|
|
2882
|
-
|
|
2982
|
+
// Result custody must outlive the real run. Observed GPT latency in a heavy
|
|
2983
|
+
// group reached ~59m40s, so a 30-minute release dropped a computed answer
|
|
2984
|
+
// that had no receiver left. Two hours covers the observed tail.
|
|
2985
|
+
this.releaseRunDeliveryTargetAfter(chatId, runId, 2 * 60 * 60_000);
|
|
2883
2986
|
}
|
|
2884
2987
|
// Refine state for logs/idempotent sync rows when OpenClaw returns
|
|
2885
2988
|
// a runId. markSubmittedBatch is idempotent due to local flags and
|
|
@@ -2947,7 +3050,7 @@ export class FeishuBot {
|
|
|
2947
3050
|
guard,
|
|
2948
3051
|
onSendAttempt: submissionId => {
|
|
2949
3052
|
guard();
|
|
2950
|
-
this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey:
|
|
3053
|
+
this.store.bindDeliveryRun(this.config.name, chatId, submissionId, { triggerId, messageId: lastHuman.messageId || '', runId: submissionId, stopGeneration: this.stopGeneration(chatId), sessionKey: canonicalPhysicalSessionKey(sessionKey.replace(/^agent:[^:]+:/, ''), this.profile) });
|
|
2951
3054
|
},
|
|
2952
3055
|
onSubmitted: async (runId) => { guard(); const previousRoot = owner.runId; owner.runId = runId; foregroundRunId = runId; void this.queuedPresentations.advance(liveStatus, previousRoot, runId, owner.id, owner.batchId).catch(() => { }); this.bindRunDeliveryTarget(chatId, runId, { triggerId, messageId: lastHuman.messageId || "", liveStatus }); },
|
|
2953
3056
|
});
|
|
@@ -3029,14 +3132,13 @@ export class FeishuBot {
|
|
|
3029
3132
|
const outcome = await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, this.config.model, foregroundRunId ? { runId: foregroundRunId, sessionKey, ...(foregroundSessionId ? { sessionId: foregroundSessionId } : {}), final: true } : undefined, undefined, mergedTriggerIds);
|
|
3030
3133
|
visibleOutcomeConfirmed = outcome.state === 'delivered';
|
|
3031
3134
|
if (!visibleOutcomeConfirmed) {
|
|
3032
|
-
await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;保留原投递保管,不重复发送").catch(() => { });
|
|
3135
|
+
await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;保留原投递保管,不重复发送", undefined, "delivery_pending").catch(() => { });
|
|
3033
3136
|
}
|
|
3034
3137
|
else {
|
|
3035
3138
|
await liveStatus?.complete();
|
|
3036
3139
|
// Mark every merged trigger only after receipt confirmation.
|
|
3037
3140
|
for (const id of mergedTriggerIds)
|
|
3038
3141
|
this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
|
|
3039
|
-
void this.maybeAlertHighContext(chatId);
|
|
3040
3142
|
}
|
|
3041
3143
|
// Go supervision: record the delivered final reply as evidence and
|
|
3042
3144
|
// arm a debounced checker round (busy gates apply inside).
|
|
@@ -3101,6 +3203,10 @@ export class FeishuBot {
|
|
|
3101
3203
|
this.goController?.notifyRunOutcome(chatId, "no_reply", `trigger:${triggerId}`, "(本轮无用户可见回复 NO_REPLY)");
|
|
3102
3204
|
}
|
|
3103
3205
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
3206
|
+
// Context usage is re-checked after EVERY completed message (delivered
|
|
3207
|
+
// reply, NO_REPLY turn or runtime failure) and re-notifies each time while
|
|
3208
|
+
// usage stays above the threshold.
|
|
3209
|
+
await this.maybeAlertHighContext(chatId);
|
|
3104
3210
|
guard();
|
|
3105
3211
|
// Replace ack reactions with DONE only for trigger messages actually
|
|
3106
3212
|
// processed in this run. Queued messages that arrived mid-run keep their
|
|
@@ -3151,17 +3257,17 @@ export class FeishuBot {
|
|
|
3151
3257
|
this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'target_changed', { ...row, attempted: false, reason: err.message });
|
|
3152
3258
|
}
|
|
3153
3259
|
await this.enqueueAndDispatchDelivery(chatId, 'admission_status', `admission:${err.submissionId}:refused`, err.message, [], lastHuman.messageId, `admission:${err.submissionId}:refused`).catch(() => { });
|
|
3154
|
-
await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
|
|
3260
|
+
await liveStatus?.pauseForUnconfirmedResult(err.message, undefined, "blocked").catch(() => { });
|
|
3155
3261
|
owner.continuePending = true; // independently proved newer cohorts may still be sent
|
|
3156
3262
|
break;
|
|
3157
3263
|
}
|
|
3158
3264
|
if (err instanceof AdmissionUnconfirmed) {
|
|
3159
3265
|
await this.enqueueAndDispatchDelivery(chatId, 'admission_status', `admission:${err.submissionId}:unknown`, err.message, [], lastHuman.messageId, `admission:${err.submissionId}:unknown`).catch(() => { });
|
|
3160
|
-
await liveStatus?.pauseForUnconfirmedResult(err.message).catch(() => { });
|
|
3266
|
+
await liveStatus?.pauseForUnconfirmedResult(err.message, undefined, "submitting").catch(() => { });
|
|
3161
3267
|
break;
|
|
3162
3268
|
}
|
|
3163
3269
|
let pause = err instanceof SessionWaitPaused ? err : undefined;
|
|
3164
|
-
if (!pause && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
3270
|
+
if (!pause && !(err instanceof RunTerminalError) && isWaitTimeout(err instanceof Error ? err.message : String(err))) {
|
|
3165
3271
|
const state = await this.readSessionStatus(chatId);
|
|
3166
3272
|
if (state.running)
|
|
3167
3273
|
pause = new SessionWaitPaused(state, true);
|
|
@@ -3170,7 +3276,7 @@ export class FeishuBot {
|
|
|
3170
3276
|
const target = this.activeDeliveryTargets.get(chatId);
|
|
3171
3277
|
if (target?.triggerId === triggerId && target.runId)
|
|
3172
3278
|
this.openclawClient.releasePausedWait?.(target.runId);
|
|
3173
|
-
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}
|
|
3279
|
+
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`, "paused").catch(() => { });
|
|
3174
3280
|
await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, pause).catch(() => { });
|
|
3175
3281
|
// A wait notice is not the final answer and must never claim its key.
|
|
3176
3282
|
// Do not mark DONE, retry this input, or abort the running session.
|
|
@@ -3226,7 +3332,7 @@ export class FeishuBot {
|
|
|
3226
3332
|
if (row && !row.admissionSubmissionId && !row.admittedRunId)
|
|
3227
3333
|
this.store.saveInsertion(this.config.name, chatId, id, row.insertionId, 'target_changed', { ...row, attempted: false, reason: 'guarded pre-admission failure; input retained without sync or new-ID retry' });
|
|
3228
3334
|
}
|
|
3229
|
-
await liveStatus?.pauseForUnconfirmedResult('Guarded admission unavailable; input custody retained without replay').catch(() => { });
|
|
3335
|
+
await liveStatus?.pauseForUnconfirmedResult('Guarded admission unavailable; input custody retained without replay', undefined, 'blocked').catch(() => { });
|
|
3230
3336
|
break;
|
|
3231
3337
|
}
|
|
3232
3338
|
console.error(`[${this.config.name}] processQueue error:`, safeErrorSummary(err));
|
|
@@ -3236,13 +3342,30 @@ export class FeishuBot {
|
|
|
3236
3342
|
if (lastHuman.messageId) {
|
|
3237
3343
|
// Error message goes through the normal delivery path; the live status
|
|
3238
3344
|
// is a separate message that we mark interrupted afterwards.
|
|
3239
|
-
|
|
3240
|
-
.
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
}
|
|
3245
|
-
|
|
3345
|
+
if (err instanceof RunTerminalError) {
|
|
3346
|
+
const p = this.scopeRunError(chatId, err.provenance);
|
|
3347
|
+
this.store.observeRunError(this.config.name, chatId, p);
|
|
3348
|
+
await this.reconcileErrorNotices(chatId);
|
|
3349
|
+
errorOutcomeConfirmed = this.store.findRunErrorOwner(this.config.name, chatId, p)?.status === 'delivered';
|
|
3350
|
+
}
|
|
3351
|
+
else if (this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
|
|
3352
|
+
// This trigger already received its visible reply. A later terminal
|
|
3353
|
+
// error for the same run must NOT contradict a delivered answer:
|
|
3354
|
+
// the user would see "did not complete" under their own answer.
|
|
3355
|
+
errorOutcomeConfirmed = true;
|
|
3356
|
+
console.warn(`[${this.config.name}] provider_error suppressed for ${chatId.slice(-8)} trigger=${triggerId}: reply already delivered`);
|
|
3357
|
+
}
|
|
3358
|
+
else
|
|
3359
|
+
await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:provider-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:provider-error`, undefined, foregroundRunId ? (() => {
|
|
3360
|
+
const p = this.scopeRunError(chatId, { sessionKey: canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile), sessionId: owner.sessionId, runId: foregroundRunId, source: 'chat', state: 'error', detail: err instanceof Error ? err.message : undefined });
|
|
3361
|
+
return { sessionKey: p.sessionKey, sessionId: p.sessionId, runId: p.runId, error: p };
|
|
3362
|
+
})() : undefined)
|
|
3363
|
+
.then(outcome => {
|
|
3364
|
+
errorOutcomeConfirmed = outcome.state === 'delivered';
|
|
3365
|
+
if (errorOutcomeConfirmed && current() && triggerId)
|
|
3366
|
+
this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
|
|
3367
|
+
})
|
|
3368
|
+
.catch(() => { });
|
|
3246
3369
|
await liveStatus?.fail().catch(() => { });
|
|
3247
3370
|
}
|
|
3248
3371
|
else {
|
|
@@ -3485,13 +3608,14 @@ export class FeishuBot {
|
|
|
3485
3608
|
thinkingLevel: meta.thinkingLevel,
|
|
3486
3609
|
toolCalls: meta.toolCalls,
|
|
3487
3610
|
noReply: Boolean(meta.noReply),
|
|
3611
|
+
inputStatus: meta.inputStatus ? { ...meta.inputStatus, items: meta.inputStatus.items.map(item => item.kind === 'delivery_pending' ? { ...item, kind: 'completed' } : item) } : { items: [{ kind: meta.noReply ? "no_reply" : "completed", at: 0 }] },
|
|
3488
3612
|
state: "done",
|
|
3489
3613
|
};
|
|
3490
3614
|
await this.patchLiveStatusCard(messageId, view, chatId);
|
|
3491
3615
|
}
|
|
3492
3616
|
async readSessionStatus(chatId) {
|
|
3493
3617
|
try {
|
|
3494
|
-
return await this.openclawClient.getSessionRuntimeStatus(this.
|
|
3618
|
+
return await this.openclawClient.getSessionRuntimeStatus(this.physicalSessionKey(chatId));
|
|
3495
3619
|
}
|
|
3496
3620
|
catch {
|
|
3497
3621
|
return normalizeSessionRuntimeStatus(undefined);
|
|
@@ -3504,7 +3628,7 @@ export class FeishuBot {
|
|
|
3504
3628
|
formatUserVisibleError(err, chatId) {
|
|
3505
3629
|
if (err instanceof SessionWaitPaused)
|
|
3506
3630
|
return err.message;
|
|
3507
|
-
return formatProviderError(err, this.config.name, this.config.model, this.chatLocale(chatId));
|
|
3631
|
+
return formatProviderError(err, this.config.name, this.config.model || "", this.chatLocale(chatId));
|
|
3508
3632
|
}
|
|
3509
3633
|
isBridgeControlReply(text) {
|
|
3510
3634
|
const t = text.trim();
|
|
@@ -3589,36 +3713,34 @@ export class FeishuBot {
|
|
|
3589
3713
|
}
|
|
3590
3714
|
}
|
|
3591
3715
|
/**
|
|
3592
|
-
* After a
|
|
3593
|
-
* threshold (default 80%), so they can compact before
|
|
3594
|
-
* timeout/lock/overflow failure modes.
|
|
3595
|
-
*
|
|
3596
|
-
*
|
|
3716
|
+
* After a message completes, alert the user when context usage is at or above
|
|
3717
|
+
* the threshold (default 80%), so they can compact before the session bloats
|
|
3718
|
+
* into the timeout/lock/overflow failure modes.
|
|
3719
|
+
*
|
|
3720
|
+
* The check runs after EVERY completed message and re-notifies on every message
|
|
3721
|
+
* while usage stays above the threshold. There is deliberately no per-chat
|
|
3722
|
+
* "already warned" latch: with one, a single failed send (or a stale latch) would
|
|
3723
|
+
* silently suppress every later warning, and the user would stop being told to
|
|
3724
|
+
* compact while their context keeps growing.
|
|
3597
3725
|
*/
|
|
3598
3726
|
async maybeAlertHighContext(chatId) {
|
|
3599
3727
|
const threshold = contextAlertPct();
|
|
3600
3728
|
if (threshold <= 0)
|
|
3601
3729
|
return;
|
|
3602
3730
|
try {
|
|
3603
|
-
const sessionKey = this.
|
|
3731
|
+
const sessionKey = this.physicalSessionKey(chatId);
|
|
3604
3732
|
const pct = await this.getContextUsagePercent(sessionKey);
|
|
3605
|
-
if (pct <= 0)
|
|
3733
|
+
if (pct <= 0 || pct < threshold)
|
|
3606
3734
|
return;
|
|
3607
|
-
const
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
await this.sendMessage(chatId, text).catch(() => { });
|
|
3617
|
-
}
|
|
3618
|
-
else if (pct < threshold - 5) {
|
|
3619
|
-
// Re-arm once usage clearly dropped (hysteresis to avoid flapping).
|
|
3620
|
-
this.contextAlerted.set(chatId, false);
|
|
3621
|
-
}
|
|
3735
|
+
const en = this.isEn(chatId);
|
|
3736
|
+
const text = en
|
|
3737
|
+
? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
|
|
3738
|
+
: `\u26a0\ufe0f \u4e0a\u4e0b\u6587\u5df2\u8fbe ${pct}%\uff08\u2265${threshold}%\uff09\uff0c\u5efa\u8bae\u5c3d\u5feb /compact \u538b\u7f29\uff0c\u907f\u514d\u8d85\u65f6/\u7206\u4e0a\u4e0b\u6587\u3002`;
|
|
3739
|
+
// Best-effort: a failed alert must not block delivery, but it is logged so
|
|
3740
|
+
// a persistently undelivered warning stays visible instead of silent.
|
|
3741
|
+
await this.sendMessage(chatId, text).catch((err) => {
|
|
3742
|
+
console.warn(`[${this.config.name}] high-context alert send failed for ${chatId.slice(-8)}: ${this.errorSummary(err)}`);
|
|
3743
|
+
});
|
|
3622
3744
|
}
|
|
3623
3745
|
catch {
|
|
3624
3746
|
// best-effort; never block delivery
|
|
@@ -3779,7 +3901,12 @@ export class FeishuBot {
|
|
|
3779
3901
|
const previous = this.deliveryTargetsByRun.get(key);
|
|
3780
3902
|
if (previous?.timer)
|
|
3781
3903
|
clearTimeout(previous.timer);
|
|
3782
|
-
|
|
3904
|
+
const persisted = this.store.getDeliveryRunTarget(this.config.name, chatId, runId);
|
|
3905
|
+
const owner = this.queueOwners.get(chatId);
|
|
3906
|
+
this.store.bindDeliveryRun(this.config.name, chatId, runId, { ...persisted, triggerId: target.triggerId, messageId: target.messageId, runId,
|
|
3907
|
+
sessionId: persisted?.sessionId || (owner?.runId === runId ? owner.sessionId : undefined),
|
|
3908
|
+
botIdentity: persisted?.botIdentity || this.config.appId, stopGeneration: persisted?.stopGeneration ?? this.stopGeneration(chatId),
|
|
3909
|
+
sessionKey: persisted?.sessionKey || canonicalPhysicalSessionKey(this.getSessionKey(chatId), this.profile) });
|
|
3783
3910
|
this.deliveryTargetsByRun.set(key, {
|
|
3784
3911
|
triggerId: target.triggerId,
|
|
3785
3912
|
messageId: target.messageId,
|
|
@@ -3847,7 +3974,7 @@ export class FeishuBot {
|
|
|
3847
3974
|
this.delayedFailureTimers.set(chatId, timer);
|
|
3848
3975
|
}
|
|
3849
3976
|
deliverySessionKey(chatId) {
|
|
3850
|
-
return this.
|
|
3977
|
+
return this.physicalSessionKey(chatId);
|
|
3851
3978
|
}
|
|
3852
3979
|
stableHash(text) {
|
|
3853
3980
|
let hash = 2166136261;
|
|
@@ -3866,7 +3993,7 @@ export class FeishuBot {
|
|
|
3866
3993
|
}
|
|
3867
3994
|
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel, provenance, errorNoticeId, completionTriggerIds) {
|
|
3868
3995
|
if (provenance?.sessionKey && !/^agent:/.test(provenance.sessionKey))
|
|
3869
|
-
provenance = { ...provenance, sessionKey:
|
|
3996
|
+
provenance = { ...provenance, sessionKey: canonicalPhysicalSessionKey(provenance.sessionKey, this.profile) };
|
|
3870
3997
|
if (provenance?.sessionKey && provenance.runId && this.store.isStoppedObservation(provenance.sessionKey, provenance.runId))
|
|
3871
3998
|
return { state: "suppressed", outboxIds: [] };
|
|
3872
3999
|
if (text.trim().toUpperCase() === 'NO_REPLY')
|
|
@@ -3878,9 +4005,9 @@ export class FeishuBot {
|
|
|
3878
4005
|
const contentHash = this.stableHash(normalizedPayload);
|
|
3879
4006
|
const finalDeliveryKey = deliveryKey || sourceId;
|
|
3880
4007
|
const deliveryMetaJson = JSON.stringify({
|
|
3881
|
-
botIdentity: this.config.appId,
|
|
4008
|
+
botIdentity: provenance?.error?.botIdentity || this.config.appId,
|
|
3882
4009
|
...(completionTriggerIds?.length ? { completionTriggerIds } : {}),
|
|
3883
|
-
deliveryStopEpoch: this.stopGeneration(chatId),
|
|
4010
|
+
deliveryStopEpoch: provenance?.error?.stopEpoch ?? this.stopGeneration(chatId),
|
|
3884
4011
|
...(finalReplyModel?.trim() ? { model: finalReplyModel.trim() } : {}),
|
|
3885
4012
|
...(provenance ? { provenance } : {}), ...(errorNoticeId ? { errorNoticeId } : {}),
|
|
3886
4013
|
});
|
|
@@ -4035,13 +4162,52 @@ export class FeishuBot {
|
|
|
4035
4162
|
}
|
|
4036
4163
|
return this.store.getDeliveryDispatchOutcome(this.config.name, chatId, finalDeliveryKey);
|
|
4037
4164
|
}
|
|
4165
|
+
scopeRunError(chatId, p) {
|
|
4166
|
+
const key = canonicalPhysicalSessionKey(p.sessionKey, this.profile);
|
|
4167
|
+
const target = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
|
|
4168
|
+
const observation = this.store.getRunObservation(key, p.runId);
|
|
4169
|
+
// Pre-upgrade targets may lack app/physical fields. A known exact legacy
|
|
4170
|
+
// outbox owner is original route evidence, never permission for the current
|
|
4171
|
+
// replacement app to take over its notice.
|
|
4172
|
+
const legacy = !target?.botIdentity ? this.store.findRunErrorOwner(this.config.name, chatId, { ...p, sessionKey: key }) : undefined;
|
|
4173
|
+
let meta;
|
|
4174
|
+
try {
|
|
4175
|
+
meta = JSON.parse(legacy?.deliveryMetaJson || '{}');
|
|
4176
|
+
}
|
|
4177
|
+
catch {
|
|
4178
|
+
meta = {};
|
|
4179
|
+
}
|
|
4180
|
+
return { ...p, sessionKey: key, sessionId: p.sessionId || target?.sessionId || observation?.sessionId || meta.provenance?.sessionId,
|
|
4181
|
+
botIdentity: p.botIdentity || target?.botIdentity || meta.botIdentity || this.config.appId,
|
|
4182
|
+
stopEpoch: p.stopEpoch ?? target?.stopGeneration ?? meta.deliveryStopEpoch ?? this.stopGeneration(chatId) };
|
|
4183
|
+
}
|
|
4184
|
+
/** Shared original-error authorization for journaled terminals AND ordinary
|
|
4185
|
+
* admitted provider errors. Current outbox/config metadata is not authority
|
|
4186
|
+
* to replace the retained admission route or physical target. */
|
|
4187
|
+
errorRouteRevocation(chatId, p) {
|
|
4188
|
+
const original = this.store.getDeliveryRunTarget(this.config.name, chatId, p.runId);
|
|
4189
|
+
if (original?.messageId && this.store.isMessageRecalled(original.messageId) ||
|
|
4190
|
+
original?.sessionId && p.sessionId && original.sessionId !== p.sessionId ||
|
|
4191
|
+
original?.sessionKey && canonicalPhysicalSessionKey(original.sessionKey, this.profile) !== canonicalPhysicalSessionKey(p.sessionKey, this.profile))
|
|
4192
|
+
return 'original_error_target_revoked';
|
|
4193
|
+
const epoch = this.stopGeneration(chatId);
|
|
4194
|
+
if (p.botIdentity && p.botIdentity !== this.config.appId || original?.botIdentity && original.botIdentity !== this.config.appId ||
|
|
4195
|
+
p.stopEpoch !== undefined && p.stopEpoch !== epoch || original?.stopGeneration !== undefined && original.stopGeneration !== epoch)
|
|
4196
|
+
return 'original_error_route_revoked';
|
|
4197
|
+
}
|
|
4038
4198
|
async inspectErrorNotice(record) {
|
|
4199
|
+
const revoked = this.errorRouteRevocation(record.chatId, record.provenance);
|
|
4200
|
+
if (revoked)
|
|
4201
|
+
return { state: 'stopped', reason: revoked };
|
|
4039
4202
|
if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, true)) {
|
|
4040
4203
|
return { state: "suppressed", reason: "exact_run_final_platform_delivery_confirmed" };
|
|
4041
4204
|
}
|
|
4042
4205
|
if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
|
|
4043
4206
|
return { state: "pending", reason: "exact_run_final_delivery_pending" };
|
|
4044
4207
|
}
|
|
4208
|
+
const existing = this.store.findRunErrorOwner(this.config.name, record.chatId, record.provenance);
|
|
4209
|
+
if (!record.provenance.runId.startsWith('announce:') && existing && existing.deliveryKey !== `run-error:${errorNoticeIdentity(record.provenance)}`)
|
|
4210
|
+
return { state: 'suppressed', reason: 'existing_exact_run_error_owner' };
|
|
4045
4211
|
let verdict;
|
|
4046
4212
|
try {
|
|
4047
4213
|
verdict = await this.openclawClient.inspectRunError(record.provenance);
|
|
@@ -4051,8 +4217,13 @@ export class FeishuBot {
|
|
|
4051
4217
|
}
|
|
4052
4218
|
// Presentation only: leave exact-run finality, stop suppression and durable
|
|
4053
4219
|
// outbox keys owned by the existing verdict; no retry/model fallback here.
|
|
4054
|
-
if (verdict.state === "terminal" && verdict.reason
|
|
4055
|
-
|
|
4220
|
+
if (verdict.state === "terminal" && ['ordinary_gateway_error', 'closed_gateway_terminal', 'exact_run_terminal_error'].includes(verdict.reason)) {
|
|
4221
|
+
const reason = closedStopReason(record.provenance.stopReason) || verdict.stopReason;
|
|
4222
|
+
const en = this.isEn(record.chatId);
|
|
4223
|
+
verdict = { ...verdict, text: reason === 'timeout' ? this.formatUserVisibleError({ code: 'execution_timeout' }, record.chatId)
|
|
4224
|
+
: reason === 'superseded' ? (en ? '⚠️ This run ended after being superseded by a newer session writer; no user stop is inferred.' : '⚠️ 本次运行已被新的会话写入者取代而终止;不据此认定用户停止。')
|
|
4225
|
+
: reason === 'restart' ? (en ? '⚠️ This run ended for a Gateway restart; no user stop is inferred.' : '⚠️ 本次运行因 Gateway 重启而终止;不据此认定用户停止。')
|
|
4226
|
+
: this.formatUserVisibleError(record.provenance.detail, record.chatId) };
|
|
4056
4227
|
}
|
|
4057
4228
|
// A stop or final may have arrived during the read-only lookup.
|
|
4058
4229
|
const latest = this.store.getErrorNotice(this.config.name, record.chatId, record.noticeId);
|
|
@@ -4071,6 +4242,8 @@ export class FeishuBot {
|
|
|
4071
4242
|
if (this.store.hasRunFinalDelivery(this.config.name, record.chatId, record.provenance, false)) {
|
|
4072
4243
|
return { state: "pending", reason: "exact_run_final_delivery_pending" };
|
|
4073
4244
|
}
|
|
4245
|
+
if (verdict.state === 'terminal' && verdict.stopReason && !record.provenance.stopReason)
|
|
4246
|
+
record.provenance = { ...record.provenance, stopReason: verdict.stopReason };
|
|
4074
4247
|
return verdict;
|
|
4075
4248
|
}
|
|
4076
4249
|
deferErrorNotice(record, verdict) {
|
|
@@ -4153,7 +4326,7 @@ export class FeishuBot {
|
|
|
4153
4326
|
this.store.saveErrorNotice(record);
|
|
4154
4327
|
if (verdict.state === "terminal" && verdict.terminalKey && verdict.text) {
|
|
4155
4328
|
// Independent identity: never take the result's trigger/final key.
|
|
4156
|
-
await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", sessionKey: record.provenance.sessionKey, runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
|
|
4329
|
+
await this.enqueueAndDispatchDelivery(chatId, "run_error", verdict.terminalKey, verdict.text, [], undefined, verdict.terminalKey, undefined, { sourceType: "run_error", sessionKey: record.provenance.sessionKey, sessionId: record.provenance.sessionId, runId: record.provenance.runId, error: record.provenance, terminalEvidence: verdict }, record.noticeId);
|
|
4157
4330
|
// The send-time recheck may have parked or suppressed the observation.
|
|
4158
4331
|
const refreshed = this.store.getErrorNotice(this.config.name, chatId, record.noticeId);
|
|
4159
4332
|
if (refreshed?.verdict && ["pending", "suppressed", "stopped"].includes(refreshed.verdict.state))
|
|
@@ -4301,7 +4474,7 @@ export class FeishuBot {
|
|
|
4301
4474
|
}
|
|
4302
4475
|
const notice = JSON.parse(item.deliveryMetaJson || '{}').insertionNotice;
|
|
4303
4476
|
if (notice) {
|
|
4304
|
-
const info = await this.openclawClient.getSessionInfo(this.
|
|
4477
|
+
const info = await this.openclawClient.getSessionInfo(this.physicalSessionKey(chatId), 1500).catch(() => null);
|
|
4305
4478
|
if (!this.insertionNoticeCurrent(chatId, notice) || info?.session?.archived === true ||
|
|
4306
4479
|
info?.session?.sessionId && info.session.sessionId !== notice.sessionId) {
|
|
4307
4480
|
this.store.markDeliveryFailed(item.id);
|
|
@@ -4326,8 +4499,13 @@ export class FeishuBot {
|
|
|
4326
4499
|
const meta = JSON.parse(item.deliveryMetaJson || '{}'), p = meta.provenance;
|
|
4327
4500
|
if (!this.insertionNoticeCurrent(chatId, meta.insertionNotice))
|
|
4328
4501
|
throw Object.assign(new Error('Insertion notice no longer current'), { deliveryStopped: true });
|
|
4329
|
-
if (
|
|
4330
|
-
|
|
4502
|
+
if (p?.error) {
|
|
4503
|
+
const revoked = this.errorRouteRevocation(chatId, p.error);
|
|
4504
|
+
if (revoked)
|
|
4505
|
+
throw Object.assign(new Error(`Original error delivery revoked: ${revoked}`), { deliveryStopped: true });
|
|
4506
|
+
}
|
|
4507
|
+
if ((p?.final || p?.error || item.sourceType === 'run_error') &&
|
|
4508
|
+
(meta.botIdentity && meta.botIdentity !== this.config.appId || typeof meta.deliveryStopEpoch === 'number' && meta.deliveryStopEpoch !== this.stopGeneration(chatId) ||
|
|
4331
4509
|
p?.sessionKey && p.runId && this.store.isStoppedObservation(p.sessionKey, p.runId))) {
|
|
4332
4510
|
throw Object.assign(new Error('Delivery revoked by explicit stop before platform attempt'), { deliveryStopped: true });
|
|
4333
4511
|
}
|
|
@@ -4699,7 +4877,7 @@ export class FeishuBot {
|
|
|
4699
4877
|
}
|
|
4700
4878
|
if (pause) {
|
|
4701
4879
|
discussionWaitPaused = true;
|
|
4702
|
-
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}
|
|
4880
|
+
await liveStatus?.pauseForUnconfirmedResult(pause.message, `${this.config.name} 等待暂停 · ${pause.snapshot.status}`, "paused").catch(() => { });
|
|
4703
4881
|
await this.sendWaitPaused(chatId, 0, undefined, pause).catch(() => { });
|
|
4704
4882
|
}
|
|
4705
4883
|
else
|
|
@@ -4735,7 +4913,7 @@ export class FeishuBot {
|
|
|
4735
4913
|
const outcome = await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
|
|
4736
4914
|
discussionDeliveryConfirmed = outcome.state === 'delivered';
|
|
4737
4915
|
if (!discussionDeliveryConfirmed)
|
|
4738
|
-
await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;原投递仍受保管").catch(() => { });
|
|
4916
|
+
await liveStatus?.pauseForUnconfirmedResult("飞书投递回执尚未确认;原投递仍受保管", undefined, "delivery_pending").catch(() => { });
|
|
4739
4917
|
}
|
|
4740
4918
|
catch (err) {
|
|
4741
4919
|
await liveStatus?.fail().catch(() => { });
|
|
@@ -4755,13 +4933,46 @@ export class FeishuBot {
|
|
|
4755
4933
|
}
|
|
4756
4934
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible && discussionDeliveryConfirmed };
|
|
4757
4935
|
}
|
|
4758
|
-
|
|
4936
|
+
liveInputElements(view, chatId, limits) {
|
|
4937
|
+
const context = view.inputStatus;
|
|
4938
|
+
if (!context)
|
|
4939
|
+
return [];
|
|
4940
|
+
const en = this.isEn(chatId);
|
|
4941
|
+
const scoped = !!chatId && context.chatId === chatId && context.botName === this.config.name && context.appId === this.config.appId &&
|
|
4942
|
+
context.stopEpoch === this.stopGeneration(chatId);
|
|
4943
|
+
const preview = (hint) => {
|
|
4944
|
+
const source = scoped && hint.rowId ? this.store.getMessageByRowId(hint.rowId) : undefined;
|
|
4945
|
+
if (!source || source.chatId !== chatId || source.senderType !== 'human' || !hint.messageId || source.messageId !== hint.messageId)
|
|
4946
|
+
return en ? '[Message body unavailable]' : '[消息正文暂不可用]';
|
|
4947
|
+
if (this.store.isMessageRecalled(source.messageId))
|
|
4948
|
+
return en ? '[Message recalled]' : '[消息已撤回]';
|
|
4949
|
+
return hint.previewSource === 'visible' ? visibleInputPreview(hint.preview ?? '', limits.preview, en) : inputPreview(hint.preview ?? source.content, limits.preview, en);
|
|
4950
|
+
};
|
|
4951
|
+
const selected = selectInputHints(context.items, limits.members);
|
|
4952
|
+
const stamp = (at) => { const sec = Number.isFinite(at) ? Math.max(0, Math.floor(at)) : 0; return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; };
|
|
4953
|
+
const lines = selected.map(hint => `${stamp(hint.at)} ${hintLabel(hint.kind, en)} ${inertInputText(preview(hint))}${hintDetail(hint.kind, en) ? '\n ↳ ' + inertInputText(hintDetail(hint.kind, en)) : ''}`);
|
|
4954
|
+
if (context.items.length > selected.length)
|
|
4955
|
+
lines.splice(Math.min(1, lines.length), 0, en ? `… ${context.items.length - selected.length} other inputs omitted; newest inputs retained` : `… 另有 ${context.items.length - selected.length} 条输入未展开,保留最新输入`);
|
|
4956
|
+
if (context.note)
|
|
4957
|
+
lines.push(`${stamp(context.note.at)} ${hintLabel(context.note.kind, en)} ${selected.map(hint => inertInputText(preview(hint))).join(' · ')}\n ↳ ${inertInputText(hintDetail(context.note.kind, en))}`);
|
|
4958
|
+
return lines.length ? [{ tag: 'markdown', content: lines.join('\n') }] : [];
|
|
4959
|
+
}
|
|
4960
|
+
liveStatusRequest(view, chatId, request) {
|
|
4961
|
+
for (const limits of INPUT_DISPLAY_LIMITS) {
|
|
4962
|
+
const value = request(this.buildLiveStatusCard(view, chatId, limits));
|
|
4963
|
+
if (Buffer.byteLength(JSON.stringify(value), 'utf8') <= LIVE_CARD_BYTES - 512)
|
|
4964
|
+
return value; // reserve SDK envelope overhead
|
|
4965
|
+
}
|
|
4966
|
+
throw new LiveStatusEffectSuppressedError('Live status request exceeds local outer JSON byte budget; no SDK call');
|
|
4967
|
+
}
|
|
4968
|
+
buildLiveStatusCard(view, chatId, limits = INPUT_DISPLAY_LIMITS[0]) {
|
|
4759
4969
|
const en = this.isEn(chatId);
|
|
4970
|
+
const inputElements = this.liveInputElements(view, chatId, limits);
|
|
4760
4971
|
if (view.state === "replied") {
|
|
4761
4972
|
// Neutral UI-only closure, not a green business/input-completion claim.
|
|
4762
4973
|
const summary = en ? `💬 Linked run replied · ⏱ ${view.elapsed}` : `💬 关联轮次已回复 · ⏱ 耗时${view.elapsed}`;
|
|
4763
4974
|
return { schema: "2.0", config: { update_multi: true, width_mode: "fill" },
|
|
4764
|
-
body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] } };
|
|
4975
|
+
body: { elements: [...inputElements, { tag: "markdown", content: `<font color='grey'>${summary}</font>` }] } };
|
|
4765
4976
|
}
|
|
4766
4977
|
// Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
|
|
4767
4978
|
// one compact grey line (status emoji + tool-call count + total elapsed) so
|
|
@@ -4770,18 +4981,18 @@ export class FeishuBot {
|
|
|
4770
4981
|
const statusEmoji = view.noReply ? "💤" : "✅";
|
|
4771
4982
|
const summary = this.completionSummary({
|
|
4772
4983
|
toolCalls: view.toolCalls,
|
|
4773
|
-
elapsed: view.elapsed,
|
|
4774
|
-
model: view.model,
|
|
4775
|
-
thinkingLevel: view.thinkingLevel,
|
|
4984
|
+
elapsed: displayClip(view.elapsed, 32),
|
|
4985
|
+
model: view.model ? displayClip(view.model, 160) : undefined,
|
|
4986
|
+
thinkingLevel: view.thinkingLevel ? displayClip(view.thinkingLevel, 80) : undefined,
|
|
4776
4987
|
locale: en ? "en" : "zh",
|
|
4777
4988
|
}, statusEmoji);
|
|
4778
4989
|
return {
|
|
4779
4990
|
schema: "2.0",
|
|
4780
4991
|
config: { update_multi: true, width_mode: "fill" },
|
|
4781
|
-
body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
|
|
4992
|
+
body: { elements: [...inputElements, { tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
|
|
4782
4993
|
};
|
|
4783
4994
|
}
|
|
4784
|
-
const elements = [];
|
|
4995
|
+
const elements = [...inputElements];
|
|
4785
4996
|
// Running OR failed: show the recent activity window (each line prefixed with
|
|
4786
4997
|
// the relative time mm:ss). On failure we deliberately KEEP the last lines so
|
|
4787
4998
|
// the steps leading up to the error/kill/timeout are visible for debugging.
|
|
@@ -4794,24 +5005,26 @@ export class FeishuBot {
|
|
|
4794
5005
|
: "•";
|
|
4795
5006
|
const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
|
|
4796
5007
|
const content = view.lines
|
|
5008
|
+
.filter(l => !l.inputHint && !(view.inputStatus?.botName && l.kind === 'lifecycle'))
|
|
4797
5009
|
.map((l) => {
|
|
4798
5010
|
const stamp = l.kind === "summary" ? "" : `\`${fmtAt(l.at)}\` `;
|
|
4799
|
-
return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(l.text)}`;
|
|
5011
|
+
return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(displayClip(l.text, limits.activity))}`;
|
|
4800
5012
|
})
|
|
4801
5013
|
.join("\n");
|
|
4802
|
-
|
|
5014
|
+
if (content)
|
|
5015
|
+
elements.push({ tag: "markdown", content });
|
|
4803
5016
|
}
|
|
4804
|
-
else {
|
|
5017
|
+
else if (!inputElements.length) {
|
|
4805
5018
|
elements.push({ tag: "markdown", content: view.state === "queued" ? (en ? "_Received locally; waiting for admission_" : "_本地已收件,等待提交确认_") : view.state === "admitted" ? (en ? "_Accepted; waiting for activity_" : "_已接收,等待活动_") : (en ? "_working…_" : "_正在启动…_") });
|
|
4806
5019
|
}
|
|
4807
5020
|
elements.push({ tag: "hr" });
|
|
4808
5021
|
// Keep the active model visible while the run is in progress or failed.
|
|
4809
5022
|
// The final reply also keeps model attribution so it remains self-contained.
|
|
4810
|
-
const footerBits = [en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`];
|
|
5023
|
+
const footerBits = [en ? `⏱ ${displayClip(view.elapsed, 32)}` : `⏱ 已用 ${displayClip(view.elapsed, 32)}`];
|
|
4811
5024
|
if (view.model?.trim()) {
|
|
4812
5025
|
const modelBit = view.thinkingLevel?.trim()
|
|
4813
|
-
? `🧠 ${this.escapeCardText(view.model.trim())} · ${this.escapeCardText(view.thinkingLevel.trim())}`
|
|
4814
|
-
: `🧠 ${this.escapeCardText(view.model.trim())}`;
|
|
5026
|
+
? `🧠 ${this.escapeCardText(displayClip(view.model.trim(), 160))} · ${this.escapeCardText(displayClip(view.thinkingLevel.trim(), 80))}`
|
|
5027
|
+
: `🧠 ${this.escapeCardText(displayClip(view.model.trim(), 160))}`;
|
|
4815
5028
|
footerBits.push(modelBit);
|
|
4816
5029
|
}
|
|
4817
5030
|
elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
|
|
@@ -4822,7 +5035,7 @@ export class FeishuBot {
|
|
|
4822
5035
|
// rejects patches on cards that did not declare it before and after.
|
|
4823
5036
|
config: { update_multi: true, width_mode: "fill" },
|
|
4824
5037
|
header: {
|
|
4825
|
-
title: { tag: "plain_text", content: view.title },
|
|
5038
|
+
title: { tag: "plain_text", content: displayClip(view.title, 160) },
|
|
4826
5039
|
template,
|
|
4827
5040
|
},
|
|
4828
5041
|
body: { elements },
|
|
@@ -4859,14 +5072,8 @@ export class FeishuBot {
|
|
|
4859
5072
|
}
|
|
4860
5073
|
async sendLiveStatusCard(chatId, view) {
|
|
4861
5074
|
try {
|
|
4862
|
-
const
|
|
4863
|
-
|
|
4864
|
-
data: {
|
|
4865
|
-
receive_id: chatId,
|
|
4866
|
-
content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
|
|
4867
|
-
msg_type: "interactive",
|
|
4868
|
-
},
|
|
4869
|
-
});
|
|
5075
|
+
const request = this.liveStatusRequest(view, chatId, card => ({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content: JSON.stringify(card), msg_type: "interactive" } }));
|
|
5076
|
+
const res = await this.client.im.message.create(request);
|
|
4870
5077
|
if (typeof res.code === "number" && res.code !== 0)
|
|
4871
5078
|
throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
|
|
4872
5079
|
if (res.code !== 0)
|
|
@@ -4881,13 +5088,8 @@ export class FeishuBot {
|
|
|
4881
5088
|
}
|
|
4882
5089
|
}
|
|
4883
5090
|
async replyLiveStatusCard(messageId, view, chatId) {
|
|
4884
|
-
const
|
|
4885
|
-
|
|
4886
|
-
data: {
|
|
4887
|
-
content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
|
|
4888
|
-
msg_type: "interactive",
|
|
4889
|
-
},
|
|
4890
|
-
});
|
|
5091
|
+
const request = this.liveStatusRequest(view, chatId, card => ({ path: { message_id: messageId }, data: { content: JSON.stringify(card), msg_type: "interactive" } }));
|
|
5092
|
+
const res = await this.client.im.message.reply(request);
|
|
4891
5093
|
if (typeof res.code === "number" && res.code !== 0)
|
|
4892
5094
|
throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
|
|
4893
5095
|
if (res.code !== 0)
|
|
@@ -4895,10 +5097,8 @@ export class FeishuBot {
|
|
|
4895
5097
|
return res?.data?.message_id || res?.message_id;
|
|
4896
5098
|
}
|
|
4897
5099
|
async patchLiveStatusCard(messageId, view, chatId) {
|
|
4898
|
-
const
|
|
4899
|
-
|
|
4900
|
-
data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
|
|
4901
|
-
});
|
|
5100
|
+
const request = this.liveStatusRequest(view, chatId, card => ({ path: { message_id: messageId }, data: { content: JSON.stringify(card) } }));
|
|
5101
|
+
const res = await this.client.im.message.patch(request);
|
|
4902
5102
|
if (typeof res.code === "number" && res.code !== 0)
|
|
4903
5103
|
throw new LiveStatusEffectRejectedError(`Feishu rejected card: ${res.code}`);
|
|
4904
5104
|
if (res.code !== 0)
|
|
@@ -5798,7 +5998,7 @@ export class FeishuBot {
|
|
|
5798
5998
|
return;
|
|
5799
5999
|
}
|
|
5800
6000
|
if (nextModel === this.config.model) {
|
|
5801
|
-
await this.replyMessage(messageId, `🤖 ${this.config.name} 已经在使用:${this.config.model}`);
|
|
6001
|
+
await this.replyMessage(messageId, `🤖 ${this.config.name} 已经在使用:${this.config.model || `profile ${this.profile}`}`);
|
|
5802
6002
|
return;
|
|
5803
6003
|
}
|
|
5804
6004
|
if (!this.configPath) {
|
|
@@ -5829,7 +6029,7 @@ export class FeishuBot {
|
|
|
5829
6029
|
* Handle /status command: show current session info.
|
|
5830
6030
|
*/
|
|
5831
6031
|
async handleStatusCommand(chatId, chatType, messageId) {
|
|
5832
|
-
const sessionKey = this.
|
|
6032
|
+
const sessionKey = this.physicalSessionKey(chatId);
|
|
5833
6033
|
const chatInfo = this.store.getChatInfo(chatId);
|
|
5834
6034
|
const msgCount = this.store.getMessageCount(chatId);
|
|
5835
6035
|
let session = null;
|
|
@@ -5976,7 +6176,7 @@ export class FeishuBot {
|
|
|
5976
6176
|
}
|
|
5977
6177
|
}
|
|
5978
6178
|
async handleCompactCommand(chatId, messageId, args = "") {
|
|
5979
|
-
const sessionKey = this.
|
|
6179
|
+
const sessionKey = this.physicalSessionKey(chatId);
|
|
5980
6180
|
const en = this.isEn(chatId);
|
|
5981
6181
|
// Argument parsing — modes are strictly separated:
|
|
5982
6182
|
// /compact → transcript tail trim (default, model-free)
|
|
@@ -6077,7 +6277,7 @@ export class FeishuBot {
|
|
|
6077
6277
|
* then verify via describe.
|
|
6078
6278
|
*/
|
|
6079
6279
|
async handleResetCommand(chatId, messageId) {
|
|
6080
|
-
const sessionKey = this.
|
|
6280
|
+
const sessionKey = this.physicalSessionKey(chatId);
|
|
6081
6281
|
try {
|
|
6082
6282
|
await this.assertIdentityEffect();
|
|
6083
6283
|
// Go supervision: a reset replaces the executor session generation; pause
|
|
@@ -6100,7 +6300,7 @@ export class FeishuBot {
|
|
|
6100
6300
|
await new Promise((r) => setTimeout(r, 2000));
|
|
6101
6301
|
this.assertIdentityCurrent();
|
|
6102
6302
|
// A held reset must not initialize an unobserved successor generation.
|
|
6103
|
-
this.initializedSessions.delete(
|
|
6303
|
+
this.initializedSessions.delete(this.getSessionKey(chatId));
|
|
6104
6304
|
if (!held)
|
|
6105
6305
|
await this.ensureSession(chatId);
|
|
6106
6306
|
await this.replyMessage(messageId, `✅ Session 已重置\n模型: ${this.config.model}`);
|
|
@@ -6114,8 +6314,42 @@ export class FeishuBot {
|
|
|
6114
6314
|
* runs for the session, clears the busy lock and every pending trigger, and
|
|
6115
6315
|
* resets stuck reactions so new messages are processed normally again.
|
|
6116
6316
|
*/
|
|
6317
|
+
/** Release every peer bot's in-memory scheduling state for one chat.
|
|
6318
|
+
* Each bot instance keeps its own queueRuns / queueOwners / busyChats /
|
|
6319
|
+
* pendingAckMessages maps; a durable row cleanup cannot reach those. A
|
|
6320
|
+
* group-scoped /stop must retire them too, otherwise the peer's queue owner
|
|
6321
|
+
* survives and its next message is treated as "busy" forever. */
|
|
6322
|
+
releasePeerBotsForChat(chatId) {
|
|
6323
|
+
let released = 0;
|
|
6324
|
+
for (const peer of Array.from(FeishuBot.allBots.values())) {
|
|
6325
|
+
if (peer === this || peer.store !== this.store)
|
|
6326
|
+
continue;
|
|
6327
|
+
// Durable stop epoch was already bumped for this peer by the store-level
|
|
6328
|
+
// group cleanup; mirror it into the peer's in-memory cache.
|
|
6329
|
+
peer.stopEpoch.set(chatId, peer.store.getStopGeneration(peer.config.name, chatId));
|
|
6330
|
+
peer.forceExpireQueueOwner(chatId, 'group-scoped stop by peer bot');
|
|
6331
|
+
peer.busyChats.set(chatId, 0);
|
|
6332
|
+
peer.admittedSteerAcks.delete(chatId);
|
|
6333
|
+
peer.runFailureObservers.delete(chatId);
|
|
6334
|
+
peer.queueRunTokens.delete(chatId);
|
|
6335
|
+
const acks = peer.pendingAckMessages.get(chatId) || [];
|
|
6336
|
+
peer.pendingAckMessages.set(chatId, []);
|
|
6337
|
+
for (const ack of acks)
|
|
6338
|
+
void peer.receiptReaction(chatId, ack.rowId, ack.messageId, '').catch(() => { });
|
|
6339
|
+
// Fence the peer's stale collector so it cannot keep destructively
|
|
6340
|
+
// consuming the replacement batch's frames from the shared session bucket.
|
|
6341
|
+
try {
|
|
6342
|
+
this.openclawClient.revokeSessionCollectors(peer.getSessionKey(chatId), 'group-scoped stop');
|
|
6343
|
+
}
|
|
6344
|
+
catch { /* isolated */ }
|
|
6345
|
+
void peer.openclawClient.cancelSteerSession?.(peer.getSessionKey(chatId), peer.stopGeneration(chatId)).catch(() => { });
|
|
6346
|
+
void peer.openclawClient.abortChat(peer.getSessionKey(chatId)).catch(() => { });
|
|
6347
|
+
released++;
|
|
6348
|
+
}
|
|
6349
|
+
return released;
|
|
6350
|
+
}
|
|
6117
6351
|
async handleStopCommand(chatId, messageId) {
|
|
6118
|
-
const sessionKey = this.
|
|
6352
|
+
const sessionKey = this.physicalSessionKey(chatId);
|
|
6119
6353
|
// Go supervision: a user stop must also pause related Go, so the checker
|
|
6120
6354
|
// cannot wake the just-stopped task back up with a follow-up.
|
|
6121
6355
|
void this.goController?.pauseSilently(chatId, "用户执行了 /stop");
|
|
@@ -6140,14 +6374,29 @@ export class FeishuBot {
|
|
|
6140
6374
|
this.pendingAckMessages.set(chatId, []);
|
|
6141
6375
|
for (const ack of acks)
|
|
6142
6376
|
void this.receiptReaction(chatId, ack.rowId, ack.messageId, '').catch(() => { });
|
|
6377
|
+
// Group scope: a bare /stop in a multi-bot group is executed by ONE bot,
|
|
6378
|
+
// but every bot keeps its own pending_triggers / insertion_custody /
|
|
6379
|
+
// dispatch_custody / reaction rows AND its own in-memory queue owner for
|
|
6380
|
+
// this same chat. Stopping only the executing bot left the others
|
|
6381
|
+
// believing the chat was busy, so their next message was queued and
|
|
6382
|
+
// never dispatched. Release the whole chat for every peer bot.
|
|
6383
|
+
//
|
|
6384
|
+
// This MUST run BEFORE the abort RPC await: a user message that arrives
|
|
6385
|
+
// while the abort is in flight has to survive the stop cutoff, exactly
|
|
6386
|
+
// like the executing bot's own late input above.
|
|
6387
|
+
const group = this.store.stopChatForAllBots(chatId, this.config.name);
|
|
6388
|
+
const peers = this.releasePeerBotsForChat(chatId);
|
|
6143
6389
|
await this.openclawClient.abortChat(sessionKey).catch(() => { });
|
|
6144
6390
|
if (!this.queueRuns.has(chatId))
|
|
6145
6391
|
this.busyChats.set(chatId, 0);
|
|
6146
6392
|
const locale = this.chatLocale(chatId);
|
|
6393
|
+
const scopeNote = peers > 0
|
|
6394
|
+
? (locale === "en" ? ` Also released ${peers} peer bot(s) in this chat.` : ` 同时释放了本群其余 ${peers} 个 bot 的队列。`)
|
|
6395
|
+
: "";
|
|
6147
6396
|
await this.replyMessage(messageId, locale === "en"
|
|
6148
|
-
? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now
|
|
6149
|
-
: `⏹️ 已停止。已中止当前 run、解锁队列,并清掉 ${cleared}
|
|
6150
|
-
console.log(`[${this.config.name}] /stop force-cleared ${chatId.slice(-8)}: aborted run, cleared ${cleared} pending`);
|
|
6397
|
+
? `⏹️ Stopped. Aborted the active run, unlocked the queue, and cleared ${cleared} pending message(s). You can send a new message now.${scopeNote}`
|
|
6398
|
+
: `⏹️ 已停止。已中止当前 run、解锁队列,并清掉 ${cleared} 条待处理消息。现在可以重新发消息了。${scopeNote}`);
|
|
6399
|
+
console.log(`[${this.config.name}] /stop force-cleared ${chatId.slice(-8)}: aborted run, cleared ${cleared} pending, group: insertions=${group.stoppedInsertions} triggers=${group.clearedTriggers} custody=${group.clearedCustody} peers=${peers}`);
|
|
6151
6400
|
}
|
|
6152
6401
|
catch (err) {
|
|
6153
6402
|
await this.replyMessage(messageId, `❌ stop 失败: ${err.message}`);
|
|
@@ -6158,28 +6407,17 @@ export class FeishuBot {
|
|
|
6158
6407
|
* Called once per chat on first message.
|
|
6159
6408
|
*/
|
|
6160
6409
|
async fetchAndCacheChatInfo(chatId, chatType) {
|
|
6410
|
+
if (chatType === "p2p") {
|
|
6411
|
+
// Private ownership is not hourly metadata. Check/pin the stable SDK app
|
|
6412
|
+
// identity even on a fresh cache; never replace an unknown legacy owner.
|
|
6413
|
+
this.store.claimP2pOwner(chatId, this.config.name, this.config.appId);
|
|
6414
|
+
return;
|
|
6415
|
+
}
|
|
6161
6416
|
const existing = this.store.getChatInfo(chatId);
|
|
6162
6417
|
// Refresh at most once per hour
|
|
6163
6418
|
if (existing && Date.now() - existing.updatedAt < 3600_000)
|
|
6164
6419
|
return;
|
|
6165
6420
|
try {
|
|
6166
|
-
if (chatType === "p2p") {
|
|
6167
|
-
// For p2p, we don't have a group name or member list from chat API
|
|
6168
|
-
this.store.upsertChatInfo({
|
|
6169
|
-
chatId,
|
|
6170
|
-
chatType: "p2p",
|
|
6171
|
-
chatName: "私聊",
|
|
6172
|
-
members: "",
|
|
6173
|
-
memberNames: "",
|
|
6174
|
-
ownerBot: existing?.ownerBot || this.config.name,
|
|
6175
|
-
freeDiscussion: this.store.getChatInfo(chatId)?.freeDiscussion || false,
|
|
6176
|
-
verbose: this.store.getChatInfo(chatId)?.verbose || false,
|
|
6177
|
-
discuss: this.store.getChatInfo(chatId)?.discuss || false,
|
|
6178
|
-
discussMaxRounds: this.store.getChatInfo(chatId)?.discussMaxRounds || 10,
|
|
6179
|
-
updatedAt: Date.now(),
|
|
6180
|
-
});
|
|
6181
|
-
return;
|
|
6182
|
-
}
|
|
6183
6421
|
// Fetch group info
|
|
6184
6422
|
const chatResp = await this.client.im.chat.get({
|
|
6185
6423
|
path: { chat_id: chatId },
|
|
@@ -6428,6 +6666,28 @@ export class FeishuBot {
|
|
|
6428
6666
|
/**
|
|
6429
6667
|
* Extract text content from a rich text (post) message.
|
|
6430
6668
|
*/
|
|
6669
|
+
/** Visible rich-post fragments only. Text nodes remain literal; only actual
|
|
6670
|
+
* media nodes become type markers, never a regex over the user's prose. */
|
|
6671
|
+
visiblePostInput(content, chatId) {
|
|
6672
|
+
const parts = [];
|
|
6673
|
+
if (typeof content.title === 'string')
|
|
6674
|
+
parts.push(content.title);
|
|
6675
|
+
for (const paragraph of Array.isArray(content.content) ? content.content : []) {
|
|
6676
|
+
if (!Array.isArray(paragraph))
|
|
6677
|
+
continue;
|
|
6678
|
+
for (const node of paragraph) {
|
|
6679
|
+
if (node?.tag === 'text' && typeof node.text === 'string')
|
|
6680
|
+
parts.push(node.text);
|
|
6681
|
+
else if (node?.tag === 'a' && typeof node.text === 'string')
|
|
6682
|
+
parts.push(`${node.text}${typeof node.href === 'string' ? `(${node.href})` : ''}`);
|
|
6683
|
+
else if (node?.tag === 'at' && typeof node.user_name === 'string')
|
|
6684
|
+
parts.push(`@${node.user_name}`);
|
|
6685
|
+
else if (node?.tag === 'img')
|
|
6686
|
+
parts.push(this.isEn(chatId) ? '[Image]' : '[图片]');
|
|
6687
|
+
}
|
|
6688
|
+
}
|
|
6689
|
+
return parts.join(' ');
|
|
6690
|
+
}
|
|
6431
6691
|
extractPostText(content) {
|
|
6432
6692
|
const parts = [];
|
|
6433
6693
|
const title = content.title;
|