@xmanrui/dsh-im 4.11.0 → 4.13.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.
Files changed (51) hide show
  1. package/PROACTIVE_DELIVERY.en.md +4 -0
  2. package/PROACTIVE_DELIVERY.md +4 -0
  3. package/README.en.md +12 -2
  4. package/README.md +12 -2
  5. package/assets/dingtalk-menu-template.json +6 -0
  6. package/lib/client.js +1211 -1034
  7. package/lib/index.js +270 -264
  8. package/package.json +1 -1
  9. package/plugin-src/client/channels/dingtalk/index.js +16 -5
  10. package/plugin-src/client/channels/feishu/index.js +45 -36
  11. package/plugin-src/client/channels/qq/index.js +16 -5
  12. package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
  13. package/plugin-src/client/channels/shared/token-channel.js +39 -28
  14. package/plugin-src/client/channels/wecom/index.js +16 -5
  15. package/plugin-src/client/channels/weixin/index.js +16 -5
  16. package/plugin-src/client/channels/whatsapp/index.js +16 -5
  17. package/plugin-src/client/i18n.js +2 -0
  18. package/plugin-src/client/styles.js +14 -1
  19. package/plugin-src/host/harness-command-executor.mjs +13 -6
  20. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
  22. package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
  23. package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
  24. package/src/channels/dingtalk/state-store.mjs +8 -0
  25. package/src/channels/feishu/bridge.mjs +74 -3
  26. package/src/channels/feishu/state-store.mjs +11 -0
  27. package/src/channels/qq/qq-bridge.mjs +121 -1
  28. package/src/channels/qq/qq-menu.mjs +205 -0
  29. package/src/channels/qq/state-store.mjs +16 -0
  30. package/src/channels/shared/bot-workspace-store.mjs +3 -0
  31. package/src/channels/shared/control-command.mjs +8 -0
  32. package/src/channels/shared/conversation-state-store.mjs +8 -0
  33. package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
  34. package/src/channels/shared/deferred-delivery.mjs +161 -0
  35. package/src/channels/shared/deferred-state.mjs +53 -0
  36. package/src/channels/shared/harness-client.mjs +21 -1
  37. package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
  38. package/src/channels/shared/i18n-en/feishu.mjs +6 -0
  39. package/src/channels/shared/i18n-en/qq.mjs +39 -0
  40. package/src/channels/shared/i18n-en/wecom.mjs +24 -0
  41. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  42. package/src/channels/shared/text-harness-bridge.mjs +15 -0
  43. package/src/channels/shared/workspace-session.mjs +16 -1
  44. package/src/channels/wecom/state-store.mjs +8 -0
  45. package/src/channels/wecom/wecom-bridge.mjs +294 -6
  46. package/src/channels/wecom/wecom-cards.mjs +101 -0
  47. package/src/channels/wecom/wecom-runtime.mjs +2 -0
  48. package/src/channels/weixin/state-store.mjs +64 -1
  49. package/src/channels/weixin/weixin-bridge.mjs +31 -5
  50. package/src/channels/weixin/weixin-runtime.mjs +33 -8
  51. package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
@@ -3,6 +3,7 @@ import { extname } from 'node:path';
3
3
 
4
4
  import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
5
5
  import { t } from '../shared/i18n.mjs';
6
+ import { DINGTALK_MENU_TEMPLATE_ID, dingtalkMenuCardData } from './dingtalk-menu.mjs';
6
7
 
7
8
  export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
8
9
  export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
@@ -876,6 +877,38 @@ export function createDingtalkApi({
876
877
  }
877
878
  },
878
879
 
880
+ async createMenuCard({ clientId, clientSecret, target, data, signal }) {
881
+ const normalizedTarget = normalizeCardTarget(target);
882
+ const token = await accessToken({ clientId, clientSecret, signal });
883
+ const cardInstanceId = `dsh_menu_${randomUUID()}`;
884
+ const headers = { 'x-acs-dingtalk-access-token': token };
885
+ await cardRequest('v1.0/card/instances/createAndDeliver', {
886
+ body: {
887
+ ...cardDeliverBody(cardInstanceId, normalizedTarget, clientId),
888
+ cardTemplateId: DINGTALK_MENU_TEMPLATE_ID,
889
+ outTrackId: cardInstanceId,
890
+ cardData: dingtalkMenuCardData(data),
891
+ callbackType: 'STREAM',
892
+ imGroupOpenSpaceModel: { supportForward: false },
893
+ imRobotOpenSpaceModel: { supportForward: false },
894
+ },
895
+ headers, signal, action: '菜单卡片创建',
896
+ });
897
+ return { cardInstanceId };
898
+ },
899
+
900
+ async updateMenuCard({ clientId, clientSecret, cardInstanceId, data, signal }) {
901
+ if (!nonEmptyString(cardInstanceId)) throw new TypeError('cardInstanceId is required');
902
+ const token = await accessToken({ clientId, clientSecret, signal });
903
+ await cardRequest('v1.0/card/instances', {
904
+ method: 'PUT',
905
+ body: { outTrackId: cardInstanceId, cardData: dingtalkMenuCardData(data),
906
+ cardUpdateOptions: { updateCardDataByKey: true } },
907
+ headers: { 'x-acs-dingtalk-access-token': token }, signal, action: '菜单卡片更新',
908
+ });
909
+ return true;
910
+ },
911
+
879
912
  async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
880
913
  const appKey = nonEmptyString(clientId);
881
914
  const appSecret = nonEmptyString(clientSecret);
@@ -1,3 +1,4 @@
1
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
1
2
  import {
2
3
  DINGTALK_DONE_REACTION_NAME,
3
4
  DINGTALK_ERROR_REACTION_NAME,
@@ -6,6 +7,7 @@ import {
6
7
  splitDingtalkText,
7
8
  } from './dingtalk-api.mjs';
8
9
  import { createDingTalkCardStream } from './dingtalk-card-stream.mjs';
10
+ import { dingtalkMenuSnapshot, dingtalkMenuCommand, isDingtalkMenuCommand } from './dingtalk-menu.mjs';
9
11
  import {
10
12
  harnessAnswerForQuestion,
11
13
  harnessQuestionText,
@@ -76,6 +78,7 @@ const HELP_TEXT_LINES = [
76
78
  '钉钉机器人已连接 DeepSeek Harness。',
77
79
  '',
78
80
  '直接发送文字、图片或文件即可继续当前会话。',
81
+ '/m 或 /menu 打开下拉操作菜单',
79
82
  '/new 开启一个全新会话',
80
83
  '/compact 压缩当前会话的较早上下文',
81
84
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)',
@@ -475,6 +478,7 @@ export class DingtalkHarnessBridge {
475
478
  #clientSecret;
476
479
  #harness;
477
480
  #state;
481
+ #deferred;
478
482
  #contextEnhancement;
479
483
  #accessPolicy;
480
484
  #status;
@@ -488,6 +492,7 @@ export class DingtalkHarnessBridge {
488
492
  #interactionKeys = new Map();
489
493
  #interactionTasks = new Set();
490
494
  #commandTasks = new Set();
495
+ #menus = new Map();
491
496
  // Keep the accepted configuration through the existing queue/reply lifecycle.
492
497
  #acceptedMessageIds = new Map();
493
498
  #approvals;
@@ -529,6 +534,9 @@ export class DingtalkHarnessBridge {
529
534
  : 5_000;
530
535
  this.#maxMessageChars = maxMessageChars;
531
536
  this.#signal = signal;
537
+ this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
538
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
539
+ });
532
540
  ensureStats(this.#status);
533
541
  this.#refreshPendingSenders();
534
542
  }
@@ -574,6 +582,7 @@ export class DingtalkHarnessBridge {
574
582
  text: commandText,
575
583
  hasImages: hasInboundImages(promptMessage),
576
584
  hasFiles: hasInboundFiles(promptMessage),
585
+ ...(isDingtalkMenuCommand(commandText) ? { isCommand: true } : {}),
577
586
  });
578
587
  if (!access.allowed) {
579
588
  this.#acceptedMessageIds.set(messageId, null);
@@ -617,7 +626,8 @@ export class DingtalkHarnessBridge {
617
626
  statusReaction,
618
627
  ));
619
628
  }
620
- if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
629
+ if (direct && sessionWebhook && !isDingtalkMenuCommand(commandText)
630
+ && (batchCommand || batchStatus.phase === 'collecting')) {
621
631
  const exactBatchStart = /^\/batch$/iu.test(commandText);
622
632
  const result = exactBatchStart
623
633
  && batchStatus.phase === 'idle'
@@ -652,7 +662,10 @@ export class DingtalkHarnessBridge {
652
662
  ));
653
663
  }
654
664
  }
655
- const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
665
+ const commandRunner = isDingtalkMenuCommand(commandText)
666
+ && !hasInboundFiles(promptMessage) && !hasInboundImages(promptMessage)
667
+ ? async () => { await this.#showMenu(message, key); }
668
+ : isHistoryCommand(commandText) ? runHistoryCommand
656
669
  : hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
657
670
  ? runControlCommand
658
671
  : (isModelCommand(commandText)
@@ -773,6 +786,111 @@ export class DingtalkHarnessBridge {
773
786
  return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
774
787
  }
775
788
 
789
+ async #showMenu(message, key) {
790
+ if (typeof this.#api.createMenuCard !== 'function') {
791
+ await this.#send(message.sessionWebhook, helpText(), this.#atUsersFor(message));
792
+ return;
793
+ }
794
+ const snapshot = await dingtalkMenuSnapshot(this.#harness, this.#state, key, this.#signal);
795
+ const now = Date.now();
796
+ for (const [id, entry] of this.#menus) {
797
+ if (entry.expiresAt <= now || entry.key === key) this.#menus.delete(id);
798
+ }
799
+ while (this.#menus.size >= 256) this.#menus.delete(this.#menus.keys().next().value);
800
+ const { cardInstanceId } = await this.#api.createMenuCard({
801
+ clientId: this.#clientId, clientSecret: this.#clientSecret,
802
+ target: cardTarget(message, senderStaffId(message)), data: snapshot.data, signal: this.#signal,
803
+ });
804
+ this.#menus.set(cardInstanceId, {
805
+ ...snapshot, key, message, cardInstanceId, expiresAt: now + 30 * 60_000, busy: false,
806
+ });
807
+ }
808
+
809
+ acceptCard(callback, messageId) {
810
+ if (this.#signal?.aborted || !nonEmptyString(messageId) || this.#state.hasSeen(messageId)) {
811
+ return Promise.resolve();
812
+ }
813
+ const entry = this.#menus.get(callback?.outTrackId);
814
+ if (!entry || entry.busy || entry.expiresAt <= Date.now()
815
+ || callback?.userId !== senderStaffId(entry.message)) return Promise.resolve();
816
+ const command = dingtalkMenuCommand(entry, callback);
817
+ if (!command) return Promise.resolve();
818
+ entry.busy = true;
819
+ let task;
820
+ task = this.#processMenuAction(entry, command, messageId).catch(async (error) => {
821
+ if (this.#signal?.aborted) return;
822
+ this.#status.lastError = error?.message ?? String(error);
823
+ this.#logger.warn?.('[dsh-dingtalk] menu action failed', safeErrorDiagnostic(error));
824
+ await this.#api.updateMenuCard({
825
+ clientId: this.#clientId, clientSecret: this.#clientSecret,
826
+ cardInstanceId: entry.cardInstanceId,
827
+ data: { notice: t('操作未完成,请重新发送 /m 后重试。') }, signal: this.#signal,
828
+ }).catch(() => undefined);
829
+ }).finally(() => { entry.busy = false; this.#commandTasks.delete(task); });
830
+ this.#commandTasks.add(task);
831
+ return task;
832
+ }
833
+
834
+ async #processMenuAction(entry, command, messageId) {
835
+ await this.#state.markSeen(messageId);
836
+ const { key, message } = entry;
837
+ const options = {
838
+ signal: this.#signal, isDirect: String(message.conversationType) === '1',
839
+ pendingInteraction: this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
840
+ control: { owner: this, key }, deferredDelivery: this.#deferred,
841
+ };
842
+ const access = evaluateInboundAccess(this.#accessPolicy, {
843
+ conversationType: options.isDirect ? 'direct' : 'group',
844
+ senderIds: senderStaffId(message), text: command, isCommand: true,
845
+ });
846
+ let result;
847
+ if (!access.allowed) {
848
+ await this.#api.updateMenuCard({
849
+ clientId: this.#clientId, clientSecret: this.#clientSecret,
850
+ cardInstanceId: entry.cardInstanceId,
851
+ data: { notice: t(COMMAND_PERMISSION_DENIED_MESSAGE) }, signal: this.#signal,
852
+ });
853
+ return;
854
+ }
855
+ if (entry.workspace !== this.#harness.currentWorkspace?.()
856
+ || entry.sessionId !== this.#state.sessionFor(key)) {
857
+ result = { message: t('会话或工作区已变化,菜单已刷新,请重新选择。') };
858
+ } else if ((this.#queues.has(key) || options.pendingInteraction || this.#batchInputs.status(key).phase !== 'idle')
859
+ && !['/stop', '/help', '/status', '/history'].includes(command)) {
860
+ result = { message: t('当前任务尚未结束,请先停止任务或等待完成后再操作。') };
861
+ } else if (command === '/new') {
862
+ await this.#state.clearSession(key);
863
+ result = { message: t('已开启新会话。请发送你的问题。') };
864
+ } else if (command === '/status') {
865
+ await this.#harness.ensureRunning({ signal: this.#signal });
866
+ result = { message: t('钉钉机器人与 DeepSeek Harness 连接正常。') };
867
+ } else if (command === '/help') {
868
+ result = { message: helpText() };
869
+ } else {
870
+ result = await runWorkspaceCommand(command, this.#harness, key)
871
+ ?? await runCompactCommand(command, this.#harness, this.#state, key, options)
872
+ ?? await runControlCommand(command, this.#harness, this.#state, key, options)
873
+ ?? await runHistoryCommand(command, this.#harness, this.#state, key, options)
874
+ ?? await runModelCommand(command, this.#harness, this.#state, key, options)
875
+ ?? await runPresetCommand(command, this.#harness, this.#state, key, options);
876
+ }
877
+ if (result?.stopped) await Promise.allSettled([
878
+ this.#cancelPendingInteraction(key), this.#approvals.closeRoute(key),
879
+ ]);
880
+ const snapshot = await dingtalkMenuSnapshot(this.#harness, this.#state, key, this.#signal);
881
+ if (command === '/help' || command === '/history') {
882
+ for (const reply of result?.messages ?? [result?.message]) {
883
+ if (reply) await this.#send(message.sessionWebhook, reply, this.#atUsersFor(message));
884
+ }
885
+ } else snapshot.data.notice = (result?.message ?? '').slice(0, 300);
886
+ await this.#api.updateMenuCard({
887
+ clientId: this.#clientId, clientSecret: this.#clientSecret,
888
+ cardInstanceId: entry.cardInstanceId, data: snapshot.data, signal: this.#signal,
889
+ });
890
+ Object.assign(entry, snapshot);
891
+ this.#status.lastError = null;
892
+ }
893
+
776
894
  #runReactionCall(method, target, reactionName, kind) {
777
895
  const controller = new AbortController();
778
896
  const operation = Promise.resolve().then(() => this.#api[method]({
@@ -936,6 +1054,19 @@ export class DingtalkHarnessBridge {
936
1054
  return { content: text };
937
1055
  }
938
1056
 
1057
+ async #deliverDeferredOutcome(entry, outcome) {
1058
+ const text = deferredOutcomeText(outcome);
1059
+ if (outcome.found && ['createAiCard', 'updateAiCard', 'finishAiCard'].every((name) => typeof this.#api[name] === 'function')) {
1060
+ const card = createDingTalkCardStream({ api: this.#api, clientId: this.#clientId,
1061
+ clientSecret: this.#clientSecret, target: entry.target, signal: this.#signal, logger: this.#logger });
1062
+ if (await card.start(t(CARD_INITIAL_TEXT)) && await card.finish(text)) return true;
1063
+ this.#logger.warn?.('[dsh-dingtalk] deferred card unavailable; sending robot text');
1064
+ }
1065
+ await this.#api.sendRobotText({ clientId: this.#clientId, clientSecret: this.#clientSecret,
1066
+ target: entry.target, text, signal: this.#signal });
1067
+ return true;
1068
+ }
1069
+
939
1070
  async waitForIdle() {
940
1071
  await Promise.allSettled([
941
1072
  ...this.#queues.values(),
@@ -945,6 +1076,7 @@ export class DingtalkHarnessBridge {
945
1076
  ...this.#interactionTasks,
946
1077
  ...this.#commandTasks,
947
1078
  ]);
1079
+ await this.#deferred.whenIdle();
948
1080
  }
949
1081
 
950
1082
  async #processFastCommand(message, messageId, key, sessionWebhook, prompt, runner) {
@@ -966,6 +1098,7 @@ export class DingtalkHarnessBridge {
966
1098
  pendingInteraction: this.#pendingInteractions.has(key)
967
1099
  || this.#approvals.hasPending(key),
968
1100
  control: { owner: this, key },
1101
+ deferredDelivery: this.#deferred,
969
1102
  },
970
1103
  );
971
1104
  if (result?.stopped) {
@@ -1157,6 +1290,7 @@ export class DingtalkHarnessBridge {
1157
1290
  if (cardStarted) cardStartedAt = startedAt;
1158
1291
  }
1159
1292
  const { answer, artifacts = [] } = await askInWorkspaceSession({
1293
+ deferredDelivery: () => ({ coordinator: this.#deferred, target: { ...cardTarget(message, sender), robotCode: this.#clientId } }),
1160
1294
  harness: this.#harness,
1161
1295
  state: this.#state,
1162
1296
  key,
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { workspacePathSnapshot } from '../shared/workspace-command.mjs';
3
+ import { t } from '../shared/i18n.mjs';
4
+
5
+ export const DINGTALK_MENU_TEMPLATE_ID = '9ca31362-e5b3-409c-a694-6993706a6004.schema';
6
+ export const DINGTALK_CARD_TOPIC = '/v1.0/card/instances/callback';
7
+ export const DINGTALK_MENU_BUTTONS = Object.freeze({
8
+ new: '/new', history: '/history', stop: '/stop', compact: '/compact',
9
+ status: '/status', help: '/help',
10
+ });
11
+
12
+ export function isDingtalkMenuCommand(text) {
13
+ return typeof text === 'string' && /^\/(?:m|menu)$/iu.test(text.trim());
14
+ }
15
+
16
+ const presetCommand = (id) => `/preset ${/^\d+$/u.test(id) ? 'id:' : ''}${id}`;
17
+
18
+ // Keep exact commands alongside the displayed options. Never interpret a callback
19
+ // as a command supplied by the client or as a prompt for the model.
20
+ export async function dingtalkMenuSnapshot(harness, state, key, signal) {
21
+ const workspace = harness.currentWorkspace?.();
22
+ const sessionId = state.sessionFor(key);
23
+ const options = { signal };
24
+ const results = await Promise.allSettled([
25
+ workspacePathSnapshot(harness, options),
26
+ harness.listWorkspaceSessions?.(workspace, options),
27
+ (async () => {
28
+ const session = sessionId ? harness.workspaceSession?.(sessionId) : null;
29
+ return typeof session?.models === 'function'
30
+ ? session.models(options) : harness.listModels?.(options);
31
+ })(),
32
+ harness.agentPresetSettings?.(options),
33
+ ]);
34
+ signal?.throwIfAborted();
35
+ if (harness.currentWorkspace?.() !== workspace || state.sessionFor(key) !== sessionId) {
36
+ throw new Error(t('会话或工作区已变化,请重新发送 /m。'));
37
+ }
38
+ const [paths, listed, catalog, settings] = results.map((r) => r.status === 'fulfilled' ? r.value : null);
39
+ const selections = {
40
+ session: [[t('新会话'), '/new'], ...(listed?.sessions ?? []).map((item) => [
41
+ item.title || item.sessionId, `/session ${item.sessionId}`,
42
+ ])],
43
+ workspace: (paths?.paths ?? (workspace ? [workspace] : [])).map((path) => [path, `/workspace ${path}`]),
44
+ preset: [[t('跟随 Host 默认'), '/preset --default'], ...(settings?.agentPresetCatalog?.items ?? []).map((item) => [
45
+ item.label || item.id, presetCommand(item.id),
46
+ ])],
47
+ model: (catalog?.groups ?? []).flatMap((group) => group.models.map((model) => [
48
+ `${model.name} (${group.id})`, `/model ${group.id}/${model.id}`,
49
+ ])),
50
+ };
51
+ const current = {
52
+ session: sessionId ? `/session ${sessionId}` : '/new',
53
+ workspace: `/workspace ${workspace}`,
54
+ preset: settings?.agentPreset ? presetCommand(settings.agentPreset) : '/preset --default',
55
+ model: catalog?.current ? `/model ${catalog.current.provider}/${catalog.current.model}` : null,
56
+ };
57
+ const data = { revision: randomUUID(), notice: '' };
58
+ for (const [name, entries] of Object.entries(selections)) {
59
+ // Keep the active value visible even when the session listing is paginated.
60
+ if (current[name] && !entries.some(([, command]) => command === current[name])) {
61
+ entries.unshift([name === 'session' ? sessionId : current[name].replace(/^\/\w+\s+/u, ''), current[name]]);
62
+ }
63
+ data[`${name}_options`] = entries.map(([label], index) => ({
64
+ value: String(index), text: { zh_CN: label, en_US: label },
65
+ }));
66
+ data[`${name}_index`] = entries.findIndex(([, command]) => command === current[name]);
67
+ }
68
+ return { workspace, sessionId, selections, data };
69
+ }
70
+
71
+ export function dingtalkMenuCommand(entry, callback) {
72
+ let content;
73
+ try {
74
+ content = typeof callback?.content === 'string' ? JSON.parse(callback.content) : callback?.content;
75
+ } catch { return null; }
76
+ const { actionIds, params } = content?.cardPrivateData ?? {};
77
+ if (!Array.isArray(actionIds) || actionIds.length !== 1
78
+ || params?.revision !== entry.data.revision) return null;
79
+ const action = actionIds[0];
80
+ if (Object.hasOwn(DINGTALK_MENU_BUTTONS, action)) return DINGTALK_MENU_BUTTONS[action];
81
+ const entries = entry.selections[action];
82
+ const index = params[action]?.index;
83
+ if (!Array.isArray(entries) || !Number.isSafeInteger(index) || index < 0) return null;
84
+ return entries[index]?.[1] ?? null;
85
+ }
86
+
87
+ export function dingtalkMenuCardData(data) {
88
+ return { cardParamMap: Object.fromEntries(Object.entries(data).map(([key, value]) => [
89
+ key, typeof value === 'string' ? value : JSON.stringify(value),
90
+ ])) };
91
+ }
@@ -7,6 +7,7 @@ import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
7
7
  import { t } from '../shared/i18n.mjs';
8
8
  import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
9
9
  import { dingtalkRuntimeStartError } from './connection-error.mjs';
10
+ import { DINGTALK_CARD_TOPIC } from './dingtalk-menu.mjs';
10
11
 
11
12
  function nonEmptyString(value) {
12
13
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -271,6 +272,22 @@ export class DingtalkRuntime {
271
272
  const client = this.#client;
272
273
  const bridge = this.#bridge;
273
274
  startStage = 'dingtalk-stream-listener-failed';
275
+ client.registerCallbackListener(DINGTALK_CARD_TOPIC, (response) => {
276
+ if (this.#client !== client || this.#bridge !== bridge) return;
277
+ const messageId = nonEmptyString(response?.headers?.messageId);
278
+ if (messageId) {
279
+ try { client.socketCallBackResponse(messageId, { success: true }); }
280
+ catch { this.#logger.warn?.('[dsh-dingtalk] unable to acknowledge a card callback'); }
281
+ }
282
+ const task = Promise.resolve().then(async () => {
283
+ if (this.#bridge !== bridge) return;
284
+ const callback = typeof response?.data === 'string' ? JSON.parse(response.data) : response?.data;
285
+ await bridge.acceptCard(callback, messageId);
286
+ }).catch(() => {
287
+ if (!signal.aborted) this.#logger.warn?.('[dsh-dingtalk] card callback processing failed');
288
+ }).finally(() => this.#callbackTasks.delete(task));
289
+ this.#callbackTasks.add(task);
290
+ });
274
291
  client.registerCallbackListener(this.#topic, (response) => {
275
292
  if (this.#client !== client || this.#bridge !== bridge) return;
276
293
  const callbackMessageId = nonEmptyString(response?.headers?.messageId);
@@ -1,3 +1,4 @@
1
+ import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
4
  import { dirname } from 'node:path';
@@ -114,6 +115,7 @@ function normalizeState(value) {
114
115
  return {
115
116
  version: 1,
116
117
  sessions,
118
+ ...(value.deferred ? { deferred: normalizeDeferredState(value.deferred) } : {}),
117
119
  seenMessageIds: Array.isArray(value.seenMessageIds)
118
120
  ? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000)
119
121
  : [],
@@ -126,6 +128,7 @@ export class DingtalkStateStore {
126
128
  #path;
127
129
  #state = structuredClone(EMPTY_STATE);
128
130
  #writeQueue = Promise.resolve();
131
+ #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
129
132
  #idFactory;
130
133
  #now;
131
134
 
@@ -150,6 +153,11 @@ export class DingtalkStateStore {
150
153
  return this;
151
154
  }
152
155
 
156
+ deferredEntries() { return this.#deferred.entries(); }
157
+ putDeferred(entry) { return this.#deferred.put(entry); }
158
+ patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
159
+ removeDeferred(id) { return this.#deferred.remove(id); }
160
+
153
161
  sessionFor(key) {
154
162
  return this.#state.sessions[key] ?? null;
155
163
  }
@@ -56,6 +56,7 @@ import {
56
56
  workspacePathSnapshot,
57
57
  } from '../shared/workspace-command.mjs';
58
58
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
59
+ import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
59
60
  import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
60
61
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
61
62
  import {
@@ -505,6 +506,7 @@ export class FeishuHarnessBridge {
505
506
  #cardKeys = new Map();
506
507
  /** The global event-mux watcher (one per bridge). */
507
508
  #eventWatcher = null;
509
+ #deferred;
508
510
  /** Serializes completion work per session without blocking unrelated sessions. */
509
511
  #eventTails = new Map();
510
512
  /** Coalesces baseline compensation and records whether a trailing pass is needed. */
@@ -581,10 +583,16 @@ export class FeishuHarnessBridge {
581
583
  this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
582
584
  this.#signal = signal;
583
585
  ensureStatus(this.#status);
586
+ this.#deferred = createDeferredDeliveryCoordinator({
587
+ harness, state, signal, logger, watch: false,
588
+ deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
589
+ });
584
590
  // Persisted watches must resume at runtime start, not on the first
585
591
  // message. Older hosts without the mux watcher simply skip this.
586
592
  if (typeof this.#harness?.watchHarnessEvents === 'function') {
587
- queueMicrotask(() => this.#ensureEventWatcher());
593
+ queueMicrotask(() => {
594
+ this.#ensureEventWatcher();
595
+ });
588
596
  }
589
597
  }
590
598
 
@@ -1128,6 +1136,7 @@ export class FeishuHarnessBridge {
1128
1136
  // Drain to a fixed point: awaited work can register compensation or
1129
1137
  // another serialized tail before it settles.
1130
1138
  for (;;) {
1139
+ await this.#deferred.whenIdle();
1131
1140
  const tasks = [
1132
1141
  ...this.#queues.values(),
1133
1142
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -1141,6 +1150,7 @@ export class FeishuHarnessBridge {
1141
1150
  ];
1142
1151
  if (tasks.length === 0) return;
1143
1152
  await Promise.allSettled(tasks);
1153
+ await this.#deferred.whenIdle();
1144
1154
  if (this.#queues.size === 0
1145
1155
  && this.#interactionTasks.size === 0
1146
1156
  && this.#commandTasks.size === 0
@@ -1166,7 +1176,7 @@ export class FeishuHarnessBridge {
1166
1176
  await this.#state.markSeen(messageId);
1167
1177
  this.#status.lastMessageAt = new Date().toISOString();
1168
1178
  this.#status.messagesReceived += 1;
1169
- const result = await runner(
1179
+ let result = await runner(
1170
1180
  nonEmptyString(message.content) ?? '',
1171
1181
  this.#harness,
1172
1182
  this.#state,
@@ -1175,6 +1185,7 @@ export class FeishuHarnessBridge {
1175
1185
  signal: this.#signal,
1176
1186
  isDirect: event.message.chat_type === 'p2p',
1177
1187
  hasImages: hasInboundImages(message),
1188
+ deferredDelivery: this.#deferred,
1178
1189
  hasFiles: hasInboundFiles(message),
1179
1190
  pendingInteraction: this.#hasPendingInteraction(key),
1180
1191
  control: { owner: this, key },
@@ -2831,7 +2842,7 @@ export class FeishuHarnessBridge {
2831
2842
  */
2832
2843
  async #handleStop(key, chatId, replyTo = null) {
2833
2844
  try {
2834
- const result = await runControlCommand(
2845
+ let result = await runControlCommand(
2835
2846
  '/stop', this.#harness, this.#state, key, {
2836
2847
  signal: this.#signal,
2837
2848
  control: { owner: this, key },
@@ -2952,6 +2963,61 @@ export class FeishuHarnessBridge {
2952
2963
 
2953
2964
  // ── Watches: read-only session tracking + completion pushes ─────────────
2954
2965
 
2966
+ // ── Deferred delivery (MODEL_REPLY_TIMEOUT → late result push) ──────────
2967
+
2968
+ async #deliverDeferredOutcome(entry, outcome) {
2969
+ this.#rememberTopicReply(entry.replyToMessageId, entry.key);
2970
+ if (entry.key === managedGroupKey(entry.chatId, entry.replyToMessageId)) {
2971
+ this.#rememberTopicRoot(entry.replyToMessageId);
2972
+ }
2973
+ const text = deferredOutcomeText(outcome);
2974
+ if (outcome.found) {
2975
+ return this.#deliverDeferredText(entry.chatId, answerTextForDelivery(text, []), entry.replyToMessageId);
2976
+ }
2977
+ await this.#send(entry.chatId, text, { replyTo: entry.replyToMessageId });
2978
+ return true;
2979
+ }
2980
+
2981
+ async #deliverDeferredText(chatId, markdownText, replyToMessageId) {
2982
+ if (this.#channel?.stream) {
2983
+ try {
2984
+ await this.#channel.stream(chatId, {
2985
+ markdown: async (controller) => {
2986
+ await controller.setContent(markdownText);
2987
+ },
2988
+ }, {
2989
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
2990
+ ...(this.#replyInThreadFor(replyToMessageId) ? {
2991
+ replyInThread: true,
2992
+ onReplyThreadId: async (threadId) => this.#registerTopicThreadId(
2993
+ threadId,
2994
+ replyToMessageId,
2995
+ chatId,
2996
+ ),
2997
+ } : {}),
2998
+ });
2999
+ return true;
3000
+ } catch (error) {
3001
+ if (error?.deliveryOutcome === 'unknown') throw error;
3002
+ this.#logger.warn?.(
3003
+ '[dsh-feishu] deferred stream delivery failed; falling back to text:',
3004
+ error.message,
3005
+ );
3006
+ }
3007
+ }
3008
+ try {
3009
+ await this.#sendAnswerText(chatId, markdownText, {
3010
+ deliveryId: `deferred-${Date.now()}`,
3011
+ presentation: 'feishu-deferred-text',
3012
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
3013
+ });
3014
+ return true;
3015
+ } catch (error) {
3016
+ this.#logger.warn?.('[dsh-feishu] deferred text delivery failed:', error.message);
3017
+ throw error;
3018
+ }
3019
+ }
3020
+
2955
3021
  #ensureEventWatcher() {
2956
3022
  if (this.#eventWatcher) return;
2957
3023
  if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
@@ -2963,6 +3029,7 @@ export class FeishuHarnessBridge {
2963
3029
  onSessionEvent: (payload) => this.#onHarnessEvent(payload),
2964
3030
  onReconnect: () => {
2965
3031
  void this.#compensateMissedEvents();
3032
+ void this.#deferred.resume();
2966
3033
  },
2967
3034
  });
2968
3035
  Promise.resolve(this.#eventWatcher).catch((error) => {
@@ -3321,6 +3388,7 @@ export class FeishuHarnessBridge {
3321
3388
  .some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
3322
3389
  if (needsBaseline || hasFailedDelivery) await this.#compensateSession(sessionId);
3323
3390
  await this.#deliverCompletion(sessionId, event);
3391
+ await this.#deferred.onEvent({ sessionId, event });
3324
3392
  });
3325
3393
  }
3326
3394
 
@@ -3562,6 +3630,7 @@ export class FeishuHarnessBridge {
3562
3630
  }
3563
3631
  if (!this.#channel?.stream) {
3564
3632
  const { answer, artifacts = [] } = await askInWorkspaceSession({
3633
+ deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3565
3634
  harness: this.#harness,
3566
3635
  state: this.#state,
3567
3636
  key,
@@ -3632,6 +3701,7 @@ export class FeishuHarnessBridge {
3632
3701
  },
3633
3702
  };
3634
3703
  const completed = await askInWorkspaceSession({
3704
+ deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3635
3705
  harness: this.#harness,
3636
3706
  state: this.#state,
3637
3707
  key,
@@ -3701,6 +3771,7 @@ export class FeishuHarnessBridge {
3701
3771
 
3702
3772
  this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
3703
3773
  const { answer, artifacts = [] } = await askInWorkspaceSession({
3774
+ deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3704
3775
  harness: this.#harness,
3705
3776
  state: this.#state,
3706
3777
  key,
@@ -1,3 +1,4 @@
1
+ import { deferredStateAccess, normalizeDeferredState } from '../shared/deferred-state.mjs';
1
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
2
3
  import { dirname } from 'node:path';
3
4
 
@@ -6,6 +7,7 @@ const EMPTY_STATE = Object.freeze({
6
7
  sessions: {},
7
8
  seenMessageIds: [],
8
9
  watches: {},
10
+ deferred: {},
9
11
  includeArchivedSessions: false,
10
12
  topics: {},
11
13
  });
@@ -25,6 +27,7 @@ export class StateStore {
25
27
  #path;
26
28
  #state = structuredClone(EMPTY_STATE);
27
29
  #writeQueue = Promise.resolve();
30
+ #deferred = deferredStateAccess(() => this.#state, () => this.#persist());
28
31
 
29
32
  constructor(path) {
30
33
  this.#path = path;
@@ -38,6 +41,7 @@ export class StateStore {
38
41
  sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
39
42
  seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
40
43
  watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
44
+ deferred: normalizeDeferredState(parsed.deferred),
41
45
  includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
42
46
  ? parsed.includeArchivedSessions
43
47
  : false,
@@ -50,6 +54,11 @@ export class StateStore {
50
54
  return this;
51
55
  }
52
56
 
57
+ deferredEntries() { return this.#deferred.entries(); }
58
+ putDeferred(entry) { return this.#deferred.put(entry); }
59
+ patchDeferred(id, patch) { return this.#deferred.patch(id, patch); }
60
+ removeDeferred(id) { return this.#deferred.remove(id); }
61
+
53
62
  sessionFor(key) {
54
63
  return this.#state.sessions[key] ?? null;
55
64
  }
@@ -138,6 +147,8 @@ export class StateStore {
138
147
  return [...ids];
139
148
  }
140
149
 
150
+ // ── Deferred delivery (persisted: surviving restarts) ───────────────────
151
+
141
152
  // ── Managed Feishu topics (thread_id → root message, persisted) ────────
142
153
 
143
154
  topicRootFor(threadId) {