@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.
Files changed (47) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/lib/client.js +917 -521
  4. package/lib/index.js +269 -267
  5. package/package.json +1 -1
  6. package/plugin-src/client/bot-alias.js +169 -0
  7. package/plugin-src/client/channels/dingtalk/api.js +3 -0
  8. package/plugin-src/client/channels/dingtalk/index.js +7 -1
  9. package/plugin-src/client/channels/feishu/api.js +3 -0
  10. package/plugin-src/client/channels/feishu/index.js +7 -1
  11. package/plugin-src/client/channels/qq/api.js +3 -0
  12. package/plugin-src/client/channels/qq/index.js +9 -1
  13. package/plugin-src/client/channels/shared/token-api.js +3 -0
  14. package/plugin-src/client/channels/shared/token-channel.js +9 -2
  15. package/plugin-src/client/channels/wecom/api.js +3 -0
  16. package/plugin-src/client/channels/wecom/index.js +9 -1
  17. package/plugin-src/client/channels/wecom-app/api.js +3 -0
  18. package/plugin-src/client/channels/wecom-app/index.js +9 -1
  19. package/plugin-src/client/channels/weixin/api.js +3 -0
  20. package/plugin-src/client/channels/weixin/index.js +7 -1
  21. package/plugin-src/client/channels/whatsapp/api.js +3 -0
  22. package/plugin-src/client/channels/whatsapp/index.js +9 -1
  23. package/plugin-src/client/i18n.js +10 -0
  24. package/plugin-src/client/styles.js +41 -6
  25. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  26. package/plugin-src/host/channels/feishu/production.mjs +22 -0
  27. package/plugin-src/host/channels/feishu/rpc.mjs +14 -0
  28. package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
  29. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  30. package/plugin-src/host/channels/shared/bot-alias-rpc.mjs +15 -0
  31. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  32. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  33. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  34. package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
  35. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  36. package/plugin-src/host/channels/whatsapp/rpc.mjs +13 -0
  37. package/plugin-src/host/session-sync-coordinator.mjs +20 -1
  38. package/src/channels/feishu/bridge.mjs +330 -10
  39. package/src/channels/feishu/feishu-cards.mjs +1 -1
  40. package/src/channels/feishu/feishu-runtime.mjs +6 -0
  41. package/src/channels/feishu/state-store.mjs +17 -0
  42. package/src/channels/shared/bot-alias.mjs +29 -0
  43. package/src/channels/shared/bot-workspace-store.mjs +71 -3
  44. package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
  45. package/src/channels/shared/message-failure.mjs +2 -1
  46. package/src/channels/shared/model-command.mjs +3 -2
  47. 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
- // Persisted watches must resume at runtime start, not on the first
721
- // message. Older hosts without the mux watcher simply skip this.
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') return;
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) => this.#onHarnessEvent(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
- /** Queue live turn completions behind any reconnect compensation. */
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
- { replyTo: card.replyToMessageId },
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
- { replyTo: card.replyToMessageId },
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
- { replyTo: card.replyToMessageId },
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
- { replyTo: card.replyToMessageId },
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
+ }