@xmanrui/dsh-im 4.12.0 → 4.14.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 (47) hide show
  1. package/PROACTIVE_DELIVERY.en.md +4 -0
  2. package/PROACTIVE_DELIVERY.md +4 -0
  3. package/README.en.md +11 -2
  4. package/README.md +11 -2
  5. package/assets/dingtalk-menu-template.json +6 -0
  6. package/lib/client.js +78 -2
  7. package/lib/index.js +273 -264
  8. package/package.json +1 -1
  9. package/plugin-src/client/channels/feishu/api.js +2 -0
  10. package/plugin-src/client/channels/feishu/index.js +57 -0
  11. package/plugin-src/client/i18n.js +5 -0
  12. package/plugin-src/client/styles.js +2 -1
  13. package/plugin-src/host/channels/feishu/production.mjs +1 -0
  14. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  15. package/plugin-src/host/harness-command-executor.mjs +13 -6
  16. package/src/channels/dingtalk/dingtalk-api.mjs +33 -0
  17. package/src/channels/dingtalk/dingtalk-bridge.mjs +136 -2
  18. package/src/channels/dingtalk/dingtalk-menu.mjs +91 -0
  19. package/src/channels/dingtalk/dingtalk-runtime.mjs +17 -0
  20. package/src/channels/dingtalk/state-store.mjs +8 -0
  21. package/src/channels/feishu/bridge.mjs +584 -6
  22. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  23. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  24. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  25. package/src/channels/feishu/state-store.mjs +11 -0
  26. package/src/channels/qq/qq-bridge.mjs +121 -1
  27. package/src/channels/qq/qq-menu.mjs +205 -0
  28. package/src/channels/qq/state-store.mjs +16 -0
  29. package/src/channels/shared/bot-workspace-store.mjs +17 -1
  30. package/src/channels/shared/control-command.mjs +8 -0
  31. package/src/channels/shared/conversation-state-store.mjs +8 -0
  32. package/src/channels/shared/deferred-delivery-coordinator.mjs +241 -0
  33. package/src/channels/shared/deferred-delivery.mjs +161 -0
  34. package/src/channels/shared/deferred-state.mjs +53 -0
  35. package/src/channels/shared/harness-client.mjs +42 -3
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +5 -0
  37. package/src/channels/shared/i18n-en/feishu.mjs +8 -0
  38. package/src/channels/shared/i18n-en/qq.mjs +39 -0
  39. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  40. package/src/channels/shared/semantic/artifact.mjs +3 -1
  41. package/src/channels/shared/text-harness-bridge.mjs +15 -0
  42. package/src/channels/shared/workspace-session.mjs +16 -1
  43. package/src/channels/wecom/state-store.mjs +8 -0
  44. package/src/channels/wecom/wecom-bridge.mjs +19 -4
  45. package/src/channels/weixin/state-store.mjs +64 -1
  46. package/src/channels/weixin/weixin-bridge.mjs +31 -5
  47. package/src/channels/weixin/weixin-runtime.mjs +33 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.12.0",
3
+ "version": "4.14.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -32,6 +32,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
32
32
  setAccessPolicy: "bot.access-policy.set",
33
33
  setGroupResponseMode: "bot.group-response-mode.set",
34
34
  setGroupTopicReply: "bot.group-topic-reply.set",
35
+ setStepPush: "bot.step-push.set",
35
36
  // Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
36
37
  testConnection: "connection.test",
37
38
  disconnect: "connection.disconnect",
@@ -217,6 +218,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
217
218
  : {}),
218
219
  groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
219
220
  groupTopicReply: value.groupTopicReply === true,
221
+ stepPush: value.stepPush === true,
220
222
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
221
223
  bot: normalizeBot(value.bot),
222
224
  health: normalizeHealth(value.health, connected),
@@ -464,6 +464,53 @@ function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
464
464
  );
465
465
  }
466
466
 
467
+ /** Toggle row for 分步直推, mirroring the group-topic reply toggle pattern. */
468
+ function StepPushEditor({ value = false, disabled = false, onSave }) {
469
+ const titleId = React.useId();
470
+ const current = value === true ? "on" : "off";
471
+ const [saving, setSaving] = React.useState(false);
472
+ const [error, setError] = React.useState(null);
473
+
474
+ const change = async (event) => {
475
+ const next = event.target.value === "on";
476
+ if ((next ? "on" : "off") === current || saving || disabled) return;
477
+ setSaving(true);
478
+ setError(null);
479
+ try {
480
+ await onSave?.(next);
481
+ } catch (cause) {
482
+ setError(cause?.message ?? "分步直推设置保存失败,请重试。");
483
+ } finally {
484
+ setSaving(false);
485
+ }
486
+ };
487
+
488
+ return h("section", {
489
+ className: "dim-feishuGroupControl",
490
+ "aria-labelledby": titleId,
491
+ },
492
+ h("div", { className: "dim-feishuGroupControlHeader" },
493
+ h("h3", { id: titleId }, "分步直推"),
494
+ saving
495
+ ? h("span", { className: "dim-feishuGroupControlStatus", role: "status" }, "保存中…")
496
+ : null),
497
+ h("select", {
498
+ className: "dim-feishuGroupSelect",
499
+ value: current,
500
+ disabled: disabled || saving,
501
+ "aria-label": "分步直推",
502
+ onChange: (event) => { void change(event); },
503
+ },
504
+ h("option", { value: "off" }, "关闭(保持流式卡模式)"),
505
+ h("option", { value: "on" }, "开启(逐步推送工具调用与过程说明)")),
506
+ h("p", { className: "dim-feishuGroupHelp" },
507
+ "开启后逐步推送工具调用与过程说明"),
508
+ error ? h("p", {
509
+ className: "dim-feishuGroupError",
510
+ role: "alert",
511
+ }, error) : null);
512
+ }
513
+
467
514
  export function BotCard({
468
515
  connection,
469
516
  busy,
@@ -479,6 +526,7 @@ export function BotCard({
479
526
  onModelSave,
480
527
  onAgentPresetSave,
481
528
  onContextEnhancementSave,
529
+ onStepPushSave,
482
530
  onRequestRemove,
483
531
  onConfirmRemove,
484
532
  onCancelRemove,
@@ -565,6 +613,11 @@ export function BotCard({
565
613
  disabled: Boolean(busy),
566
614
  onSave: onContextEnhancementSave,
567
615
  }),
616
+ h(StepPushEditor, {
617
+ value: connection.stepPush,
618
+ disabled: Boolean(busy),
619
+ onSave: onStepPushSave,
620
+ }),
568
621
  provisionContent
569
622
  ? h("section", {
570
623
  className: "bxf-botProvision dim-botProvision",
@@ -657,6 +710,7 @@ function BotList(props) {
657
710
  onModelSave: (model) => props.onModelSave(bot, model),
658
711
  onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
659
712
  onContextEnhancementSave: (config) => props.onContextEnhancementSave(bot, config),
713
+ onStepPushSave: (stepPush) => props.onStepPushSave(bot, stepPush),
660
714
  onRequestRemove: () => props.onRequestRemove(bot),
661
715
  onConfirmRemove: () => props.onConfirmRemove(bot),
662
716
  onCancelRemove: props.onCancelRemove,
@@ -1431,6 +1485,9 @@ export function FeishuSettingsTab({ rpcCall }) {
1431
1485
  onContextEnhancementSave: (connection, config) => saveBotSetting(
1432
1486
  connection, "context-enhancement", FEISHU_ENDPOINTS.setContextEnhancement, { config },
1433
1487
  ),
1488
+ onStepPushSave: (connection, stepPush) => saveBotSetting(
1489
+ connection, "step-push", FEISHU_ENDPOINTS.setStepPush, { stepPush },
1490
+ ),
1434
1491
  onRequestRemove: requestRemove,
1435
1492
  onConfirmRemove: (bot) => void confirmRemove(bot),
1436
1493
  onCancelRemove: cancelRemove,
@@ -220,6 +220,11 @@ const EN = Object.freeze({
220
220
  '关闭(群内直接回复)': 'Off (reply in the group feed)',
221
221
  '开启(自动开启独立飞书话题)': 'On (auto-open a dedicated Feishu topic)',
222
222
  '开启后,群聊中向机器人提问会自动开启独立飞书话题,回复落在话题内;每个话题是 dsh 会话列表里一条独立会话,上下文互不串。私聊不受影响。': 'When enabled, questions to the bot in a group auto-open a dedicated Feishu topic and replies stay inside it. Each topic is an independent conversation in dsh with its own context. Private chats are unaffected.',
223
+ '分步直推': 'Step push',
224
+ '开启后逐步推送工具调用与过程说明': 'Push tool calls and process notes step by step',
225
+ '关闭(保持流式卡模式)': 'Off (keep the streaming card)',
226
+ '开启(逐步推送工具调用与过程说明)': 'On (push tool calls and process notes step by step)',
227
+ '分步直推设置保存失败,请重试。': 'Could not save the step push setting. Try again.',
223
228
  '钉钉设置': 'DingTalk settings',
224
229
  '企业微信设置': 'WeCom settings',
225
230
  '扫码接入机器人': 'Scan QR code',
@@ -355,7 +355,8 @@ const CSS = String.raw`
355
355
  .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 32px; padding: 0 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }
356
356
  .dim-panel .dim-cardActions .dim-cardAction:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
357
357
  .dim-panel .dim-cardActions .dim-cardAction[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); }
358
- .dim-panel .dim-botCard { position: relative; min-width: 0; width: 100%; max-width: 100%; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); }
358
+ /* Header tooltips may extend beyond the card; the collapsible body clips its own content. */
359
+ .dim-panel .dim-botCard { position: relative; min-width: 0; width: 100%; max-width: 100%; overflow: visible; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); }
359
360
  .dim-panel .dim-botCard::before { display: none; }
360
361
  .dim-panel .dim-botCardBody { position: relative; min-width: 0; width: 100%; max-width: 100%; padding: 12px; }
361
362
  .dim-collapsibleAccount { min-width: 0; display: flex; flex-direction: column; }
@@ -218,6 +218,7 @@ export async function createProductionController(ctx, config = {}, internals = {
218
218
  botOpenId: botConfig.botOpenId,
219
219
  groupResponseMode: botConfig.groupResponseMode,
220
220
  groupTopicReply: botConfig.groupTopicReply,
221
+ stepPush: botConfig.stepPush,
221
222
  ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
222
223
  harness: workspaceScope.harness,
223
224
  state: workspaceScope.state,
@@ -294,6 +294,7 @@ function publicBotEntry(entry) {
294
294
  accessPolicy: normalizeAccessPolicy(source.accessPolicy),
295
295
  groupResponseMode: normalizeFeishuGroupResponseMode(source.groupResponseMode),
296
296
  groupTopicReply: source.groupTopicReply === true,
297
+ stepPush: source.stepPush === true,
297
298
  groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
298
299
  bot: publicBot(source.bot),
299
300
  health: publicHealth(source, connected),
@@ -458,6 +459,13 @@ function validPayload(endpoint, payload) {
458
459
  ? null
459
460
  : '请选择是否以话题方式回复。';
460
461
  }
462
+ if (endpoint === FEISHU_ENDPOINTS.setStepPush) {
463
+ return hasOnlyKeys(payload, new Set(['botId', 'stepPush']))
464
+ && safeOpaqueId(payload.botId)
465
+ && typeof payload.stepPush === 'boolean'
466
+ ? null
467
+ : '请选择是否分步直推。';
468
+ }
461
469
  return 'Unknown Feishu endpoint.';
462
470
  }
463
471
 
@@ -742,6 +750,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
742
750
  await controller.updateGroupTopicReply(payload.botId, payload.groupTopicReply),
743
751
  { encodeQr: cachedEncodeQr },
744
752
  );
753
+ } else if (endpoint === FEISHU_ENDPOINTS.setStepPush) {
754
+ if (typeof controller.updateStepPush !== 'function') {
755
+ throw new Error('Step push update is unavailable');
756
+ }
757
+ value = await toPublicFeishuStatus(
758
+ await controller.updateStepPush(payload.botId, payload.stepPush),
759
+ { encodeQr: cachedEncodeQr },
760
+ );
745
761
  } else {
746
762
  if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
747
763
  value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
@@ -22,19 +22,26 @@ export function createHarnessCommandExecutor(ctx, provided) {
22
22
  try {
23
23
  return await gateway.invoke(request);
24
24
  } catch (error) {
25
- // Newer Hosts require images; older Hosts reject that field before
26
- // invoking the command. Retry only that exact pre-dispatch failure so
25
+ // Host versions use no attachments, images, or submittedAttachments.
26
+ // Adapt only exact descriptor rejections before command dispatch, so
27
27
  // a business failure can never cause compaction to run twice.
28
28
  if (error?.name !== 'TypertGatewayError'
29
- || error.code !== 'arguments-invalid'
30
- || error.endpoint !== 'commands/execute'
31
- || error.message !== 'typert gateway: commands/execute: args fields do not match the descriptor: unexpected "images"') {
29
+ || !['arguments-invalid', 'gateway/arguments-invalid'].includes(error.code)
30
+ || error.endpoint !== 'commands/execute') {
31
+ throw error;
32
+ }
33
+ let args;
34
+ if (error.message === 'typert gateway: commands/execute: args fields do not match the descriptor: unexpected "images"') {
35
+ args = { agentId: sessionId, line };
36
+ } else if (error.message === 'typert gateway: commands/execute: args fields do not match the descriptor: missing "submittedAttachments"; unexpected "images"') {
37
+ args = { agentId: sessionId, line, submittedAttachments: [] };
38
+ } else {
32
39
  throw error;
33
40
  }
34
41
  options.signal?.throwIfAborted();
35
42
  return gateway.invoke({
36
43
  ...request,
37
- args: { agentId: sessionId, line },
44
+ args,
38
45
  });
39
46
  }
40
47
  };
@@ -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
  }