@xmanrui/dsh-im 4.18.1 → 4.19.1
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.en.md +1 -0
- package/README.md +1 -0
- package/lib/client.js +917 -521
- package/lib/index.js +269 -267
- package/package.json +1 -1
- package/plugin-src/client/bot-alias.js +169 -0
- package/plugin-src/client/channels/dingtalk/api.js +3 -0
- package/plugin-src/client/channels/dingtalk/index.js +7 -1
- package/plugin-src/client/channels/feishu/api.js +3 -0
- package/plugin-src/client/channels/feishu/index.js +7 -1
- package/plugin-src/client/channels/qq/api.js +3 -0
- package/plugin-src/client/channels/qq/index.js +9 -1
- package/plugin-src/client/channels/shared/token-api.js +3 -0
- package/plugin-src/client/channels/shared/token-channel.js +9 -2
- package/plugin-src/client/channels/wecom/api.js +3 -0
- package/plugin-src/client/channels/wecom/index.js +9 -1
- package/plugin-src/client/channels/wecom-app/api.js +3 -0
- package/plugin-src/client/channels/wecom-app/index.js +9 -1
- package/plugin-src/client/channels/weixin/api.js +3 -0
- package/plugin-src/client/channels/weixin/index.js +7 -1
- package/plugin-src/client/channels/whatsapp/api.js +3 -0
- package/plugin-src/client/channels/whatsapp/index.js +9 -1
- package/plugin-src/client/i18n.js +10 -0
- package/plugin-src/client/styles.js +41 -6
- package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
- package/plugin-src/host/channels/feishu/production.mjs +22 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +14 -0
- package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
- package/plugin-src/host/channels/qq/rpc.mjs +11 -0
- package/plugin-src/host/channels/shared/bot-alias-rpc.mjs +15 -0
- package/plugin-src/host/channels/shared/rpc.mjs +9 -0
- package/plugin-src/host/channels/slack/rpc.mjs +10 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +13 -0
- package/plugin-src/host/session-sync-coordinator.mjs +20 -1
- package/src/channels/feishu/bridge.mjs +330 -10
- package/src/channels/feishu/feishu-cards.mjs +1 -1
- package/src/channels/feishu/feishu-runtime.mjs +6 -0
- package/src/channels/feishu/state-store.mjs +17 -0
- package/src/channels/shared/bot-alias.mjs +29 -0
- package/src/channels/shared/bot-workspace-store.mjs +71 -3
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
- package/src/channels/shared/message-failure.mjs +2 -1
- package/src/channels/shared/model-command.mjs +3 -2
- package/src/channels/shared/session-sync-registry.mjs +22 -0
|
@@ -28,6 +28,9 @@ import {
|
|
|
28
28
|
validHarnessQuestion,
|
|
29
29
|
} from '../shared/harness-question.mjs';
|
|
30
30
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
31
|
+
import { AssistantTextAccumulator, textFromHarnessContent } from '../shared/harness-client.mjs';
|
|
32
|
+
import { registerSessionSyncMirror } from '../shared/session-sync-registry.mjs';
|
|
33
|
+
import { extractCompletedTurnAnswer } from '../shared/deferred-delivery.mjs';
|
|
31
34
|
import {
|
|
32
35
|
BatchInputManager,
|
|
33
36
|
batchInputBusyMessage,
|
|
@@ -96,6 +99,7 @@ import {
|
|
|
96
99
|
steerCard,
|
|
97
100
|
watchListCard,
|
|
98
101
|
workspaceListCard,
|
|
102
|
+
stepStatusText,
|
|
99
103
|
} from './feishu-cards.mjs';
|
|
100
104
|
import { t } from '../shared/i18n.mjs';
|
|
101
105
|
import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
|
|
@@ -188,6 +192,8 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
|
|
|
188
192
|
/** Streaming-card mode coalesces card renders behind one PATCH per interval —
|
|
189
193
|
* patching the same message is far more rate sensitive than posting. */
|
|
190
194
|
const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
|
|
195
|
+
/** Confirm missed boundaries from history; elapsed time is never completion. */
|
|
196
|
+
const MIRROR_CHECK_MS = 30_000;
|
|
191
197
|
/** One answer chunk inside the streaming card: small enough that the block
|
|
192
198
|
* splitter can always distribute blocks across sealed/live cards. */
|
|
193
199
|
const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
|
|
@@ -633,6 +639,14 @@ export class FeishuHarnessBridge {
|
|
|
633
639
|
#observedCompletionEvents = new Map();
|
|
634
640
|
/** Earliest completion that still needs delivery for each watch. */
|
|
635
641
|
#failedWatchSeqs = new Map();
|
|
642
|
+
/** Host resolver: sessionId -> synced DM targets [{ openId, botId }]. */
|
|
643
|
+
#sessionSyncTargetsFor = null;
|
|
644
|
+
/** Per-turn mirrors and recent delivery receipts, scoped to this bot. */
|
|
645
|
+
#sessionSyncTurns = new Map();
|
|
646
|
+
#sessionSyncCurrentTurns = new Map();
|
|
647
|
+
#sessionSyncIdleTimers = new Map();
|
|
648
|
+
/** Conversation keys with an IM ask in flight (set BEFORE the turn starts). */
|
|
649
|
+
#imTurnKeys = new Set();
|
|
636
650
|
#cardDataTimeoutMs;
|
|
637
651
|
/** When true, approval/question interactions render as Feishu cards (buttons). */
|
|
638
652
|
#interactionCards = true;
|
|
@@ -660,6 +674,7 @@ export class FeishuHarnessBridge {
|
|
|
660
674
|
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
|
|
661
675
|
replyTimeoutMs = 600_000,
|
|
662
676
|
interactionCards = true,
|
|
677
|
+
sessionSyncTargetsFor = null,
|
|
663
678
|
logger = console,
|
|
664
679
|
signal,
|
|
665
680
|
}) {
|
|
@@ -709,6 +724,9 @@ export class FeishuHarnessBridge {
|
|
|
709
724
|
this.#cardDataTimeoutMs = cardDataTimeoutMs;
|
|
710
725
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
711
726
|
this.#interactionCards = interactionCards === true;
|
|
727
|
+
this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
|
|
728
|
+
? sessionSyncTargetsFor
|
|
729
|
+
: null;
|
|
712
730
|
this.#logger = logger;
|
|
713
731
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
|
714
732
|
this.#signal = signal;
|
|
@@ -717,11 +735,62 @@ export class FeishuHarnessBridge {
|
|
|
717
735
|
harness, state, signal, logger, watch: false,
|
|
718
736
|
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
719
737
|
});
|
|
720
|
-
|
|
721
|
-
|
|
738
|
+
if (this.#sessionSyncTargetsFor && this.#botId && !this.#signal?.aborted) {
|
|
739
|
+
const unregister = registerSessionSyncMirror({ channel: 'feishu', botId: this.#botId },
|
|
740
|
+
(request) => this.#deliverSessionSyncMirror(request));
|
|
741
|
+
this.#signal?.addEventListener('abort', () => {
|
|
742
|
+
unregister();
|
|
743
|
+
for (const timer of this.#sessionSyncIdleTimers.values()) clearTimeout(timer);
|
|
744
|
+
this.#sessionSyncIdleTimers.clear();
|
|
745
|
+
}, { once: true });
|
|
746
|
+
}
|
|
747
|
+
// Persisted watches and mirrors resume without waiting for an IM message.
|
|
722
748
|
if (typeof this.#harness?.watchHarnessEvents === 'function') {
|
|
723
749
|
queueMicrotask(() => {
|
|
724
750
|
this.#ensureEventWatcher();
|
|
751
|
+
void this.#sealOrphanMirrors();
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Recover only a known finished turn, using its last successful card JSON. */
|
|
757
|
+
async #sealOrphanMirrors() {
|
|
758
|
+
for (const [key, entry] of this.#state.mirrorEntries?.() ?? []) {
|
|
759
|
+
if (!entry?.chatId || !entry.sessionId || !Number.isSafeInteger(entry.turn)
|
|
760
|
+
|| !Array.isArray(entry.cardIds) || !entry.cardIds.length) continue;
|
|
761
|
+
await this.#queueEventTask(entry.sessionId, async () => {
|
|
762
|
+
if (this.#signal?.aborted) return;
|
|
763
|
+
// An adopted turn now owns this record and will finish through its queue.
|
|
764
|
+
if (this.#sessionSyncTurns.has(key)) return;
|
|
765
|
+
try {
|
|
766
|
+
const events = await this.#mirrorHistory(entry.sessionId, entry.turn);
|
|
767
|
+
const outcome = extractCompletedTurnAnswer(events, { turn: entry.turn });
|
|
768
|
+
if (outcome.endSeq < 0) {
|
|
769
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (this.#restoreMirrorCard(key, entry)) {
|
|
773
|
+
const result = await this.#finishStepCard(key, {
|
|
774
|
+
answerText: outcome.text, stopped: outcome.reason !== 'completed',
|
|
775
|
+
});
|
|
776
|
+
if (!result?.ok) throw new Error('Recovered mirror could not deliver its final answer');
|
|
777
|
+
await this.#state.clearMirror?.(key);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const content = JSON.parse(entry.lastContent);
|
|
781
|
+
const elements = content?.body?.elements;
|
|
782
|
+
if (!Array.isArray(elements)) return;
|
|
783
|
+
const last = elements.at(-1);
|
|
784
|
+
const status = `_${stepStatusText(outcome.reason === 'completed' ? 'completed' : 'stopped')}_`;
|
|
785
|
+
if (last?.tag === 'markdown' && /^_.*_$/s.test(last.content ?? '')) last.content = status;
|
|
786
|
+
else elements.push({ tag: 'markdown', content: status });
|
|
787
|
+
// Earlier chunks are sealed history; update only the live card.
|
|
788
|
+
await this.#patchStepCard(entry.cardIds.at(-1), JSON.stringify(content));
|
|
789
|
+
await this.#state.clearMirror?.(key);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
this.#logger.warn?.('[dsh-feishu] mirror recovery failed:', error?.message ?? error);
|
|
792
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
|
|
793
|
+
}
|
|
725
794
|
});
|
|
726
795
|
}
|
|
727
796
|
}
|
|
@@ -2704,6 +2773,25 @@ export class FeishuHarnessBridge {
|
|
|
2704
2773
|
const updateMessageId = nonEmptyString(options.updateMessageId);
|
|
2705
2774
|
const replyTo = nonEmptyString(options.replyTo);
|
|
2706
2775
|
|
|
2776
|
+
// Session-sync cards target the user's openId (the synced DM is a user,
|
|
2777
|
+
// not a chat), delivered fresh without topic/thread handling.
|
|
2778
|
+
if (options.receiveIdType === 'open_id') {
|
|
2779
|
+
const response = await this.#client.im.v1.message.create({
|
|
2780
|
+
params: { receive_id_type: 'open_id' },
|
|
2781
|
+
data: {
|
|
2782
|
+
receive_id: chatId,
|
|
2783
|
+
msg_type: 'interactive',
|
|
2784
|
+
content: cardJson,
|
|
2785
|
+
},
|
|
2786
|
+
});
|
|
2787
|
+
if (response?.code && response.code !== 0) {
|
|
2788
|
+
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
|
2789
|
+
}
|
|
2790
|
+
const sentId = nonEmptyString(response?.data?.message_id);
|
|
2791
|
+
if (!sentId) throw new Error('Feishu card send returned no message_id');
|
|
2792
|
+
return sentId;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2707
2795
|
if (updateMessageId) {
|
|
2708
2796
|
try {
|
|
2709
2797
|
const response = await this.#client.im.v1.message.patch({
|
|
@@ -3224,16 +3312,24 @@ export class FeishuHarnessBridge {
|
|
|
3224
3312
|
|
|
3225
3313
|
#ensureEventWatcher() {
|
|
3226
3314
|
if (this.#eventWatcher) return;
|
|
3227
|
-
if (typeof this.#harness?.watchHarnessEvents !== 'function')
|
|
3315
|
+
if (typeof this.#harness?.watchHarnessEvents !== 'function') {
|
|
3316
|
+
this.#logger.warn?.('[dsh-feishu] harness lacks watchHarnessEvents; session-sync mirror disabled');
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3228
3319
|
if (this.#signal?.aborted) return;
|
|
3229
3320
|
const signal = this.#signal ?? new AbortController().signal;
|
|
3230
3321
|
try {
|
|
3231
3322
|
this.#eventWatcher = this.#harness.watchHarnessEvents({
|
|
3232
3323
|
signal,
|
|
3233
|
-
onSessionEvent: (payload) =>
|
|
3324
|
+
onSessionEvent: (payload) => {
|
|
3325
|
+
this.#onHarnessEvent(payload);
|
|
3326
|
+
},
|
|
3234
3327
|
onReconnect: () => {
|
|
3235
3328
|
void this.#compensateMissedEvents();
|
|
3236
3329
|
void this.#deferred.resume();
|
|
3330
|
+
for (const mirror of this.#sessionSyncTurns.values()) {
|
|
3331
|
+
if (!mirror.finishedAt) void this.#checkMirror(mirror);
|
|
3332
|
+
}
|
|
3237
3333
|
},
|
|
3238
3334
|
});
|
|
3239
3335
|
Promise.resolve(this.#eventWatcher).catch((error) => {
|
|
@@ -3572,14 +3668,199 @@ export class FeishuHarnessBridge {
|
|
|
3572
3668
|
);
|
|
3573
3669
|
}
|
|
3574
3670
|
|
|
3575
|
-
/**
|
|
3671
|
+
/** True while this bridge owns the IM ask; capture before queuing events. */
|
|
3672
|
+
#isImTurn(sessionId) {
|
|
3673
|
+
for (const key of this.#imTurnKeys) {
|
|
3674
|
+
if (this.#state.sessionFor?.(key) === sessionId) return true;
|
|
3675
|
+
}
|
|
3676
|
+
return false;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
#beginImTurn(key) { this.#imTurnKeys.add(key); }
|
|
3680
|
+
#endImTurn(key) { this.#imTurnKeys.delete(key); }
|
|
3681
|
+
|
|
3682
|
+
#mirrorKey(sessionId, turn) { return `session-sync\0${sessionId}\0${turn}`; }
|
|
3683
|
+
|
|
3684
|
+
#restoreMirrorCard(key, entry) {
|
|
3685
|
+
if (!Array.isArray(entry?.blocks) || !entry.cardIds?.length) return false;
|
|
3686
|
+
const card = this.#ensureStepCard(key, entry.chatId, null);
|
|
3687
|
+
Object.assign(card, {
|
|
3688
|
+
blocks: structuredClone(entry.blocks), cardIds: [...entry.cardIds],
|
|
3689
|
+
messageId: entry.cardIds.at(-1), chunkCount: entry.cardIds.length,
|
|
3690
|
+
answerStart: entry.answerStart ?? null, answerEnd: entry.answerEnd ?? null,
|
|
3691
|
+
deliveryViaOpenId: true,
|
|
3692
|
+
});
|
|
3693
|
+
return true;
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
#scheduleMirrorCheck(key, task) {
|
|
3697
|
+
if (this.#signal?.aborted || this.#sessionSyncIdleTimers.has(key)) return;
|
|
3698
|
+
const timer = setTimeout(() => {
|
|
3699
|
+
this.#sessionSyncIdleTimers.delete(key);
|
|
3700
|
+
if (!this.#signal?.aborted) void task();
|
|
3701
|
+
}, MIRROR_CHECK_MS);
|
|
3702
|
+
timer.unref?.();
|
|
3703
|
+
this.#sessionSyncIdleTimers.set(key, timer);
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
async #mirrorHistory(sessionId, turn) {
|
|
3707
|
+
if (typeof this.#harness.rpc !== 'function') return null;
|
|
3708
|
+
const events = [];
|
|
3709
|
+
let beforeSeq;
|
|
3710
|
+
for (let page = 0; page < 10; page += 1) {
|
|
3711
|
+
const history = await this.#harness.rpc('session.history', {
|
|
3712
|
+
sessionId, maxMessages: 100,
|
|
3713
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
3714
|
+
}, 10_000, { signal: this.#signal });
|
|
3715
|
+
const batch = orderedHistoryEvents(history);
|
|
3716
|
+
events.unshift(...batch);
|
|
3717
|
+
if (!history?.hasMore || batch.some((event) => event.type === 'turn/start' && event.data?.turn === turn)) {
|
|
3718
|
+
return events;
|
|
3719
|
+
}
|
|
3720
|
+
const oldest = batch[0]?.seq;
|
|
3721
|
+
if (!validEventSeq(oldest) || oldest === beforeSeq) break;
|
|
3722
|
+
beforeSeq = oldest;
|
|
3723
|
+
}
|
|
3724
|
+
return null; // A truncated history cannot prove the final answer is complete.
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
#checkMirror(mirror) {
|
|
3728
|
+
return this.#queueEventTask(mirror.sessionId, async () => {
|
|
3729
|
+
if (mirror.finishedAt || this.#signal?.aborted) return;
|
|
3730
|
+
try {
|
|
3731
|
+
const events = await this.#mirrorHistory(mirror.sessionId, mirror.turn);
|
|
3732
|
+
const outcome = extractCompletedTurnAnswer(events, { turn: mirror.turn });
|
|
3733
|
+
if (outcome.endSeq >= 0) {
|
|
3734
|
+
await this.#finishSessionSyncMirror(mirror, outcome.text ?? mirror.assistant.text, outcome.reason);
|
|
3735
|
+
}
|
|
3736
|
+
} catch (error) {
|
|
3737
|
+
this.#logger.warn?.('[dsh-feishu] mirror history check failed:', error?.message ?? error);
|
|
3738
|
+
}
|
|
3739
|
+
if (!mirror.finishedAt) this.#scheduleMirrorCheck(mirror.key, () => this.#checkMirror(mirror));
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
async #finishSessionSyncMirror(mirror, text, reason = 'completed') {
|
|
3744
|
+
if (mirror.finishedAt) return mirror.result?.ok === true && mirror.result.text === text;
|
|
3745
|
+
const recovering = mirror.recovered;
|
|
3746
|
+
if (mirror.recovered) {
|
|
3747
|
+
const events = await this.#mirrorHistory(mirror.sessionId, mirror.turn);
|
|
3748
|
+
const outcome = extractCompletedTurnAnswer(events, { turn: mirror.turn });
|
|
3749
|
+
if (outcome.endSeq < 0) return false;
|
|
3750
|
+
text = outcome.text ?? text;
|
|
3751
|
+
reason = outcome.reason;
|
|
3752
|
+
mirror.recovered = false;
|
|
3753
|
+
}
|
|
3754
|
+
clearTimeout(this.#sessionSyncIdleTimers.get(mirror.key));
|
|
3755
|
+
this.#sessionSyncIdleTimers.delete(mirror.key);
|
|
3756
|
+
const result = await this.#finishStepCard(mirror.key, {
|
|
3757
|
+
stopped: reason !== 'completed', answerText: text,
|
|
3758
|
+
});
|
|
3759
|
+
mirror.result = { ok: result?.ok === true, text };
|
|
3760
|
+
mirror.finishedAt = Date.now();
|
|
3761
|
+
// Keep the last successful snapshot on failure so recovery can preserve it.
|
|
3762
|
+
if (result?.ok) await this.#state.clearMirror?.(mirror.key);
|
|
3763
|
+
else if (recovering) {
|
|
3764
|
+
this.#sessionSyncTurns.delete(mirror.key);
|
|
3765
|
+
this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors());
|
|
3766
|
+
}
|
|
3767
|
+
return result?.ok === true;
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
async #deliverSessionSyncMirror({ target, sessionId, turn, text }) {
|
|
3771
|
+
if (this.#signal?.aborted) return false;
|
|
3772
|
+
return await this.#queueEventTask(sessionId, async () => {
|
|
3773
|
+
const mirror = this.#sessionSyncTurns.get(this.#mirrorKey(sessionId, turn));
|
|
3774
|
+
if (!mirror || mirror.target.targetId !== target.targetId) return false;
|
|
3775
|
+
return this.#finishSessionSyncMirror(mirror, text);
|
|
3776
|
+
}) === true;
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
async #feedSessionSyncTurn(sessionId, event, imOwned = false) {
|
|
3780
|
+
if (this.#signal?.aborted || imOwned) return;
|
|
3781
|
+
const type = event.type;
|
|
3782
|
+
// Retain recent receipts for coordinator callbacks that lag the event mux.
|
|
3783
|
+
for (const [key, prior] of this.#sessionSyncTurns) {
|
|
3784
|
+
if (prior.finishedAt && Date.now() - prior.finishedAt > 300_000) this.#sessionSyncTurns.delete(key);
|
|
3785
|
+
}
|
|
3786
|
+
let turn = event.data?.turn;
|
|
3787
|
+
if (!Number.isSafeInteger(turn)) turn = this.#sessionSyncCurrentTurns.get(sessionId);
|
|
3788
|
+
if (!Number.isSafeInteger(turn)) return;
|
|
3789
|
+
const key = this.#mirrorKey(sessionId, turn);
|
|
3790
|
+
let mirror = this.#sessionSyncTurns.get(key);
|
|
3791
|
+
if (!mirror) {
|
|
3792
|
+
if (!['turn/start', 'user/message', 'assistant/message', 'tool/call'].includes(type)) return;
|
|
3793
|
+
const targets = await this.#sessionSyncTargetsFor(sessionId);
|
|
3794
|
+
const target = (Array.isArray(targets) ? targets : []).find((item) => item.botId === this.#botId);
|
|
3795
|
+
if (!target?.openId || !target?.targetId || this.#signal?.aborted) return;
|
|
3796
|
+
mirror = { key, sessionId, turn, target, assistant: new AssistantTextAccumulator(), pendingStep: null, lastSeq: -1 };
|
|
3797
|
+
this.#sessionSyncTurns.set(key, mirror);
|
|
3798
|
+
this.#sessionSyncCurrentTurns.set(sessionId, turn);
|
|
3799
|
+
const saved = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
3800
|
+
if (saved?.targetId === target.targetId && saved.chatId === target.openId
|
|
3801
|
+
&& this.#restoreMirrorCard(key, saved)) {
|
|
3802
|
+
mirror.recovered = true;
|
|
3803
|
+
mirror.lastSeq = saved.lastSeq ?? -1;
|
|
3804
|
+
mirror.pendingStep = saved.pendingStep ?? null;
|
|
3805
|
+
}
|
|
3806
|
+
const card = this.#ensureStepCard(key, target.openId, null);
|
|
3807
|
+
Object.assign(card, {
|
|
3808
|
+
deliveryViaOpenId: true, sessionSyncSessionId: sessionId,
|
|
3809
|
+
sessionSyncTargetId: target.targetId, sessionSyncTurn: turn, sessionSyncKey: key,
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3812
|
+
if (mirror.finishedAt || event.seq <= mirror.lastSeq) return;
|
|
3813
|
+
mirror.lastSeq = event.seq;
|
|
3814
|
+
this.#scheduleMirrorCheck(key, () => this.#checkMirror(mirror));
|
|
3815
|
+
const openId = mirror.target.openId;
|
|
3816
|
+
if (type === 'user/message' && event.surfaceOp === 'append') {
|
|
3817
|
+
const text = textFromHarnessContent(event.data?.content);
|
|
3818
|
+
if (text.trim()) {
|
|
3819
|
+
const excerpt = text.length > 400 ? `${text.slice(0, 399)}…` : text;
|
|
3820
|
+
await this.#appendStepCardUpdate(key, openId, null,
|
|
3821
|
+
{ kind: 'message', text: `> 👤 **我问:**${excerpt.replaceAll('\n', '\n> ')}` }, { billable: false });
|
|
3822
|
+
}
|
|
3823
|
+
} else if (type === 'tool/call') {
|
|
3824
|
+
if (mirror.pendingStep) {
|
|
3825
|
+
this.#morphStepCardAnswerToNote(key, mirror.pendingStep);
|
|
3826
|
+
mirror.pendingStep = null;
|
|
3827
|
+
}
|
|
3828
|
+
await this.#appendStepCardUpdate(key, openId, null, this.#stepCardToolBlock({
|
|
3829
|
+
name: event.data?.name ?? '',
|
|
3830
|
+
arguments: typeof event.data?.arguments === 'string' ? event.data.arguments : '',
|
|
3831
|
+
}), { billable: false });
|
|
3832
|
+
} else if (type === 'assistant/message' && event.surfaceOp === 'append' && event.data?.interrupted !== true) {
|
|
3833
|
+
const text = textFromHarnessContent(event.data?.message?.content);
|
|
3834
|
+
if (text.trim()) {
|
|
3835
|
+
mirror.assistant.setCanonical(event.data?.step, text);
|
|
3836
|
+
mirror.pendingStep = text;
|
|
3837
|
+
this.#streamStepCardAnswer(key, openId, null, text);
|
|
3838
|
+
}
|
|
3839
|
+
} else if (type === 'turn/end') {
|
|
3840
|
+
const reason = typeof event.data?.reason === 'string' ? event.data.reason : event.data?.reason?.kind;
|
|
3841
|
+
await this.#finishSessionSyncMirror(mirror, mirror.assistant.text, reason);
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3576
3845
|
#onHarnessEvent({ sessionId, event }) {
|
|
3577
3846
|
if (this.#signal?.aborted
|
|
3578
3847
|
|| !sessionId
|
|
3579
3848
|
|| !event
|
|
3580
3849
|
|| typeof event !== 'object'
|
|
3581
|
-
|| event.type !== 'turn/end'
|
|
3582
3850
|
|| !validEventSeq(event.seq)) return;
|
|
3851
|
+
|
|
3852
|
+
// Session-sync mirror: turns opened OUTSIDE the IM (DSH Web / CLI) are
|
|
3853
|
+
// rendered into the synced DM with the same #stepCards ladder as IM
|
|
3854
|
+
// turns. The mirror consumes EVERY event type (turn/start opens the
|
|
3855
|
+
// card, tool/call and assistant/message feed it, turn/end seals it);
|
|
3856
|
+
// IM-opened turns are skipped — they already own their card via the ask
|
|
3857
|
+
// callbacks. turn/end ALSO continues below for watch completions.
|
|
3858
|
+
if (this.#sessionSyncTargetsFor) {
|
|
3859
|
+
const imOwned = this.#isImTurn(sessionId);
|
|
3860
|
+
void this.#queueEventTask(sessionId, () => this.#feedSessionSyncTurn(sessionId, event, imOwned));
|
|
3861
|
+
if (event.type !== 'turn/end') return;
|
|
3862
|
+
}
|
|
3863
|
+
if (event.type !== 'turn/end') return;
|
|
3583
3864
|
// Record before consulting state: /watch may still be resolving its target
|
|
3584
3865
|
// or waiting for setWatch persistence and therefore have no visible entry.
|
|
3585
3866
|
this.#recordObservedCompletion(sessionId, event);
|
|
@@ -4045,6 +4326,27 @@ export class FeishuHarnessBridge {
|
|
|
4045
4326
|
});
|
|
4046
4327
|
}
|
|
4047
4328
|
|
|
4329
|
+
/**
|
|
4330
|
+
* Persist the mirror state for a session-sync card after a successful
|
|
4331
|
+
* render. Plain IM cards (no sessionSyncSessionId) are never recorded —
|
|
4332
|
+
* their lifecycle is owned by the ask path, not the mirror recovery.
|
|
4333
|
+
* lastContent keeps stepStreamCard's raw JSON string (single-encoded).
|
|
4334
|
+
*/
|
|
4335
|
+
async #persistMirrorState(card, liveBlocks, status) {
|
|
4336
|
+
const sessionId = card.sessionSyncSessionId;
|
|
4337
|
+
if (!sessionId || typeof this.#state.setMirror !== 'function') return;
|
|
4338
|
+
await this.#state.setMirror(card.sessionSyncKey, {
|
|
4339
|
+
sessionId, turn: card.sessionSyncTurn, targetId: card.sessionSyncTargetId,
|
|
4340
|
+
chatId: card.chatId,
|
|
4341
|
+
cardIds: [...card.cardIds],
|
|
4342
|
+
claimedAt: Date.now(),
|
|
4343
|
+
lastContent: stepStreamCard(liveBlocks, { status }),
|
|
4344
|
+
blocks: structuredClone(card.blocks), answerStart: card.answerStart, answerEnd: card.answerEnd,
|
|
4345
|
+
lastSeq: this.#sessionSyncTurns.get(card.sessionSyncKey)?.lastSeq ?? -1,
|
|
4346
|
+
pendingStep: this.#sessionSyncTurns.get(card.sessionSyncKey)?.pendingStep ?? null,
|
|
4347
|
+
});
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4048
4350
|
async #renderStepCardNow(chatId, card) {
|
|
4049
4351
|
if (card.broken) return;
|
|
4050
4352
|
const chunks = splitStepStreamCardBlocks(card.blocks);
|
|
@@ -4061,12 +4363,15 @@ export class FeishuHarnessBridge {
|
|
|
4061
4363
|
const id = await this.#sendCard(
|
|
4062
4364
|
chatId,
|
|
4063
4365
|
stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
|
|
4064
|
-
|
|
4366
|
+
card.deliveryViaOpenId
|
|
4367
|
+
? { receiveIdType: 'open_id' }
|
|
4368
|
+
: { replyTo: card.replyToMessageId },
|
|
4065
4369
|
);
|
|
4066
4370
|
card.cardIds.push(id);
|
|
4067
4371
|
if (isLive) card.messageId = id;
|
|
4068
4372
|
}
|
|
4069
4373
|
card.chunkCount = chunks.length;
|
|
4374
|
+
await this.#persistMirrorState(card, live, 'running');
|
|
4070
4375
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4071
4376
|
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4072
4377
|
return;
|
|
@@ -4084,7 +4389,9 @@ export class FeishuHarnessBridge {
|
|
|
4084
4389
|
const id = await this.#sendCard(
|
|
4085
4390
|
chatId,
|
|
4086
4391
|
stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
|
|
4087
|
-
|
|
4392
|
+
card.deliveryViaOpenId
|
|
4393
|
+
? { receiveIdType: 'open_id' }
|
|
4394
|
+
: { replyTo: card.replyToMessageId },
|
|
4088
4395
|
);
|
|
4089
4396
|
card.cardIds.push(id);
|
|
4090
4397
|
if (isLive) card.messageId = id;
|
|
@@ -4093,6 +4400,7 @@ export class FeishuHarnessBridge {
|
|
|
4093
4400
|
} else {
|
|
4094
4401
|
await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
|
|
4095
4402
|
}
|
|
4403
|
+
await this.#persistMirrorState(card, live, 'running');
|
|
4096
4404
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4097
4405
|
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4098
4406
|
} catch (error) {
|
|
@@ -4101,6 +4409,10 @@ export class FeishuHarnessBridge {
|
|
|
4101
4409
|
'[dsh-feishu] step streaming card render failed; the turn continues without it:',
|
|
4102
4410
|
error?.message ?? String(error),
|
|
4103
4411
|
);
|
|
4412
|
+
if (card.deliveryViaOpenId) {
|
|
4413
|
+
this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
|
|
4414
|
+
error?.message ?? String(error));
|
|
4415
|
+
}
|
|
4104
4416
|
}
|
|
4105
4417
|
}
|
|
4106
4418
|
|
|
@@ -4141,7 +4453,9 @@ export class FeishuHarnessBridge {
|
|
|
4141
4453
|
const id = await this.#sendCard(
|
|
4142
4454
|
card.chatId,
|
|
4143
4455
|
stepStreamCard(groups[index], { status: isLive ? status : 'sealed' }),
|
|
4144
|
-
|
|
4456
|
+
card.deliveryViaOpenId
|
|
4457
|
+
? { receiveIdType: 'open_id' }
|
|
4458
|
+
: { replyTo: card.replyToMessageId },
|
|
4145
4459
|
);
|
|
4146
4460
|
card.cardIds.push(id);
|
|
4147
4461
|
}
|
|
@@ -4161,7 +4475,9 @@ export class FeishuHarnessBridge {
|
|
|
4161
4475
|
const id = await this.#sendCard(
|
|
4162
4476
|
card.chatId,
|
|
4163
4477
|
stepStreamCard(chunks[index], { status: isLast ? status : 'sealed' }),
|
|
4164
|
-
|
|
4478
|
+
card.deliveryViaOpenId
|
|
4479
|
+
? { receiveIdType: 'open_id' }
|
|
4480
|
+
: { replyTo: card.replyToMessageId },
|
|
4165
4481
|
);
|
|
4166
4482
|
card.cardIds.push(id);
|
|
4167
4483
|
if (isLast) card.messageId = id;
|
|
@@ -4499,6 +4815,7 @@ export class FeishuHarnessBridge {
|
|
|
4499
4815
|
* (工具参数折叠为代码块);post 失败走既有纯文本降级。
|
|
4500
4816
|
*/
|
|
4501
4817
|
async #answerWithStepPush(event, key, message, { onAskComplete } = {}) {
|
|
4818
|
+
this.#beginImTurn(key);
|
|
4502
4819
|
const chatId = event.message.chat_id;
|
|
4503
4820
|
const messageId = event.message.message_id;
|
|
4504
4821
|
const text = message.content;
|
|
@@ -4506,6 +4823,7 @@ export class FeishuHarnessBridge {
|
|
|
4506
4823
|
const markAskComplete = () => {
|
|
4507
4824
|
if (askCompleted) return;
|
|
4508
4825
|
askCompleted = true;
|
|
4826
|
+
this.#endImTurn(key);
|
|
4509
4827
|
onAskComplete?.();
|
|
4510
4828
|
};
|
|
4511
4829
|
// 与流式分支一致的提示内容构造:图片与回复引用展开为富提示内容,已接受
|
|
@@ -4811,6 +5129,7 @@ export class FeishuHarnessBridge {
|
|
|
4811
5129
|
}
|
|
4812
5130
|
|
|
4813
5131
|
async #answerWithStream(event, key, message, { onAskComplete } = {}) {
|
|
5132
|
+
this.#beginImTurn(key);
|
|
4814
5133
|
const chatId = event.message.chat_id;
|
|
4815
5134
|
const messageId = event.message.message_id;
|
|
4816
5135
|
const text = message.content;
|
|
@@ -4818,6 +5137,7 @@ export class FeishuHarnessBridge {
|
|
|
4818
5137
|
const markAskComplete = () => {
|
|
4819
5138
|
if (askCompleted) return;
|
|
4820
5139
|
askCompleted = true;
|
|
5140
|
+
this.#endImTurn(key);
|
|
4821
5141
|
onAskComplete?.();
|
|
4822
5142
|
};
|
|
4823
5143
|
// 分步直推:开关开启且通道支持流式卡时,在构造提示内容之前分流到完整替
|
|
@@ -1003,7 +1003,7 @@ function stepPanel(lines, { title, expanded }) {
|
|
|
1003
1003
|
};
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
1006
|
-
function stepStatusText(status) {
|
|
1006
|
+
export function stepStatusText(status) {
|
|
1007
1007
|
if (status === 'completed') return t('已完成');
|
|
1008
1008
|
if (status === 'stopped') return t('已停止');
|
|
1009
1009
|
return t('运行中');
|
|
@@ -114,6 +114,7 @@ export class FeishuRuntime {
|
|
|
114
114
|
#groupTopicReply;
|
|
115
115
|
#stepPush;
|
|
116
116
|
#stepPushMode;
|
|
117
|
+
#sessionSyncTargetsFor;
|
|
117
118
|
#ownerOpenIds;
|
|
118
119
|
#harness;
|
|
119
120
|
#state;
|
|
@@ -146,6 +147,7 @@ export class FeishuRuntime {
|
|
|
146
147
|
groupTopicReply = false,
|
|
147
148
|
stepPush = false,
|
|
148
149
|
stepPushMode = 'post',
|
|
150
|
+
sessionSyncTargetsFor = null,
|
|
149
151
|
ownerOpenId,
|
|
150
152
|
ownerOpenIds,
|
|
151
153
|
harness,
|
|
@@ -184,6 +186,9 @@ export class FeishuRuntime {
|
|
|
184
186
|
this.#groupTopicReply = groupTopicReply === true;
|
|
185
187
|
this.#stepPush = stepPush === true;
|
|
186
188
|
this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
|
|
189
|
+
this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
|
|
190
|
+
? sessionSyncTargetsFor
|
|
191
|
+
: null;
|
|
187
192
|
this.#ownerOpenIds = normalizedOwners;
|
|
188
193
|
this.#harness = harness;
|
|
189
194
|
this.#state = state;
|
|
@@ -314,6 +319,7 @@ export class FeishuRuntime {
|
|
|
314
319
|
groupTopicReply: this.#groupTopicReply,
|
|
315
320
|
stepPush: this.#stepPush,
|
|
316
321
|
stepPushMode: this.#stepPushMode,
|
|
322
|
+
sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
|
|
317
323
|
repair: this.#repair,
|
|
318
324
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
319
325
|
// Interaction cards (approval/question buttons) are on by default.
|
|
@@ -10,6 +10,7 @@ const EMPTY_STATE = Object.freeze({
|
|
|
10
10
|
deferred: {},
|
|
11
11
|
includeArchivedSessions: false,
|
|
12
12
|
topics: {},
|
|
13
|
+
mirrors: {},
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
/** One conversation key may watch at most this many sessions. */
|
|
@@ -46,6 +47,7 @@ export class StateStore {
|
|
|
46
47
|
? parsed.includeArchivedSessions
|
|
47
48
|
: false,
|
|
48
49
|
topics: parsed.topics && typeof parsed.topics === 'object' ? parsed.topics : {},
|
|
50
|
+
mirrors: parsed.mirrors && typeof parsed.mirrors === 'object' ? parsed.mirrors : {},
|
|
49
51
|
};
|
|
50
52
|
} catch (error) {
|
|
51
53
|
if (error?.code !== 'ENOENT') throw error;
|
|
@@ -59,6 +61,21 @@ export class StateStore {
|
|
|
59
61
|
patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
|
|
60
62
|
removeDeferred(id) { return this.#deferred.remove(id); }
|
|
61
63
|
|
|
64
|
+
// ── Mirrors (persisted: open session-sync cards, recovered at startup) ──
|
|
65
|
+
setMirror(sessionId, entry) {
|
|
66
|
+
this.#state.mirrors[sessionId] = entry;
|
|
67
|
+
return this.#persist();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
clearMirror(sessionId) {
|
|
71
|
+
delete this.#state.mirrors[sessionId];
|
|
72
|
+
return this.#persist();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
mirrorEntries() {
|
|
76
|
+
return Object.entries(this.#state.mirrors ?? {});
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
sessionFor(key) {
|
|
63
80
|
return this.#state.sessions[key] ?? null;
|
|
64
81
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const SET_ALIAS_ENDPOINT = 'bot.alias.set';
|
|
2
|
+
export const MAX_BOT_ALIAS_LENGTH = 80;
|
|
3
|
+
|
|
4
|
+
export function validateBotAlias(value) {
|
|
5
|
+
if (typeof value !== 'string' || value.trim().length > MAX_BOT_ALIAS_LENGTH
|
|
6
|
+
|| /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
7
|
+
throw new TypeError('别名不能包含换行或控制字符,且最多 80 个字符。');
|
|
8
|
+
}
|
|
9
|
+
return value.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeBotAlias(bot) {
|
|
13
|
+
try {
|
|
14
|
+
const alias = validateBotAlias(bot?.alias);
|
|
15
|
+
return alias && typeof bot.originalName === 'string'
|
|
16
|
+
? { alias, originalName: bot.originalName }
|
|
17
|
+
: {};
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function withBotAlias(bot, alias) {
|
|
24
|
+
if (!bot) return bot;
|
|
25
|
+
const { originalName = bot.name, alias: _alias, ...rest } = bot;
|
|
26
|
+
return alias
|
|
27
|
+
? { ...rest, originalName, alias, name: alias }
|
|
28
|
+
: { ...rest, name: originalName };
|
|
29
|
+
}
|