@xmanrui/dsh-im 4.18.1 → 4.19.0
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 +805 -515
- package/lib/index.js +269 -267
- package/package.json +1 -1
- package/plugin-src/client/bot-alias.js +92 -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 +26 -0
- 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 +19 -1
- package/src/channels/feishu/bridge.mjs +370 -7
- 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 +34 -0
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
validHarnessQuestion,
|
|
29
29
|
} from '../shared/harness-question.mjs';
|
|
30
30
|
import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
|
|
31
|
+
import { textFromHarnessContent } from '../shared/harness-client.mjs';
|
|
32
|
+
import { hasActiveHarnessInteractionOwner } from '../shared/harness-client.mjs';
|
|
33
|
+
import {
|
|
34
|
+
claimSessionSyncMirror,
|
|
35
|
+
releaseSessionSyncMirror,
|
|
36
|
+
} from '../shared/session-sync-registry.mjs';
|
|
31
37
|
import {
|
|
32
38
|
BatchInputManager,
|
|
33
39
|
batchInputBusyMessage,
|
|
@@ -96,6 +102,7 @@ import {
|
|
|
96
102
|
steerCard,
|
|
97
103
|
watchListCard,
|
|
98
104
|
workspaceListCard,
|
|
105
|
+
stepStatusText,
|
|
99
106
|
} from './feishu-cards.mjs';
|
|
100
107
|
import { t } from '../shared/i18n.mjs';
|
|
101
108
|
import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
|
|
@@ -188,6 +195,8 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
|
|
|
188
195
|
/** Streaming-card mode coalesces card renders behind one PATCH per interval —
|
|
189
196
|
* patching the same message is far more rate sensitive than posting. */
|
|
190
197
|
const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
|
|
198
|
+
/** Mux doesn't forward turn/end; this idle gap seals the mirror card. */
|
|
199
|
+
const MIRROR_IDLE_SEAL_MS = 90_000;
|
|
191
200
|
/** One answer chunk inside the streaming card: small enough that the block
|
|
192
201
|
* splitter can always distribute blocks across sealed/live cards. */
|
|
193
202
|
const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
|
|
@@ -633,6 +642,19 @@ export class FeishuHarnessBridge {
|
|
|
633
642
|
#observedCompletionEvents = new Map();
|
|
634
643
|
/** Earliest completion that still needs delivery for each watch. */
|
|
635
644
|
#failedWatchSeqs = new Map();
|
|
645
|
+
/** Host resolver: sessionId -> synced DM targets [{ openId, botId }]. */
|
|
646
|
+
#sessionSyncTargetsFor = null;
|
|
647
|
+
/** sessionId -> openId for live session-sync mirrors. */
|
|
648
|
+
#sessionSyncTargets = new Map();
|
|
649
|
+
/** In-flight adopt lookups, deduped per session. */
|
|
650
|
+
#sessionSyncAdopting = new Set();
|
|
651
|
+
/** Latest interim assistant text per mirrored session (folded on tool). */
|
|
652
|
+
#sessionSyncPendingStep = new Map();
|
|
653
|
+
/** Idle-seal timers: no events for a while = the turn ended (mux may
|
|
654
|
+
* not forward turn/end), so seal the mirror card with what it has. */
|
|
655
|
+
#sessionSyncIdleTimers = new Map();
|
|
656
|
+
/** Conversation keys with an IM ask in flight (set BEFORE the turn starts). */
|
|
657
|
+
#imTurnKeys = new Set();
|
|
636
658
|
#cardDataTimeoutMs;
|
|
637
659
|
/** When true, approval/question interactions render as Feishu cards (buttons). */
|
|
638
660
|
#interactionCards = true;
|
|
@@ -660,6 +682,7 @@ export class FeishuHarnessBridge {
|
|
|
660
682
|
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
|
|
661
683
|
replyTimeoutMs = 600_000,
|
|
662
684
|
interactionCards = true,
|
|
685
|
+
sessionSyncTargetsFor = null,
|
|
663
686
|
logger = console,
|
|
664
687
|
signal,
|
|
665
688
|
}) {
|
|
@@ -709,6 +732,9 @@ export class FeishuHarnessBridge {
|
|
|
709
732
|
this.#cardDataTimeoutMs = cardDataTimeoutMs;
|
|
710
733
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
711
734
|
this.#interactionCards = interactionCards === true;
|
|
735
|
+
this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
|
|
736
|
+
? sessionSyncTargetsFor
|
|
737
|
+
: null;
|
|
712
738
|
this.#logger = logger;
|
|
713
739
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
|
714
740
|
this.#signal = signal;
|
|
@@ -722,10 +748,85 @@ export class FeishuHarnessBridge {
|
|
|
722
748
|
if (typeof this.#harness?.watchHarnessEvents === 'function') {
|
|
723
749
|
queueMicrotask(() => {
|
|
724
750
|
this.#ensureEventWatcher();
|
|
751
|
+
// Delay the recovery: a restart lands while healthy turns may still
|
|
752
|
+
// be streaming; sealing them at t=0 would wipe live cards. 90s gives
|
|
753
|
+
// the turn's own events a window to re-adopt and finish normally.
|
|
754
|
+
setTimeout(() => { void this.#sealOrphanMirrors(); }, 90_000);
|
|
725
755
|
});
|
|
726
756
|
}
|
|
727
757
|
}
|
|
728
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Seal mirrors left running by a previous process: a restart kills the
|
|
761
|
+
* turn without a turn/end event, so the persisted card would stay
|
|
762
|
+
* "running" forever. Mark each orphan sealed (stopped) on delivery.
|
|
763
|
+
*/
|
|
764
|
+
async #sealOrphanMirrors() {
|
|
765
|
+
const entries = typeof this.#state.mirrorEntries === 'function'
|
|
766
|
+
? this.#state.mirrorEntries()
|
|
767
|
+
: [];
|
|
768
|
+
// Only claim cards older than the threshold: a restart lands while a
|
|
769
|
+
// healthy turn may still be streaming, and sealing it would wipe the
|
|
770
|
+
// live card. Anything older than a turn could plausibly run is orphaned.
|
|
771
|
+
const ORPHAN_AFTER_MS = 3 * 60_000;
|
|
772
|
+
for (const [sessionId, entry] of entries) {
|
|
773
|
+
if (!entry?.chatId || !Array.isArray(entry.cardIds)) continue;
|
|
774
|
+
if (typeof entry.claimedAt === 'number' && Date.now() - entry.claimedAt < ORPHAN_AFTER_MS) {
|
|
775
|
+
this.#logger.warn?.('[dsh-feishu] mirror entry too fresh to be orphaned; leaving untouched:', sessionId);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
// The live card's last delivered content is persisted with the mirror:
|
|
779
|
+
// re-patch the live card with a stopped status line, keeping every
|
|
780
|
+
// panel intact. Sealed earlier chunks already carry no status line and
|
|
781
|
+
// keep their original content untouched.
|
|
782
|
+
let sealContent = null;
|
|
783
|
+
if (typeof entry.lastContent === 'string' && entry.lastContent) {
|
|
784
|
+
try {
|
|
785
|
+
const parsed = JSON.parse(entry.lastContent);
|
|
786
|
+
const elements = parsed?.body?.elements;
|
|
787
|
+
if (Array.isArray(elements) && elements.length > 0) {
|
|
788
|
+
const last = elements[elements.length - 1];
|
|
789
|
+
if (last?.tag === 'markdown' && typeof last.content === 'string'
|
|
790
|
+
&& last.content.startsWith('_') && last.content.endsWith('_')) {
|
|
791
|
+
last.content = `_${stepStatusText('stopped')}_`;
|
|
792
|
+
} else {
|
|
793
|
+
elements.push({ tag: 'markdown', content: `_${stepStatusText('stopped')}_` });
|
|
794
|
+
}
|
|
795
|
+
sealContent = parsed;
|
|
796
|
+
}
|
|
797
|
+
} catch { /* fall through to an empty stopped card */ }
|
|
798
|
+
}
|
|
799
|
+
for (let index = 0; index < entry.cardIds.length; index += 1) {
|
|
800
|
+
const content = index === entry.cardIds.length - 1 && sealContent
|
|
801
|
+
? JSON.stringify(sealContent)
|
|
802
|
+
: null;
|
|
803
|
+
if (content === null) continue;
|
|
804
|
+
await this.#patchStepCard(entry.cardIds[index], content)
|
|
805
|
+
.catch((error) => {
|
|
806
|
+
this.#logger.warn?.('[dsh-feishu] orphan mirror seal failed:', error?.message ?? error);
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
for (let index = 0; index < entry.cardIds.length; index += 1) {
|
|
810
|
+
const isLive = index === entry.cardIds.length - 1;
|
|
811
|
+
const content = isLive
|
|
812
|
+
? (sealContent
|
|
813
|
+
? JSON.stringify({ ...sealContent, data: JSON.stringify({
|
|
814
|
+
...(JSON.parse(entry.lastContent).data ? JSON.parse(entry.lastContent).data : {}),
|
|
815
|
+
}) })
|
|
816
|
+
: stepStreamCard([], { status: 'stopped' }))
|
|
817
|
+
: stepStreamCard([], { status: 'stopped' });
|
|
818
|
+
await this.#patchStepCard(entry.cardIds[index], content)
|
|
819
|
+
.catch((error) => {
|
|
820
|
+
this.#logger.warn?.('[dsh-feishu] orphan mirror seal failed:', error?.message ?? error);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
await this.#state.clearMirror?.(sessionId);
|
|
824
|
+
this.#logger.warn?.(
|
|
825
|
+
`[dsh-feishu] sealed ${entry.cardIds.length} orphan mirror card(s) for ${sessionId}`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
729
830
|
setGroupResponseMode(value) {
|
|
730
831
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
731
832
|
}
|
|
@@ -2704,6 +2805,25 @@ export class FeishuHarnessBridge {
|
|
|
2704
2805
|
const updateMessageId = nonEmptyString(options.updateMessageId);
|
|
2705
2806
|
const replyTo = nonEmptyString(options.replyTo);
|
|
2706
2807
|
|
|
2808
|
+
// Session-sync cards target the user's openId (the synced DM is a user,
|
|
2809
|
+
// not a chat), delivered fresh without topic/thread handling.
|
|
2810
|
+
if (options.receiveIdType === 'open_id') {
|
|
2811
|
+
const response = await this.#client.im.v1.message.create({
|
|
2812
|
+
params: { receive_id_type: 'open_id' },
|
|
2813
|
+
data: {
|
|
2814
|
+
receive_id: chatId,
|
|
2815
|
+
msg_type: 'interactive',
|
|
2816
|
+
content: cardJson,
|
|
2817
|
+
},
|
|
2818
|
+
});
|
|
2819
|
+
if (response?.code && response.code !== 0) {
|
|
2820
|
+
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
|
2821
|
+
}
|
|
2822
|
+
const sentId = nonEmptyString(response?.data?.message_id);
|
|
2823
|
+
if (!sentId) throw new Error('Feishu card send returned no message_id');
|
|
2824
|
+
return sentId;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2707
2827
|
if (updateMessageId) {
|
|
2708
2828
|
try {
|
|
2709
2829
|
const response = await this.#client.im.v1.message.patch({
|
|
@@ -3224,13 +3344,19 @@ export class FeishuHarnessBridge {
|
|
|
3224
3344
|
|
|
3225
3345
|
#ensureEventWatcher() {
|
|
3226
3346
|
if (this.#eventWatcher) return;
|
|
3227
|
-
if (typeof this.#harness?.watchHarnessEvents !== 'function')
|
|
3347
|
+
if (typeof this.#harness?.watchHarnessEvents !== 'function') {
|
|
3348
|
+
this.#logger.warn?.('[dsh-feishu] harness lacks watchHarnessEvents; session-sync mirror disabled');
|
|
3349
|
+
return;
|
|
3350
|
+
}
|
|
3228
3351
|
if (this.#signal?.aborted) return;
|
|
3229
3352
|
const signal = this.#signal ?? new AbortController().signal;
|
|
3230
3353
|
try {
|
|
3231
3354
|
this.#eventWatcher = this.#harness.watchHarnessEvents({
|
|
3232
3355
|
signal,
|
|
3233
|
-
onSessionEvent: (payload) =>
|
|
3356
|
+
onSessionEvent: (payload) => {
|
|
3357
|
+
console.error('[ss-final] mux:', payload?.sessionId?.slice(-12), payload?.event?.type);
|
|
3358
|
+
this.#onHarnessEvent(payload);
|
|
3359
|
+
},
|
|
3234
3360
|
onReconnect: () => {
|
|
3235
3361
|
void this.#compensateMissedEvents();
|
|
3236
3362
|
void this.#deferred.resume();
|
|
@@ -3573,13 +3699,206 @@ export class FeishuHarnessBridge {
|
|
|
3573
3699
|
}
|
|
3574
3700
|
|
|
3575
3701
|
/** Queue live turn completions behind any reconnect compensation. */
|
|
3702
|
+
/**
|
|
3703
|
+
* Mirror one Harness session event into the session-sync process card for
|
|
3704
|
+
* this session (a Web/CLI-initiated turn with a synced DM target). Events
|
|
3705
|
+
* are translated into the same update shapes the ask callbacks produce, so
|
|
3706
|
+
* the regular #stepCards ladder renders them identically: tool/call ->
|
|
3707
|
+
* tool block, assistant/message -> live answer draft, turn/end -> sealed
|
|
3708
|
+
* terminal card with the final answer.
|
|
3709
|
+
*/
|
|
3710
|
+
/** True when this session's running turn was opened by one of OUR asks. */
|
|
3711
|
+
#isImTurn(sessionId) {
|
|
3712
|
+
for (const imKey of this.#imTurnKeys) {
|
|
3713
|
+
if (this.#state.sessionFor?.(imKey) === sessionId) return true;
|
|
3714
|
+
}
|
|
3715
|
+
return false;
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
#beginImTurn(key) {
|
|
3719
|
+
this.#imTurnKeys.add(key);
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
#endImTurn(key) {
|
|
3723
|
+
this.#imTurnKeys.delete(key);
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
/**
|
|
3727
|
+
* The session-event mux forwards surfaced events (user/tool/assistant) but
|
|
3728
|
+
* NOT turn/start|turn/end, so the mirror cannot observe the turn boundary
|
|
3729
|
+
* directly. Instead, arm an idle timer on every event: when no event
|
|
3730
|
+
* arrives for MIRROR_IDLE_SEAL_MS, the turn is over — seal the card with
|
|
3731
|
+
* its current content (answer draft included) as completed.
|
|
3732
|
+
*/
|
|
3733
|
+
#armSessionSyncIdleTimer(sessionId, key, openId) {
|
|
3734
|
+
const previous = this.#sessionSyncIdleTimers.get(sessionId);
|
|
3735
|
+
if (previous) clearTimeout(previous);
|
|
3736
|
+
const timer = setTimeout(() => {
|
|
3737
|
+
this.#sessionSyncIdleTimers.delete(sessionId);
|
|
3738
|
+
const card = this.#stepCards.get(key);
|
|
3739
|
+
if (!card || card.broken) return;
|
|
3740
|
+
void this.#finishStepCard(key, { stopped: false, answerText: null })
|
|
3741
|
+
.then(() => this.#state.clearMirror?.(sessionId))
|
|
3742
|
+
.catch((error) => {
|
|
3743
|
+
console.error('[ss-final] idle seal failed:', error?.message ?? error);
|
|
3744
|
+
});
|
|
3745
|
+
}, MIRROR_IDLE_SEAL_MS);
|
|
3746
|
+
this.#sessionSyncIdleTimers.set(sessionId, timer);
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
async #feedSessionSyncTurn(sessionId, event) {
|
|
3750
|
+
if (this.#signal?.aborted) return;
|
|
3751
|
+
const key = `session-sync\0${sessionId}`;
|
|
3752
|
+
const type = event?.type;
|
|
3753
|
+
const target = this.#sessionSyncTargets.get(sessionId);
|
|
3754
|
+
const openId = typeof target === 'string' ? target : target?.openId;
|
|
3755
|
+
this.#armSessionSyncIdleTimer(sessionId, key, openId);
|
|
3756
|
+
|
|
3757
|
+
if (type === 'turn/start') {
|
|
3758
|
+
if (this.#stepCards.has(key)) {
|
|
3759
|
+
return;
|
|
3760
|
+
}
|
|
3761
|
+
if (this.#isImTurn(sessionId)) {
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
const targets = await this.#sessionSyncTargetsFor?.(sessionId);
|
|
3765
|
+
const owned = (Array.isArray(targets) ? targets : [])
|
|
3766
|
+
.find((target) => target.botId === this.#botId);
|
|
3767
|
+
if (!owned?.openId) {
|
|
3768
|
+
return;
|
|
3769
|
+
}
|
|
3770
|
+
this.#sessionSyncTargets.set(sessionId, owned);
|
|
3771
|
+
// chatId carries the openId; #sendCard branches on the delivery marker.
|
|
3772
|
+
this.#ensureStepCard(key, owned.openId, null);
|
|
3773
|
+
this.#stepCards.get(key).deliveryViaOpenId = true;
|
|
3774
|
+
this.#stepCards.get(key).sessionSyncSessionId = sessionId;
|
|
3775
|
+
this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
|
|
3776
|
+
// Persist the mirror so a restart can seal an orphaned running card.
|
|
3777
|
+
await this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
|
|
3778
|
+
// Claim only THIS target: the coordinator suppresses its plain text for
|
|
3779
|
+
// the mirrored target while other synced targets keep their delivery.
|
|
3780
|
+
claimSessionSyncMirror(sessionId, owned.targetId ?? '');
|
|
3781
|
+
return;
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3784
|
+
const card = this.#stepCards.get(key);
|
|
3785
|
+
if (!card) {
|
|
3786
|
+
// The bridge (re)started mid-turn: adopt the running turn on its first
|
|
3787
|
+
// visible event so the mirror still renders from here on.
|
|
3788
|
+
if ((type === 'assistant/message' || type === 'tool/call')
|
|
3789
|
+
&& !this.#sessionSyncAdopting.has(sessionId)
|
|
3790
|
+
&& !this.#isImTurn(sessionId)) {
|
|
3791
|
+
this.#sessionSyncAdopting.add(sessionId);
|
|
3792
|
+
Promise.resolve()
|
|
3793
|
+
.then(() => this.#sessionSyncTargetsFor?.(sessionId))
|
|
3794
|
+
.then((targets) => {
|
|
3795
|
+
const owned = (Array.isArray(targets) ? targets : [])
|
|
3796
|
+
.find((target) => target.botId === this.#botId);
|
|
3797
|
+
if (!owned?.openId) return null;
|
|
3798
|
+
this.#sessionSyncTargets.set(sessionId, owned);
|
|
3799
|
+
// Adopt = open the mirror card NOW, then handle this event.
|
|
3800
|
+
this.#ensureStepCard(key, owned.openId, null);
|
|
3801
|
+
this.#stepCards.get(key).deliveryViaOpenId = true;
|
|
3802
|
+
this.#stepCards.get(key).sessionSyncSessionId = sessionId;
|
|
3803
|
+
this.#stepCards.get(key).sessionSyncTargetId = owned.targetId ?? '';
|
|
3804
|
+
void this.#state.setMirror?.(sessionId, { chatId: owned.openId, targetId: owned.targetId ?? '', cardIds: [], claimedAt: Date.now() });
|
|
3805
|
+
claimSessionSyncMirror(sessionId, owned.targetId ?? '');
|
|
3806
|
+
return this.#feedSessionSyncTurn(sessionId, event);
|
|
3807
|
+
})
|
|
3808
|
+
.catch((error) => {
|
|
3809
|
+
this.#logger.warn?.('[dsh-feishu] session-sync adopt failed:', error?.message ?? error);
|
|
3810
|
+
})
|
|
3811
|
+
.finally(() => this.#sessionSyncAdopting.delete(sessionId));
|
|
3812
|
+
}
|
|
3813
|
+
return;
|
|
3814
|
+
}
|
|
3815
|
+
// A broken card must not swallow the turn boundary: turn/end still needs
|
|
3816
|
+
// to release the mirror claim and clear the state so later turns and the
|
|
3817
|
+
// plain-text fallback work again.
|
|
3818
|
+
if (card.broken && type !== 'turn/end') return;
|
|
3819
|
+
|
|
3820
|
+
if (type === 'user/message' && event?.surfaceOp === 'append') {
|
|
3821
|
+
// The coordinator's plain-text user echo is suppressed for mirrored
|
|
3822
|
+
// turns, so the card carries the question itself: a quoted block at
|
|
3823
|
+
// the top keeps the DM self-contained and readable in history.
|
|
3824
|
+
const text = textFromHarnessContent(event?.data?.content);
|
|
3825
|
+
if (text.trim()) {
|
|
3826
|
+
const excerpt = text.length > 400 ? `${text.slice(0, 399)}…` : text;
|
|
3827
|
+
await this.#appendStepCardUpdate(
|
|
3828
|
+
key, openId, null,
|
|
3829
|
+
{ kind: 'message', text: `> 👤 **我问:**${excerpt.replaceAll('\n', '\n> ')}` },
|
|
3830
|
+
{ billable: false },
|
|
3831
|
+
);
|
|
3832
|
+
}
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
if (type === 'tool/call') {
|
|
3836
|
+
// Align with the ask-callback semantics: a draft proven interim by a
|
|
3837
|
+
// tool call folds into the thinking panel instead of being overwritten
|
|
3838
|
+
// by the next draft.
|
|
3839
|
+
if (this.#sessionSyncPendingStep.has(sessionId)) {
|
|
3840
|
+
this.#morphStepCardAnswerToNote(key, this.#sessionSyncPendingStep.get(sessionId));
|
|
3841
|
+
this.#sessionSyncPendingStep.delete(sessionId);
|
|
3842
|
+
}
|
|
3843
|
+
await this.#appendStepCardUpdate(
|
|
3844
|
+
key, openId, null,
|
|
3845
|
+
this.#stepCardToolBlock({
|
|
3846
|
+
name: event?.data?.name ?? '',
|
|
3847
|
+
arguments: typeof event?.data?.arguments === 'string' ? event.data.arguments : '',
|
|
3848
|
+
}),
|
|
3849
|
+
{ billable: false },
|
|
3850
|
+
);
|
|
3851
|
+
return;
|
|
3852
|
+
}
|
|
3853
|
+
if (type === 'assistant/message') {
|
|
3854
|
+
const text = textFromHarnessContent(event?.data?.message?.content);
|
|
3855
|
+
if (text.trim()) {
|
|
3856
|
+
this.#sessionSyncPendingStep.set(sessionId, text);
|
|
3857
|
+
this.#streamStepCardAnswer(key, openId, null, text);
|
|
3858
|
+
}
|
|
3859
|
+
return;
|
|
3860
|
+
}
|
|
3861
|
+
if (type === 'turn/end') {
|
|
3862
|
+
this.#sessionSyncTargets.delete(sessionId);
|
|
3863
|
+
this.#sessionSyncPendingStep.delete(sessionId);
|
|
3864
|
+
releaseSessionSyncMirror(sessionId, card?.sessionSyncTargetId ?? '');
|
|
3865
|
+
await this.#finishStepCard(key, {
|
|
3866
|
+
stopped: event?.data?.reason?.kind === 'aborted',
|
|
3867
|
+
answerText: null,
|
|
3868
|
+
});
|
|
3869
|
+
// Clear AFTER the seal: the render chain may still write mirror state
|
|
3870
|
+
// while it finishes, so clearing earlier would be resurrected by the
|
|
3871
|
+
// trailing setMirror from the last successful render.
|
|
3872
|
+
await this.#state.clearMirror?.(sessionId);
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3576
3877
|
#onHarnessEvent({ sessionId, event }) {
|
|
3577
3878
|
if (this.#signal?.aborted
|
|
3578
3879
|
|| !sessionId
|
|
3579
3880
|
|| !event
|
|
3580
3881
|
|| typeof event !== 'object'
|
|
3581
|
-
|| event.type !== 'turn/end'
|
|
3582
3882
|
|| !validEventSeq(event.seq)) return;
|
|
3883
|
+
|
|
3884
|
+
// Session-sync mirror: turns opened OUTSIDE the IM (DSH Web / CLI) are
|
|
3885
|
+
// rendered into the synced DM with the same #stepCards ladder as IM
|
|
3886
|
+
// turns. The mirror consumes EVERY event type (turn/start opens the
|
|
3887
|
+
// card, tool/call and assistant/message feed it, turn/end seals it);
|
|
3888
|
+
// IM-opened turns are skipped — they already own their card via the ask
|
|
3889
|
+
// callbacks. turn/end ALSO continues below for watch completions.
|
|
3890
|
+
if (this.#sessionSyncTargetsFor) {
|
|
3891
|
+
if (event.type === 'turn/end') {
|
|
3892
|
+
console.error('[ss-final] turn/end reached dispatcher:', sessionId);
|
|
3893
|
+
}
|
|
3894
|
+
void this.#queueEventTask(`session-sync\0${sessionId}`, async () => {
|
|
3895
|
+
await this.#feedSessionSyncTurn(sessionId, event);
|
|
3896
|
+
}).catch((error) => {
|
|
3897
|
+
console.error('[ss-final] mirror task failed:', event?.type, error?.message ?? error);
|
|
3898
|
+
});
|
|
3899
|
+
if (event.type !== 'turn/end') return;
|
|
3900
|
+
}
|
|
3901
|
+
if (event.type !== 'turn/end') return;
|
|
3583
3902
|
// Record before consulting state: /watch may still be resolving its target
|
|
3584
3903
|
// or waiting for setWatch persistence and therefore have no visible entry.
|
|
3585
3904
|
this.#recordObservedCompletion(sessionId, event);
|
|
@@ -4045,6 +4364,23 @@ export class FeishuHarnessBridge {
|
|
|
4045
4364
|
});
|
|
4046
4365
|
}
|
|
4047
4366
|
|
|
4367
|
+
/**
|
|
4368
|
+
* Persist the mirror state for a session-sync card after a successful
|
|
4369
|
+
* render. Plain IM cards (no sessionSyncSessionId) are never recorded —
|
|
4370
|
+
* their lifecycle is owned by the ask path, not the mirror recovery.
|
|
4371
|
+
* lastContent keeps stepStreamCard's raw JSON string (single-encoded).
|
|
4372
|
+
*/
|
|
4373
|
+
#persistMirrorState(card, liveBlocks, status) {
|
|
4374
|
+
const sessionId = card.sessionSyncSessionId;
|
|
4375
|
+
if (!sessionId || typeof this.#state.setMirror !== 'function') return;
|
|
4376
|
+
void this.#state.setMirror(sessionId, {
|
|
4377
|
+
chatId: card.chatId,
|
|
4378
|
+
cardIds: card.cardIds,
|
|
4379
|
+
claimedAt: Date.now(),
|
|
4380
|
+
lastContent: stepStreamCard(liveBlocks, { status }),
|
|
4381
|
+
});
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4048
4384
|
async #renderStepCardNow(chatId, card) {
|
|
4049
4385
|
if (card.broken) return;
|
|
4050
4386
|
const chunks = splitStepStreamCardBlocks(card.blocks);
|
|
@@ -4061,12 +4397,15 @@ export class FeishuHarnessBridge {
|
|
|
4061
4397
|
const id = await this.#sendCard(
|
|
4062
4398
|
chatId,
|
|
4063
4399
|
stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
|
|
4064
|
-
|
|
4400
|
+
card.deliveryViaOpenId
|
|
4401
|
+
? { receiveIdType: 'open_id' }
|
|
4402
|
+
: { replyTo: card.replyToMessageId },
|
|
4065
4403
|
);
|
|
4066
4404
|
card.cardIds.push(id);
|
|
4067
4405
|
if (isLive) card.messageId = id;
|
|
4068
4406
|
}
|
|
4069
4407
|
card.chunkCount = chunks.length;
|
|
4408
|
+
this.#persistMirrorState(card, live, 'running');
|
|
4070
4409
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4071
4410
|
card.renderedAnswerVersion = card.answerVersion ?? 0;
|
|
4072
4411
|
return;
|
|
@@ -4084,12 +4423,15 @@ export class FeishuHarnessBridge {
|
|
|
4084
4423
|
const id = await this.#sendCard(
|
|
4085
4424
|
chatId,
|
|
4086
4425
|
stepStreamCard(chunks[index], { status: isLive ? 'running' : 'sealed' }),
|
|
4087
|
-
|
|
4426
|
+
card.deliveryViaOpenId
|
|
4427
|
+
? { receiveIdType: 'open_id' }
|
|
4428
|
+
: { replyTo: card.replyToMessageId },
|
|
4088
4429
|
);
|
|
4089
4430
|
card.cardIds.push(id);
|
|
4090
4431
|
if (isLive) card.messageId = id;
|
|
4091
4432
|
}
|
|
4092
4433
|
card.chunkCount = chunks.length;
|
|
4434
|
+
this.#persistMirrorState(card, live, 'running');
|
|
4093
4435
|
} else {
|
|
4094
4436
|
await this.#patchStepCard(card.messageId, stepStreamCard(live, { status: 'running' }));
|
|
4095
4437
|
}
|
|
@@ -4101,6 +4443,19 @@ export class FeishuHarnessBridge {
|
|
|
4101
4443
|
'[dsh-feishu] step streaming card render failed; the turn continues without it:',
|
|
4102
4444
|
error?.message ?? String(error),
|
|
4103
4445
|
);
|
|
4446
|
+
if (card.deliveryViaOpenId) {
|
|
4447
|
+
this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
|
|
4448
|
+
error?.message ?? String(error));
|
|
4449
|
+
// Release the mirror claim at the FIRST failure so the plain-text
|
|
4450
|
+
// coordinator takes over delivery for the rest of the turn (its
|
|
4451
|
+
// user-echo suppression lifts immediately, and its recipients stay
|
|
4452
|
+
// usable for the final answer fallback).
|
|
4453
|
+
const sessionId = card.sessionSyncSessionId;
|
|
4454
|
+
if (sessionId) {
|
|
4455
|
+
releaseSessionSyncMirror(sessionId, card.sessionSyncTargetId ?? '');
|
|
4456
|
+
void this.#state.clearMirror?.(sessionId);
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4104
4459
|
}
|
|
4105
4460
|
}
|
|
4106
4461
|
|
|
@@ -4141,7 +4496,9 @@ export class FeishuHarnessBridge {
|
|
|
4141
4496
|
const id = await this.#sendCard(
|
|
4142
4497
|
card.chatId,
|
|
4143
4498
|
stepStreamCard(groups[index], { status: isLive ? status : 'sealed' }),
|
|
4144
|
-
|
|
4499
|
+
card.deliveryViaOpenId
|
|
4500
|
+
? { receiveIdType: 'open_id' }
|
|
4501
|
+
: { replyTo: card.replyToMessageId },
|
|
4145
4502
|
);
|
|
4146
4503
|
card.cardIds.push(id);
|
|
4147
4504
|
}
|
|
@@ -4161,7 +4518,9 @@ export class FeishuHarnessBridge {
|
|
|
4161
4518
|
const id = await this.#sendCard(
|
|
4162
4519
|
card.chatId,
|
|
4163
4520
|
stepStreamCard(chunks[index], { status: isLast ? status : 'sealed' }),
|
|
4164
|
-
|
|
4521
|
+
card.deliveryViaOpenId
|
|
4522
|
+
? { receiveIdType: 'open_id' }
|
|
4523
|
+
: { replyTo: card.replyToMessageId },
|
|
4165
4524
|
);
|
|
4166
4525
|
card.cardIds.push(id);
|
|
4167
4526
|
if (isLast) card.messageId = id;
|
|
@@ -4499,6 +4858,7 @@ export class FeishuHarnessBridge {
|
|
|
4499
4858
|
* (工具参数折叠为代码块);post 失败走既有纯文本降级。
|
|
4500
4859
|
*/
|
|
4501
4860
|
async #answerWithStepPush(event, key, message, { onAskComplete } = {}) {
|
|
4861
|
+
this.#beginImTurn(key);
|
|
4502
4862
|
const chatId = event.message.chat_id;
|
|
4503
4863
|
const messageId = event.message.message_id;
|
|
4504
4864
|
const text = message.content;
|
|
@@ -4506,6 +4866,7 @@ export class FeishuHarnessBridge {
|
|
|
4506
4866
|
const markAskComplete = () => {
|
|
4507
4867
|
if (askCompleted) return;
|
|
4508
4868
|
askCompleted = true;
|
|
4869
|
+
this.#endImTurn(key);
|
|
4509
4870
|
onAskComplete?.();
|
|
4510
4871
|
};
|
|
4511
4872
|
// 与流式分支一致的提示内容构造:图片与回复引用展开为富提示内容,已接受
|
|
@@ -4811,6 +5172,7 @@ export class FeishuHarnessBridge {
|
|
|
4811
5172
|
}
|
|
4812
5173
|
|
|
4813
5174
|
async #answerWithStream(event, key, message, { onAskComplete } = {}) {
|
|
5175
|
+
this.#beginImTurn(key);
|
|
4814
5176
|
const chatId = event.message.chat_id;
|
|
4815
5177
|
const messageId = event.message.message_id;
|
|
4816
5178
|
const text = message.content;
|
|
@@ -4818,6 +5180,7 @@ export class FeishuHarnessBridge {
|
|
|
4818
5180
|
const markAskComplete = () => {
|
|
4819
5181
|
if (askCompleted) return;
|
|
4820
5182
|
askCompleted = true;
|
|
5183
|
+
this.#endImTurn(key);
|
|
4821
5184
|
onAskComplete?.();
|
|
4822
5185
|
};
|
|
4823
5186
|
// 分步直推:开关开启且通道支持流式卡时,在构造提示内容之前分流到完整替
|
|
@@ -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
|
+
}
|