@soimy/dingtalk 3.6.5 → 3.6.7

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 (41) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +773 -136
  3. package/dist/index.js.map +4 -4
  4. package/dist/src/card/ask-user-question-context.d.ts +5 -2
  5. package/dist/src/card/ask-user-question-context.d.ts.map +1 -1
  6. package/dist/src/card/ask-user-question-store.d.ts +42 -0
  7. package/dist/src/card/ask-user-question-store.d.ts.map +1 -0
  8. package/dist/src/card/ask-user-question.d.ts +20 -0
  9. package/dist/src/card/ask-user-question.d.ts.map +1 -1
  10. package/dist/src/card/card-action-handler.d.ts +1 -0
  11. package/dist/src/card/card-action-handler.d.ts.map +1 -1
  12. package/dist/src/card/card-template.d.ts +1 -1
  13. package/dist/src/card/task-model-metadata.d.ts +11 -0
  14. package/dist/src/card/task-model-metadata.d.ts.map +1 -0
  15. package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
  16. package/dist/src/inbound-handler.d.ts.map +1 -1
  17. package/dist/src/reply-strategy-card.d.ts.map +1 -1
  18. package/dist/src/reply-strategy-types.d.ts +1 -0
  19. package/dist/src/reply-strategy-types.d.ts.map +1 -1
  20. package/dist/src/session-state.d.ts +11 -5
  21. package/dist/src/session-state.d.ts.map +1 -1
  22. package/dist/src/targeting/agent-routing.d.ts +8 -1
  23. package/dist/src/targeting/agent-routing.d.ts.map +1 -1
  24. package/dist/src/types.d.ts +13 -0
  25. package/dist/src/types.d.ts.map +1 -1
  26. package/docs/assets/dingtalk-ask-user-card-template.json +6 -0
  27. package/package.json +2 -1
  28. package/src/access-control.ts +1 -1
  29. package/src/card/ask-user-question-context.ts +9 -1
  30. package/src/card/ask-user-question-store.ts +294 -0
  31. package/src/card/ask-user-question.ts +380 -34
  32. package/src/card/card-action-handler.ts +2 -0
  33. package/src/card/card-template.ts +1 -1
  34. package/src/card/task-model-metadata.ts +51 -0
  35. package/src/gateway/channel-gateway.ts +19 -0
  36. package/src/inbound-handler.ts +131 -49
  37. package/src/reply-strategy-card.ts +30 -6
  38. package/src/reply-strategy-types.ts +1 -0
  39. package/src/session-state.ts +44 -13
  40. package/src/targeting/agent-routing.ts +67 -19
  41. package/src/types.ts +14 -0
package/dist/index.js CHANGED
@@ -790,7 +790,7 @@ function cleanupOrphanedTempFiles(log) {
790
790
  let cleaned = 0;
791
791
  try {
792
792
  const files = fs.readdirSync(tempDir);
793
- const now = Date.now();
793
+ const now2 = Date.now();
794
794
  const maxAge = 24 * 60 * 60 * 1e3;
795
795
  for (const file of files) {
796
796
  if (!dingtalkPattern.test(file)) {
@@ -799,7 +799,7 @@ function cleanupOrphanedTempFiles(log) {
799
799
  const filePath = path2.join(tempDir, file);
800
800
  try {
801
801
  const stats = fs.statSync(filePath);
802
- if (now - stats.mtime.getTime() > maxAge) {
802
+ if (now2 - stats.mtime.getTime() > maxAge) {
803
803
  fs.unlinkSync(filePath);
804
804
  cleaned++;
805
805
  log?.debug?.(`[DingTalk] Cleaned up orphaned temp file: ${file}`);
@@ -846,9 +846,9 @@ function getCurrentTimestamp() {
846
846
  var accessTokenCache = /* @__PURE__ */ new Map();
847
847
  async function getAccessToken(config, log) {
848
848
  const cacheKey = config.clientId;
849
- const now = Date.now();
849
+ const now2 = Date.now();
850
850
  const cached = accessTokenCache.get(cacheKey);
851
- if (cached && cached.expiry > now + 6e4) {
851
+ if (cached && cached.expiry > now2 + 6e4) {
852
852
  return cached.accessToken;
853
853
  }
854
854
  const runtimeConfig = await resolveRuntimeConfig(config, log);
@@ -863,7 +863,7 @@ async function getAccessToken(config, log) {
863
863
  );
864
864
  accessTokenCache.set(cacheKey, {
865
865
  accessToken: response.data.accessToken,
866
- expiry: now + response.data.expireIn * 1e3
866
+ expiry: now2 + response.data.expireIn * 1e3
867
867
  });
868
868
  return response.data.accessToken;
869
869
  },
@@ -1050,7 +1050,7 @@ function normalizeAllowFrom(list) {
1050
1050
  function isSenderAllowed(params) {
1051
1051
  const { allow, senderId } = params;
1052
1052
  if (!allow.hasEntries) {
1053
- return true;
1053
+ return false;
1054
1054
  }
1055
1055
  if (allow.hasWildcard) {
1056
1056
  return true;
@@ -1667,7 +1667,7 @@ var BUILTIN_DINGTALK_CARD_TEMPLATE_ID = process.env.DINGTALK_CARD_TEMPLATE_ID ||
1667
1667
  var BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
1668
1668
  var BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
1669
1669
  var BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
1670
- var BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID = "89d0c6fe-3822-44c8-950e-e950f562546d.schema";
1670
+ var BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID = "c2a6355b-9724-4f7e-9653-d33fcb3311bb.schema";
1671
1671
  var DINGTALK_CARD_TEMPLATE = Object.freeze({
1672
1672
  templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
1673
1673
  contentKey: BUILTIN_DINGTALK_CARD_CONTENT_KEY,
@@ -3365,9 +3365,9 @@ function ensureSweepTimer() {
3365
3365
  return;
3366
3366
  }
3367
3367
  sweepTimer = setInterval(() => {
3368
- const now = Date.now();
3368
+ const now2 = Date.now();
3369
3369
  for (const [key, record] of records) {
3370
- if (now - record.registeredAt > CARD_RUN_TTL_MS) {
3370
+ if (now2 - record.registeredAt > CARD_RUN_TTL_MS) {
3371
3371
  records.delete(key);
3372
3372
  }
3373
3373
  }
@@ -3514,6 +3514,36 @@ function renderStatusLine(data, config) {
3514
3514
  return lines.join("\n");
3515
3515
  }
3516
3516
 
3517
+ // src/card/task-model-metadata.ts
3518
+ function readPrimaryModelRef(value) {
3519
+ if (typeof value === "string") {
3520
+ return value.trim() || void 0;
3521
+ }
3522
+ if (value && typeof value === "object" && typeof value.primary === "string") {
3523
+ return value.primary.trim() || void 0;
3524
+ }
3525
+ return void 0;
3526
+ }
3527
+ function normalizeModelDisplayName(modelRef) {
3528
+ const trimmed = typeof modelRef === "string" ? modelRef.trim() : "";
3529
+ if (!trimmed) {
3530
+ return void 0;
3531
+ }
3532
+ const parts = trimmed.split("/").map((part) => part.trim()).filter(Boolean);
3533
+ return parts.at(-1) || void 0;
3534
+ }
3535
+ function resolveConfiguredTaskModelMetadata(params) {
3536
+ const agents = params.cfg.agents;
3537
+ const agentId = String(params.agentId || "").trim();
3538
+ const agent = Array.isArray(agents?.list) ? agents.list.find((entry) => String(entry.id || "").trim() === agentId) : void 0;
3539
+ const modelRef = readPrimaryModelRef(agent?.model) ?? readPrimaryModelRef(agents?.defaults?.model);
3540
+ const effort = typeof agent?.thinkingDefault === "string" && agent.thinkingDefault.trim() ? agent.thinkingDefault.trim() : typeof agents?.defaults?.thinkingDefault === "string" && agents.defaults.thinkingDefault.trim() ? agents.defaults.thinkingDefault.trim() : void 0;
3541
+ return {
3542
+ model: normalizeModelDisplayName(modelRef),
3543
+ effort
3544
+ };
3545
+ }
3546
+
3517
3547
  // src/command/card-stop-command.ts
3518
3548
  import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth";
3519
3549
 
@@ -8528,27 +8558,43 @@ function createCardDraftController(params) {
8528
8558
 
8529
8559
  // src/session-state.ts
8530
8560
  var sessionStore = /* @__PURE__ */ new Map();
8531
- function sessionKey(accountId, conversationId) {
8532
- return `${accountId}:${conversationId}`;
8561
+ function sessionKey(scope) {
8562
+ return `${scope.accountId}:${scope.agentId}:${scope.conversationId}`;
8563
+ }
8564
+ function normalizeMetadataValue(value) {
8565
+ const trimmed = typeof value === "string" ? value.trim() : "";
8566
+ return trimmed || void 0;
8533
8567
  }
8534
- function initSessionState(accountId, conversationId) {
8535
- const key = sessionKey(accountId, conversationId);
8568
+ function seedMissingSessionStateMetadata(state, metadata) {
8569
+ const model = normalizeMetadataValue(metadata?.model);
8570
+ const effort = normalizeMetadataValue(metadata?.effort);
8571
+ if (state.model === void 0 && model !== void 0) {
8572
+ state.model = model;
8573
+ }
8574
+ if (state.effort === void 0 && effort !== void 0) {
8575
+ state.effort = effort;
8576
+ }
8577
+ }
8578
+ function initSessionState(scope, metadata) {
8579
+ const key = sessionKey(scope);
8536
8580
  const existing = sessionStore.get(key);
8537
8581
  if (existing) {
8538
8582
  existing.taskStartTime = Date.now();
8583
+ seedMissingSessionStateMetadata(existing, metadata);
8539
8584
  return existing;
8540
8585
  }
8541
8586
  const state = {
8542
8587
  taskStartTime: Date.now()
8543
8588
  };
8589
+ seedMissingSessionStateMetadata(state, metadata);
8544
8590
  sessionStore.set(key, state);
8545
8591
  return state;
8546
8592
  }
8547
- function getSessionState(accountId, conversationId) {
8548
- return sessionStore.get(sessionKey(accountId, conversationId));
8593
+ function getSessionState(scope) {
8594
+ return sessionStore.get(sessionKey(scope));
8549
8595
  }
8550
- function updateSessionState(accountId, conversationId, patch) {
8551
- const state = sessionStore.get(sessionKey(accountId, conversationId));
8596
+ function updateSessionState(scope, patch) {
8597
+ const state = sessionStore.get(sessionKey(scope));
8552
8598
  if (!state) {
8553
8599
  return;
8554
8600
  }
@@ -8559,8 +8605,8 @@ function updateSessionState(accountId, conversationId, patch) {
8559
8605
  state.effort = patch.effort;
8560
8606
  }
8561
8607
  }
8562
- function getTaskTimeSeconds(accountId, conversationId) {
8563
- const state = sessionStore.get(sessionKey(accountId, conversationId));
8608
+ function getTaskTimeSeconds(scope) {
8609
+ const state = sessionStore.get(sessionKey(scope));
8564
8610
  if (!state) {
8565
8611
  return void 0;
8566
8612
  }
@@ -8618,12 +8664,17 @@ var EMPTY_FINAL_REPLY = "\u2705 Done";
8618
8664
  var DEFAULT_CARD_FAILED_MESSAGE = "\u56DE\u590D\u751F\u6210\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5";
8619
8665
  function createCardReplyStrategy(ctx) {
8620
8666
  const { card, config, log, isStopRequested } = ctx;
8667
+ const sessionTaskStateScope = card.accountId && card.conversationId && ctx.sessionAgentId ? {
8668
+ accountId: card.accountId,
8669
+ conversationId: card.contextConversationId || card.conversationId,
8670
+ agentId: ctx.sessionAgentId
8671
+ } : void 0;
8621
8672
  const buildStatusLine = () => {
8622
8673
  if (!ctx.taskMeta) {
8623
8674
  return void 0;
8624
8675
  }
8625
8676
  const resolvedUsage = typeof card.dapiUsage === "number" ? card.dapiUsage : typeof ctx.taskMeta.usage === "number" ? ctx.taskMeta.usage : void 0;
8626
- const sessionTaskTimeSeconds = card.accountId && card.conversationId ? getTaskTimeSeconds(card.accountId, card.contextConversationId || card.conversationId) : void 0;
8677
+ const sessionTaskTimeSeconds = sessionTaskStateScope ? getTaskTimeSeconds(sessionTaskStateScope) : void 0;
8627
8678
  const cardElapsedMs = Math.max(0, Date.now() - card.createdAt);
8628
8679
  const sessionElapsedMs = typeof sessionTaskTimeSeconds === "number" ? sessionTaskTimeSeconds * 1e3 : void 0;
8629
8680
  const metaElapsedMs = typeof ctx.taskMeta.elapsedMs === "number" && ctx.taskMeta.elapsedMs > 0 ? ctx.taskMeta.elapsedMs : void 0;
@@ -8938,10 +8989,12 @@ function createCardReplyStrategy(ctx) {
8938
8989
  if (!card.accountId || !card.conversationId) {
8939
8990
  return;
8940
8991
  }
8941
- updateSessionState(card.accountId, card.contextConversationId || card.conversationId, {
8942
- model: selected.model,
8943
- effort: selected.thinkLevel
8944
- });
8992
+ if (sessionTaskStateScope) {
8993
+ updateSessionState(sessionTaskStateScope, {
8994
+ model: selected.model,
8995
+ effort: selected.thinkLevel
8996
+ });
8997
+ }
8945
8998
  if (ctx.taskMeta) {
8946
8999
  ctx.taskMeta.model = selected.model;
8947
9000
  ctx.taskMeta.effort = selected.thinkLevel;
@@ -8964,6 +9017,13 @@ function createCardReplyStrategy(ctx) {
8964
9017
  }
8965
9018
  }
8966
9019
  if (payload.kind === "final") {
9020
+ const hasPreservableReply = Boolean(
9021
+ finalTextForFallback?.trim() || controller.getFinalAnswerContent().trim()
9022
+ ) || processedMediaUrls.size > 0 || pendingNonImageMedia.length > 0;
9023
+ if (payload.isError === true && sawFinalDelivery && payload.mediaUrls.length === 0 && hasPreservableReply) {
9024
+ log?.debug?.("[DingTalk][Finalize] Ignoring late error final after answer delivery");
9025
+ return;
9026
+ }
8967
9027
  const isFirstFinalDelivery = !sawFinalDelivery;
8968
9028
  lifecycleState = "final_seen";
8969
9029
  await flushPendingReasoning();
@@ -9729,10 +9789,61 @@ async function dispatchSubAgents(params) {
9729
9789
  dingtalkConfig,
9730
9790
  sessionWebhook,
9731
9791
  extractedContent,
9792
+ sessionPeer,
9793
+ onRoutesResolved,
9732
9794
  handleMessage,
9733
9795
  downloadMedia: download,
9734
9796
  log
9735
9797
  } = params;
9798
+ let helperMissingWarningSent = false;
9799
+ const sendHelperMissingWarning = async () => {
9800
+ if (helperMissingWarningSent) {
9801
+ return;
9802
+ }
9803
+ helperMissingWarningSent = true;
9804
+ try {
9805
+ const isGroup = data.conversationType !== "1";
9806
+ const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
9807
+ await sendBySession(
9808
+ dingtalkConfig,
9809
+ sessionWebhook,
9810
+ "\u26A0\uFE0F \u5F53\u524D\u5BBF\u4E3B\u7248\u672C\u4E0D\u652F\u6301 DingTalk \u5B50\u52A9\u624B\u8DEF\u7531\u6240\u9700\u7684 session helper\uFF0C\u8BF7\u5347\u7EA7 OpenClaw \u540E\u91CD\u8BD5\u3002",
9811
+ sendOptions
9812
+ );
9813
+ } catch (notifyError) {
9814
+ log?.debug?.(
9815
+ `[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`
9816
+ );
9817
+ }
9818
+ };
9819
+ let resolvedTargets;
9820
+ try {
9821
+ const rt = getDingTalkRuntime();
9822
+ resolvedTargets = matchedAgents.map((agent) => ({
9823
+ agent,
9824
+ route: {
9825
+ agentId: agent.agentId,
9826
+ sessionKey: buildAgentSessionKey({
9827
+ rt,
9828
+ cfg,
9829
+ accountId,
9830
+ agentId: agent.agentId,
9831
+ peerKind: sessionPeer.kind,
9832
+ peerId: sessionPeer.peerId
9833
+ }),
9834
+ mainSessionKey: ""
9835
+ }
9836
+ }));
9837
+ } catch (error) {
9838
+ const message = getErrorMessage(error);
9839
+ log?.error?.(`[DingTalk] Failed to resolve sub-agent routes: ${message}`);
9840
+ if (error instanceof HostRoutingHelperUnavailableError) {
9841
+ await sendHelperMissingWarning();
9842
+ return;
9843
+ }
9844
+ throw error;
9845
+ }
9846
+ onRoutesResolved?.(resolvedTargets);
9736
9847
  let preDownloadedMedia;
9737
9848
  const robotCode = resolveRobotCode(dingtalkConfig);
9738
9849
  if (robotCode) {
@@ -9755,8 +9866,8 @@ async function dispatchSubAgents(params) {
9755
9866
  };
9756
9867
  }
9757
9868
  }
9758
- let helperMissingWarningSent = false;
9759
- for (const agentMatch of matchedAgents) {
9869
+ for (const target of resolvedTargets) {
9870
+ const agentMatch = target.agent;
9760
9871
  try {
9761
9872
  await handleMessage({
9762
9873
  cfg,
@@ -9765,6 +9876,7 @@ async function dispatchSubAgents(params) {
9765
9876
  sessionWebhook,
9766
9877
  log,
9767
9878
  dingtalkConfig,
9879
+ routeOverride: target.route,
9768
9880
  subAgentOptions: {
9769
9881
  agentId: agentMatch.agentId,
9770
9882
  responsePrefix: commandText ? "" : `> \u{1F916} **${sanitizeAgentName(agentMatch.matchedName)}**:
@@ -9778,22 +9890,8 @@ async function dispatchSubAgents(params) {
9778
9890
  } catch (error) {
9779
9891
  const message = getErrorMessage(error);
9780
9892
  log?.error?.(`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`);
9781
- if (error instanceof HostRoutingHelperUnavailableError && !helperMissingWarningSent) {
9782
- helperMissingWarningSent = true;
9783
- try {
9784
- const isGroup = data.conversationType !== "1";
9785
- const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
9786
- await sendBySession(
9787
- dingtalkConfig,
9788
- sessionWebhook,
9789
- "\u26A0\uFE0F \u5F53\u524D\u5BBF\u4E3B\u7248\u672C\u4E0D\u652F\u6301 DingTalk \u5B50\u52A9\u624B\u8DEF\u7531\u6240\u9700\u7684 session helper\uFF0C\u8BF7\u5347\u7EA7 OpenClaw \u540E\u91CD\u8BD5\u3002",
9790
- sendOptions
9791
- );
9792
- } catch (notifyError) {
9793
- log?.debug?.(
9794
- `[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`
9795
- );
9796
- }
9893
+ if (error instanceof HostRoutingHelperUnavailableError) {
9894
+ await sendHelperMissingWarning();
9797
9895
  }
9798
9896
  }
9799
9897
  }
@@ -10495,6 +10593,8 @@ async function handleDingTalkMessageInner(params) {
10495
10593
  sessionWebhook,
10496
10594
  log,
10497
10595
  dingtalkConfig,
10596
+ inboundOrigin = "stream",
10597
+ routeOverride,
10498
10598
  subAgentOptions,
10499
10599
  preDownloadedMedia
10500
10600
  } = params;
@@ -10689,32 +10789,42 @@ async function handleDingTalkMessageInner(params) {
10689
10789
  config: dingtalkConfig
10690
10790
  });
10691
10791
  const messageTarget = subAgentOptions ? null : resolveMessageTarget({ extractedContent, cfg, isGroup });
10692
- let route;
10693
- if (subAgentOptions) {
10694
- route = {
10695
- agentId: subAgentOptions.agentId,
10696
- sessionKey: buildAgentSessionKey({
10697
- rt,
10698
- cfg,
10699
- accountId,
10700
- agentId: subAgentOptions.agentId,
10701
- peerKind: sessionPeer.kind,
10702
- peerId: sessionPeer.peerId
10703
- }),
10704
- mainSessionKey: ""
10705
- };
10706
- } else {
10707
- route = rt.channel.routing.resolveAgentRoute({
10708
- cfg,
10709
- channel: "dingtalk",
10710
- accountId,
10711
- peer: { kind: sessionPeer.kind, id: sessionPeer.peerId }
10792
+ const invalidateQuestionRoutes = (routes) => {
10793
+ if (inboundOrigin === "ask-user") {
10794
+ return;
10795
+ }
10796
+ const scopeKeys = new Set(
10797
+ routes.map((resolvedRoute) => `${accountId}:${resolvedRoute.sessionKey}:${senderId}`)
10798
+ );
10799
+ const invalidatedRecords = [];
10800
+ for (const questionScopeKey of scopeKeys) {
10801
+ try {
10802
+ invalidatedRecords.push(
10803
+ ...invalidateAskUserQuestionsForScope({
10804
+ storePath: accountStorePath,
10805
+ accountId,
10806
+ questionScopeKey,
10807
+ reason: "superseded_by_message",
10808
+ log
10809
+ })
10810
+ );
10811
+ } catch (err) {
10812
+ log?.warn?.(
10813
+ `[DingTalk][AskUser] Failed to invalidate pending cards before inbound dispatch scope=${questionScopeKey}: ${String(err)}`
10814
+ );
10815
+ }
10816
+ }
10817
+ if (invalidatedRecords.length === 0) {
10818
+ return;
10819
+ }
10820
+ void syncInvalidatedAskUserQuestionCards({
10821
+ records: invalidatedRecords,
10822
+ config: dingtalkConfig,
10823
+ log
10824
+ }).catch((err) => {
10825
+ log?.warn?.(`[DingTalk][AskUser] Card invalidation sync failed: ${String(err)}`);
10712
10826
  });
10713
- }
10714
- const questionContext = getDingTalkQuestionContext();
10715
- if (questionContext) {
10716
- questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
10717
- }
10827
+ };
10718
10828
  if (messageTarget && messageTarget.kind !== "default") {
10719
10829
  if (messageTarget.kind === "subagent-command") {
10720
10830
  await dispatchSubAgents({
@@ -10726,6 +10836,8 @@ async function handleDingTalkMessageInner(params) {
10726
10836
  dingtalkConfig,
10727
10837
  sessionWebhook,
10728
10838
  extractedContent,
10839
+ sessionPeer,
10840
+ onRoutesResolved: (targets) => invalidateQuestionRoutes(targets.map((target) => target.route)),
10729
10841
  handleMessage: handleDingTalkMessage,
10730
10842
  downloadMedia,
10731
10843
  log
@@ -10754,6 +10866,8 @@ async function handleDingTalkMessageInner(params) {
10754
10866
  dingtalkConfig,
10755
10867
  sessionWebhook,
10756
10868
  extractedContent,
10869
+ sessionPeer,
10870
+ onRoutesResolved: (targets) => invalidateQuestionRoutes(targets.map((target) => target.route)),
10757
10871
  handleMessage: handleDingTalkMessage,
10758
10872
  downloadMedia,
10759
10873
  log
@@ -10761,6 +10875,44 @@ async function handleDingTalkMessageInner(params) {
10761
10875
  return;
10762
10876
  }
10763
10877
  }
10878
+ let route;
10879
+ if (routeOverride) {
10880
+ route = routeOverride;
10881
+ } else if (subAgentOptions) {
10882
+ route = {
10883
+ agentId: subAgentOptions.agentId,
10884
+ sessionKey: buildAgentSessionKey({
10885
+ rt,
10886
+ cfg,
10887
+ accountId,
10888
+ agentId: subAgentOptions.agentId,
10889
+ peerKind: sessionPeer.kind,
10890
+ peerId: sessionPeer.peerId
10891
+ }),
10892
+ mainSessionKey: ""
10893
+ };
10894
+ } else {
10895
+ route = rt.channel.routing.resolveAgentRoute({
10896
+ cfg,
10897
+ channel: "dingtalk",
10898
+ accountId,
10899
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId }
10900
+ });
10901
+ }
10902
+ const questionContext = getDingTalkQuestionContext();
10903
+ if (questionContext) {
10904
+ questionContext.resolvedRoute = route;
10905
+ questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
10906
+ questionContext.storePath = accountStorePath;
10907
+ questionContext.continuationSubAgentOptions = subAgentOptions ? {
10908
+ agentId: subAgentOptions.agentId,
10909
+ responsePrefix: subAgentOptions.responsePrefix,
10910
+ matchedName: subAgentOptions.matchedName
10911
+ } : void 0;
10912
+ }
10913
+ if (!subAgentOptions) {
10914
+ invalidateQuestionRoutes([route]);
10915
+ }
10764
10916
  const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
10765
10917
  agentId: route.agentId
10766
10918
  });
@@ -10798,16 +10950,26 @@ async function handleDingTalkMessageInner(params) {
10798
10950
  const content = extractedContent;
10799
10951
  const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
10800
10952
  const taskInfoConversationId = groupId || to;
10801
- const sessionTaskState = initSessionState(accountId, taskInfoConversationId);
10953
+ const agentDisplayName = getAgentDisplayName({
10954
+ subAgentOptions,
10955
+ agentId: route.agentId,
10956
+ agentsList: cfg.agents?.list
10957
+ });
10958
+ const configuredTaskMetadata = resolveConfiguredTaskModelMetadata({
10959
+ cfg,
10960
+ agentId: route.agentId
10961
+ });
10962
+ const sessionTaskStateScope = {
10963
+ accountId,
10964
+ conversationId: taskInfoConversationId,
10965
+ agentId: route.agentId
10966
+ };
10967
+ const sessionTaskState = initSessionState(sessionTaskStateScope, configuredTaskMetadata);
10802
10968
  const initialStatusLine = renderStatusLine(
10803
10969
  {
10804
10970
  model: sessionTaskState.model,
10805
10971
  effort: sessionTaskState.effort,
10806
- agent: getAgentDisplayName({
10807
- subAgentOptions,
10808
- agentId: route.agentId,
10809
- agentsList: cfg.agents?.list
10810
- })
10972
+ agent: agentDisplayName
10811
10973
  },
10812
10974
  dingtalkConfig
10813
10975
  ) || void 0;
@@ -10817,7 +10979,6 @@ async function handleDingTalkMessageInner(params) {
10817
10979
  let cardFlightKey;
10818
10980
  if (questionContext) {
10819
10981
  questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
10820
- questionCardTookOver = true;
10821
10982
  if (cardFlightKey) {
10822
10983
  cardCreationInFlight.delete(cardFlightKey);
10823
10984
  cardFlightKey = void 0;
@@ -10838,23 +10999,34 @@ async function handleDingTalkMessageInner(params) {
10838
10999
  log?.warn?.(
10839
11000
  `[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`
10840
11001
  );
11002
+ return false;
10841
11003
  }
11004
+ questionCardTookOver = true;
10842
11005
  if (!currentAICard) {
10843
- return;
11006
+ return true;
10844
11007
  }
10845
11008
  if (isCardInTerminalState(currentAICard.state)) {
10846
- return;
11009
+ return true;
11010
+ }
11011
+ let recalled = false;
11012
+ try {
11013
+ recalled = await recallAICardMessage(currentAICard, log);
11014
+ } catch (err) {
11015
+ log?.warn?.(
11016
+ `[DingTalk][AskUser] Question card took over, but AI card recall errored question=${questionId} outTrackId=${outTrackId}: ${err instanceof Error ? err.message : String(err)}`
11017
+ );
11018
+ return true;
10847
11019
  }
10848
- const recalled = await recallAICardMessage(currentAICard, log);
10849
11020
  if (!recalled) {
10850
11021
  log?.warn?.(
10851
11022
  `[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`
10852
11023
  );
10853
- return;
11024
+ return true;
10854
11025
  }
10855
11026
  log?.info?.(
10856
11027
  `[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`
10857
11028
  );
11029
+ return true;
10858
11030
  };
10859
11031
  }
10860
11032
  if (useCardMode && !isBtwBypass) {
@@ -11768,16 +11940,12 @@ ${attachmentExtractedText}` : inboundBody;
11768
11940
  accountId
11769
11941
  });
11770
11942
  const strategyConfig = legacyCardStreamReasoning === void 0 ? dingtalkConfig : { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
11771
- const sessionTaskState2 = getSessionState(accountId, taskInfoConversationId);
11943
+ const sessionTaskState2 = getSessionState(sessionTaskStateScope);
11772
11944
  const taskMeta = {
11773
11945
  model: sessionTaskState2?.model,
11774
11946
  effort: sessionTaskState2?.effort,
11775
11947
  elapsedMs: typeof sessionTaskState2?.taskStartTime === "number" ? Math.max(0, Date.now() - sessionTaskState2.taskStartTime) : void 0,
11776
- agent: getAgentDisplayName({
11777
- subAgentOptions,
11778
- agentId: route.agentId,
11779
- agentsList: cfg.agents?.list
11780
- })
11948
+ agent: agentDisplayName
11781
11949
  };
11782
11950
  const strategy = createReplyStrategy({
11783
11951
  config: strategyConfig,
@@ -11838,6 +12006,7 @@ ${attachmentExtractedText}` : inboundBody;
11838
12006
  mediaUrls,
11839
12007
  audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
11840
12008
  kind: replyKind,
12009
+ isError: payload.isError === true,
11841
12010
  isReasoning: richPayload.isReasoning === true
11842
12011
  });
11843
12012
  } catch (err) {
@@ -11908,6 +12077,194 @@ ${attachmentExtractedText}` : inboundBody;
11908
12077
  }
11909
12078
  }
11910
12079
 
12080
+ // src/card/ask-user-question-store.ts
12081
+ var ASK_USER_LIFECYCLE_NAMESPACE = "cards.ask-user.lifecycle";
12082
+ var ACTIVE_TTL_MS = 5 * 60 * 1e3;
12083
+ var TOMBSTONE_TTL_MS = 30 * 60 * 1e3;
12084
+ function now(options) {
12085
+ return options.now?.() ?? Date.now();
12086
+ }
12087
+ function emptyState(timestamp) {
12088
+ return { version: 1, updatedAt: timestamp, records: [] };
12089
+ }
12090
+ function isActiveState(state) {
12091
+ return state === "reserved" || state === "pending" || state === "dispatching";
12092
+ }
12093
+ function isAnswerableState(state) {
12094
+ return state === "reserved" || state === "pending";
12095
+ }
12096
+ function loadState2(options) {
12097
+ const timestamp = now(options);
12098
+ const state = readNamespaceJson(ASK_USER_LIFECYCLE_NAMESPACE, {
12099
+ storePath: options.storePath,
12100
+ scope: { accountId: options.accountId },
12101
+ fallback: emptyState(timestamp),
12102
+ log: options.log
12103
+ });
12104
+ if (state.version !== 1 || !Array.isArray(state.records)) {
12105
+ return emptyState(timestamp);
12106
+ }
12107
+ return state;
12108
+ }
12109
+ function persistState(options, state) {
12110
+ state.updatedAt = now(options);
12111
+ writeNamespaceJsonAtomic(ASK_USER_LIFECYCLE_NAMESPACE, {
12112
+ storePath: options.storePath,
12113
+ scope: { accountId: options.accountId },
12114
+ data: state,
12115
+ log: options.log
12116
+ });
12117
+ }
12118
+ function toTerminal(record, reason, timestamp) {
12119
+ record.state = "terminal";
12120
+ record.terminalReason = reason;
12121
+ record.updatedAt = timestamp;
12122
+ record.expiresAt = timestamp + TOMBSTONE_TTL_MS;
12123
+ return record;
12124
+ }
12125
+ function cleanupState(state, timestamp) {
12126
+ let changed = false;
12127
+ for (const record of state.records) {
12128
+ if (isAnswerableState(record.state) && record.expiresAt <= timestamp) {
12129
+ toTerminal(record, "expired", timestamp);
12130
+ changed = true;
12131
+ }
12132
+ }
12133
+ const retained = state.records.filter(
12134
+ (record) => record.state !== "terminal" || record.expiresAt > timestamp
12135
+ );
12136
+ if (retained.length !== state.records.length) {
12137
+ state.records = retained;
12138
+ changed = true;
12139
+ }
12140
+ return changed;
12141
+ }
12142
+ function readCleanState(options) {
12143
+ const state = loadState2(options);
12144
+ if (cleanupState(state, now(options))) {
12145
+ persistState(options, state);
12146
+ }
12147
+ return state;
12148
+ }
12149
+ function findRecord(state, identifier) {
12150
+ if (identifier.outTrackId) {
12151
+ const byTrackId = state.records.find((record) => record.outTrackId === identifier.outTrackId);
12152
+ if (byTrackId) {
12153
+ return byTrackId;
12154
+ }
12155
+ }
12156
+ if (identifier.questionId) {
12157
+ return state.records.find((record) => record.questionId === identifier.questionId);
12158
+ }
12159
+ return void 0;
12160
+ }
12161
+ function reserveAskUserQuestion(options, input) {
12162
+ const timestamp = now(options);
12163
+ const state = readCleanState(options);
12164
+ const record = {
12165
+ questionId: input.questionId,
12166
+ accountId: options.accountId,
12167
+ questionScopeKey: input.questionScopeKey,
12168
+ outTrackId: input.outTrackId,
12169
+ title: input.title,
12170
+ state: "reserved",
12171
+ createdAt: timestamp,
12172
+ updatedAt: timestamp,
12173
+ expiresAt: timestamp + ACTIVE_TTL_MS
12174
+ };
12175
+ state.records = state.records.filter(
12176
+ (item) => item.questionId !== input.questionId && item.outTrackId !== input.outTrackId
12177
+ );
12178
+ state.records.push(record);
12179
+ persistState(options, state);
12180
+ return { ...record };
12181
+ }
12182
+ function activateAskUserQuestion(options, questionId) {
12183
+ const timestamp = now(options);
12184
+ const state = readCleanState(options);
12185
+ const record = findRecord(state, { questionId });
12186
+ if (!record || record.state !== "reserved") {
12187
+ return { record: record ? { ...record } : void 0, superseded: [] };
12188
+ }
12189
+ const superseded = [];
12190
+ for (const candidate of state.records) {
12191
+ if (candidate !== record && isAnswerableState(candidate.state) && candidate.questionScopeKey === record.questionScopeKey) {
12192
+ toTerminal(candidate, "superseded_by_question", timestamp);
12193
+ superseded.push({ ...candidate });
12194
+ }
12195
+ }
12196
+ record.state = "pending";
12197
+ record.updatedAt = timestamp;
12198
+ record.expiresAt = timestamp + ACTIVE_TTL_MS;
12199
+ persistState(options, state);
12200
+ return { record: { ...record }, superseded };
12201
+ }
12202
+ function claimAskUserQuestion(options, identifier) {
12203
+ const timestamp = now(options);
12204
+ const state = readCleanState(options);
12205
+ const record = findRecord(state, identifier);
12206
+ if (!record || record.state !== "pending") {
12207
+ return void 0;
12208
+ }
12209
+ record.state = "dispatching";
12210
+ record.updatedAt = timestamp;
12211
+ record.expiresAt = timestamp + ACTIVE_TTL_MS;
12212
+ persistState(options, state);
12213
+ return { ...record };
12214
+ }
12215
+ function terminateAskUserQuestion(options, questionId, reason) {
12216
+ const timestamp = now(options);
12217
+ const state = readCleanState(options);
12218
+ const record = findRecord(state, { questionId });
12219
+ if (!record || !isActiveState(record.state)) {
12220
+ return void 0;
12221
+ }
12222
+ toTerminal(record, reason, timestamp);
12223
+ persistState(options, state);
12224
+ return { ...record };
12225
+ }
12226
+ function invalidateAskUserQuestionsInScope(options, questionScopeKey, reason) {
12227
+ const timestamp = now(options);
12228
+ const state = readCleanState(options);
12229
+ const invalidated = [];
12230
+ for (const record of state.records) {
12231
+ if (isAnswerableState(record.state) && record.questionScopeKey === questionScopeKey) {
12232
+ toTerminal(record, reason, timestamp);
12233
+ invalidated.push({ ...record });
12234
+ }
12235
+ }
12236
+ if (invalidated.length > 0) {
12237
+ persistState(options, state);
12238
+ }
12239
+ return invalidated;
12240
+ }
12241
+ function resolveAskUserQuestion(options, identifier) {
12242
+ const record = findRecord(readCleanState(options), identifier);
12243
+ return record ? { ...record } : void 0;
12244
+ }
12245
+ function recoverAskUserQuestionsAfterRestart(options) {
12246
+ const timestamp = now(options);
12247
+ const state = loadState2(options);
12248
+ const retained = state.records.filter(
12249
+ (record) => record.state !== "terminal" || record.expiresAt > timestamp
12250
+ );
12251
+ const removedExpiredTombstones = retained.length !== state.records.length;
12252
+ state.records = retained;
12253
+ const recovered = [];
12254
+ for (const record of state.records) {
12255
+ if (!isActiveState(record.state)) {
12256
+ continue;
12257
+ }
12258
+ const reason = record.state === "dispatching" ? "restart_during_dispatch" : "restart_invalidated";
12259
+ toTerminal(record, reason, timestamp);
12260
+ recovered.push({ ...record });
12261
+ }
12262
+ if (recovered.length > 0 || removedExpiredTombstones) {
12263
+ persistState(options, state);
12264
+ }
12265
+ return recovered;
12266
+ }
12267
+
11911
12268
  // src/card/ask-user-question.ts
11912
12269
  var DINGTALK_API4 = "https://api.dingtalk.com";
11913
12270
  var PENDING_QUESTION_TTL_MS = 5 * 60 * 1e3;
@@ -12177,9 +12534,11 @@ function supersedePendingQuestionsInScope(ctx) {
12177
12534
  });
12178
12535
  }
12179
12536
  }
12180
- function storePendingQuestion(ctx) {
12537
+ function storePendingQuestion(ctx, options = {}) {
12181
12538
  ctx.ownerUserId = resolvePendingQuestionOwner(ctx);
12182
- supersedePendingQuestionsInScope(ctx);
12539
+ if (options.supersedeExisting !== false) {
12540
+ supersedePendingQuestionsInScope(ctx);
12541
+ }
12183
12542
  pendingQuestionsByTrackId.set(ctx.outTrackId, ctx);
12184
12543
  pendingQuestionsByQuestionId.set(ctx.questionId, ctx);
12185
12544
  addScopeIndex(ctx);
@@ -12187,7 +12546,9 @@ function storePendingQuestion(ctx) {
12187
12546
  if (!pendingQuestionsByTrackId.has(ctx.outTrackId) || ctx.submitted) {
12188
12547
  return;
12189
12548
  }
12190
- ctx.submitted = true;
12549
+ if (!claimPendingQuestionForDispatch(ctx)) {
12550
+ return;
12551
+ }
12191
12552
  consumePendingQuestion(ctx);
12192
12553
  addHandledQuestionTombstone(ctx, "expired");
12193
12554
  void updateQuestionCardBestEffort(ctx, {
@@ -12195,14 +12556,12 @@ function storePendingQuestion(ctx) {
12195
12556
  question_desc: "\u95EE\u9898\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u3002",
12196
12557
  form_btn_text: "\u5DF2\u5931\u6548"
12197
12558
  });
12198
- setImmediate(() => {
12199
- void injectAnswerSyntheticMessage(ctx, buildExpiredAnswerMessage(ctx), "expired").catch(
12200
- (err) => {
12201
- ctx.log?.error?.(
12202
- `[DingTalk][AskUser] Failed to inject expired answer message: ${String(err)}`
12203
- );
12204
- }
12205
- );
12559
+ dispatchSyntheticAnswer({
12560
+ ctx,
12561
+ text: buildExpiredAnswerMessage(ctx),
12562
+ suffix: "expired",
12563
+ successReason: "expired",
12564
+ log: ctx.log
12206
12565
  });
12207
12566
  }, PENDING_QUESTION_TTL_MS);
12208
12567
  }
@@ -12227,6 +12586,123 @@ async function updateQuestionCardBestEffort(ctx, variables) {
12227
12586
  );
12228
12587
  }
12229
12588
  }
12589
+ function getAskUserStoreOptions(params) {
12590
+ if (!params.storePath) {
12591
+ return void 0;
12592
+ }
12593
+ return {
12594
+ storePath: params.storePath,
12595
+ accountId: params.accountId,
12596
+ log: params.log
12597
+ };
12598
+ }
12599
+ function terminalCardVariables(reason) {
12600
+ const descriptions = {
12601
+ delivery_failed: "\u95EE\u9898\u5361\u7247\u53D1\u9001\u5931\u8D25\u3002",
12602
+ superseded_by_question: "\u5DF2\u6709\u65B0\u7684\u95EE\u9898\u5361\u7247\uFF0C\u8BF7\u56DE\u7B54\u6700\u65B0\u5361\u7247\u3002",
12603
+ superseded_by_message: "\u4F60\u5728\u95EE\u9898\u5361\u7247\u53D1\u51FA\u540E\u53D1\u9001\u4E86\u65B0\u6D88\u606F\uFF0C\u6B64\u5361\u5DF2\u5931\u6548\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u9700\u8981\u586B\u5199\u7684\u95EE\u9898\u3002",
12604
+ expired: "\u95EE\u9898\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u3002",
12605
+ cancelled: "\u5DF2\u53D6\u6D88\u3002",
12606
+ empty: "\u5DF2\u63D0\u4EA4\uFF0C\u672A\u586B\u5199\u4EFB\u4F55\u5185\u5BB9\u3002",
12607
+ submitted: "\u5DF2\u63D0\u4EA4\u3002",
12608
+ pause_failed: "\u5F53\u524D\u4EFB\u52A1\u672A\u80FD\u6682\u505C\uFF0C\u6B64\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u3002",
12609
+ restart_invalidated: "\u670D\u52A1\u5DF2\u91CD\u542F\uFF0C\u539F\u95EE\u9898\u4E0A\u4E0B\u6587\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u3002",
12610
+ restart_during_dispatch: "\u670D\u52A1\u5728\u5904\u7406\u56DE\u7B54\u671F\u95F4\u91CD\u542F\uFF0C\u672C\u6B21\u5904\u7406\u7ED3\u679C\u53EF\u80FD\u672A\u5B8C\u6210\uFF0C\u8BF7\u53D1\u9001\u65B0\u6D88\u606F\u7EE7\u7EED\u3002",
12611
+ dispatch_failed: "\u56DE\u7B54\u5DF2\u6536\u5230\uFF0C\u4F46\u672A\u80FD\u7EE7\u7EED\u4F1A\u8BDD\uFF0C\u8BF7\u53D1\u9001\u4E00\u6761\u666E\u901A\u6D88\u606F\u7EE7\u7EED\u3002"
12612
+ };
12613
+ return {
12614
+ card_status: reason === "cancelled" ? "cancelled" : reason === "submitted" || reason === "empty" ? "submitted" : "expired",
12615
+ question_desc: descriptions[reason],
12616
+ form_btn_text: reason === "cancelled" ? "\u5DF2\u53D6\u6D88" : reason === "submitted" || reason === "empty" ? "\u5DF2\u63D0\u4EA4" : "\u5DF2\u5931\u6548"
12617
+ };
12618
+ }
12619
+ async function updateLifecycleRecordCardBestEffort(params) {
12620
+ const reason = params.record.terminalReason;
12621
+ if (!reason || reason === "delivery_failed") {
12622
+ return;
12623
+ }
12624
+ try {
12625
+ const token = await getAccessToken(params.config, params.log);
12626
+ await updateCardVariables(
12627
+ params.record.outTrackId,
12628
+ terminalCardVariables(reason),
12629
+ token,
12630
+ params.config
12631
+ );
12632
+ } catch (err) {
12633
+ params.log?.warn?.(
12634
+ `[DingTalk][AskUser] Failed to update lifecycle card ${params.record.questionId}: ${String(err)}`
12635
+ );
12636
+ }
12637
+ }
12638
+ function consumeLifecyclePendingContext(record) {
12639
+ const ctx = pendingQuestionsByQuestionId.get(record.questionId) ?? pendingQuestionsByTrackId.get(record.outTrackId);
12640
+ if (!ctx) {
12641
+ return void 0;
12642
+ }
12643
+ ctx.submitted = true;
12644
+ consumePendingQuestion(ctx);
12645
+ addHandledQuestionTombstone(ctx, record.terminalReason === "expired" ? "expired" : "superseded");
12646
+ return ctx;
12647
+ }
12648
+ function invalidateAskUserQuestionsForScope(params) {
12649
+ const invalidated = invalidateAskUserQuestionsInScope(
12650
+ {
12651
+ storePath: params.storePath,
12652
+ accountId: params.accountId,
12653
+ log: params.log
12654
+ },
12655
+ params.questionScopeKey,
12656
+ params.reason
12657
+ );
12658
+ for (const record of invalidated) {
12659
+ consumeLifecyclePendingContext(record);
12660
+ }
12661
+ return invalidated;
12662
+ }
12663
+ async function syncInvalidatedAskUserQuestionCards(params) {
12664
+ await Promise.allSettled(
12665
+ params.records.map(
12666
+ (record) => updateLifecycleRecordCardBestEffort({
12667
+ record,
12668
+ config: params.config,
12669
+ log: params.log
12670
+ })
12671
+ )
12672
+ );
12673
+ }
12674
+ async function recoverAskUserQuestionsForAccount(params) {
12675
+ if (!params.storePath) {
12676
+ return 0;
12677
+ }
12678
+ const recovered = recoverAskUserQuestionsAfterRestart({
12679
+ storePath: params.storePath,
12680
+ accountId: params.accountId,
12681
+ log: params.log
12682
+ });
12683
+ for (const record of recovered) {
12684
+ consumeLifecyclePendingContext(record);
12685
+ await updateLifecycleRecordCardBestEffort({
12686
+ record,
12687
+ config: params.config,
12688
+ log: params.log
12689
+ });
12690
+ }
12691
+ return recovered.length;
12692
+ }
12693
+ async function terminatePendingQuestion(params) {
12694
+ const storeOptions = getAskUserStoreOptions(params.ctx);
12695
+ if (storeOptions) {
12696
+ terminateAskUserQuestion(storeOptions, params.ctx.questionId, params.reason);
12697
+ }
12698
+ params.ctx.submitted = true;
12699
+ consumePendingQuestion(params.ctx);
12700
+ addHandledQuestionTombstone(
12701
+ params.ctx,
12702
+ params.reason === "cancelled" ? "cancelled" : params.reason === "empty" ? "empty" : params.reason === "submitted" ? "submitted" : params.reason === "expired" ? "expired" : "superseded"
12703
+ );
12704
+ await updateQuestionCardBestEffort(params.ctx, terminalCardVariables(params.reason));
12705
+ }
12230
12706
  function parseEmbeddedJson2(value) {
12231
12707
  if (typeof value !== "string") {
12232
12708
  return value;
@@ -12346,7 +12822,42 @@ async function injectAnswerSyntheticMessage(ctx, text, suffix) {
12346
12822
  data: syntheticData,
12347
12823
  sessionWebhook: ctx.sessionWebhook,
12348
12824
  log: ctx.log,
12349
- dingtalkConfig: ctx.dingtalkConfig
12825
+ dingtalkConfig: ctx.dingtalkConfig,
12826
+ inboundOrigin: "ask-user",
12827
+ routeOverride: ctx.resolvedRoute,
12828
+ subAgentOptions: ctx.continuationSubAgentOptions
12829
+ });
12830
+ }
12831
+ function claimPendingQuestionForDispatch(ctx) {
12832
+ const storeOptions = getAskUserStoreOptions(ctx);
12833
+ if (storeOptions) {
12834
+ return Boolean(
12835
+ claimAskUserQuestion(storeOptions, {
12836
+ questionId: ctx.questionId,
12837
+ outTrackId: ctx.outTrackId
12838
+ })
12839
+ );
12840
+ }
12841
+ if (ctx.submitted) {
12842
+ return false;
12843
+ }
12844
+ ctx.submitted = true;
12845
+ return true;
12846
+ }
12847
+ function dispatchSyntheticAnswer(params) {
12848
+ const storeOptions = getAskUserStoreOptions(params.ctx);
12849
+ void injectAnswerSyntheticMessage(params.ctx, params.text, params.suffix).then(() => {
12850
+ if (storeOptions) {
12851
+ terminateAskUserQuestion(storeOptions, params.ctx.questionId, params.successReason);
12852
+ }
12853
+ }).catch((err) => {
12854
+ if (storeOptions) {
12855
+ terminateAskUserQuestion(storeOptions, params.ctx.questionId, "dispatch_failed");
12856
+ }
12857
+ void updateQuestionCardBestEffort(params.ctx, terminalCardVariables("dispatch_failed"));
12858
+ params.log?.error?.(
12859
+ `[DingTalk][AskUser] Failed to inject ${params.suffix} answer message: ${String(err)}`
12860
+ );
12350
12861
  });
12351
12862
  }
12352
12863
  async function handleDingTalkAskUserCardCallback(params) {
@@ -12358,8 +12869,43 @@ async function handleDingTalkAskUserCardCallback(params) {
12358
12869
  );
12359
12870
  return { handled: true };
12360
12871
  }
12872
+ const storeOptions = params.storePath ? {
12873
+ storePath: params.storePath,
12874
+ accountId: params.accountId,
12875
+ log: params.log
12876
+ } : void 0;
12877
+ const lifecycleRecord = storeOptions ? resolveAskUserQuestion(storeOptions, {
12878
+ questionId: parsed.actionId,
12879
+ outTrackId: parsed.outTrackId
12880
+ }) : void 0;
12881
+ if (lifecycleRecord?.state === "terminal") {
12882
+ params.log?.debug?.(
12883
+ `[DingTalk][AskUser] Ignoring terminal callback question=${lifecycleRecord.questionId} reason=${lifecycleRecord.terminalReason ?? "unknown"}`
12884
+ );
12885
+ await updateLifecycleRecordCardBestEffort({
12886
+ record: lifecycleRecord,
12887
+ config: params.config,
12888
+ log: params.log
12889
+ });
12890
+ return { handled: true };
12891
+ }
12361
12892
  const ctx = (parsed.outTrackId ? pendingQuestionsByTrackId.get(parsed.outTrackId) : void 0) ?? (parsed.actionId ? pendingQuestionsByQuestionId.get(parsed.actionId) : void 0);
12362
12893
  if (!ctx) {
12894
+ if (lifecycleRecord && storeOptions) {
12895
+ const recovered = terminateAskUserQuestion(
12896
+ storeOptions,
12897
+ lifecycleRecord.questionId,
12898
+ lifecycleRecord.state === "dispatching" ? "restart_during_dispatch" : "restart_invalidated"
12899
+ );
12900
+ if (recovered) {
12901
+ await updateLifecycleRecordCardBestEffort({
12902
+ record: recovered,
12903
+ config: params.config,
12904
+ log: params.log
12905
+ });
12906
+ }
12907
+ return { handled: true };
12908
+ }
12363
12909
  return { handled: false };
12364
12910
  }
12365
12911
  if (!isOwnerClick(ctx, params.clickerUserId)) {
@@ -12374,13 +12920,15 @@ async function handleDingTalkAskUserCardCallback(params) {
12374
12920
  );
12375
12921
  return { handled: true };
12376
12922
  }
12377
- if (ctx.submitted) {
12923
+ if (ctx.submitted || lifecycleRecord?.state === "dispatching") {
12378
12924
  params.log?.debug?.(`[DingTalk][AskUser] Duplicate submit ignored question=${ctx.questionId}`);
12379
12925
  return { handled: true };
12380
12926
  }
12381
12927
  const isCancel = parseBooleanLike(parsed.params.user_cancel) === true;
12382
- ctx.submitted = true;
12383
12928
  if (isCancel) {
12929
+ if (!claimPendingQuestionForDispatch(ctx)) {
12930
+ return { handled: true };
12931
+ }
12384
12932
  await updateQuestionCardBestEffort(ctx, {
12385
12933
  card_status: "cancelled",
12386
12934
  question_desc: "\u5DF2\u53D6\u6D88\u3002",
@@ -12388,25 +12936,25 @@ async function handleDingTalkAskUserCardCallback(params) {
12388
12936
  });
12389
12937
  consumePendingQuestion(ctx);
12390
12938
  addHandledQuestionTombstone(ctx, "cancelled");
12391
- setImmediate(() => {
12392
- void injectAnswerSyntheticMessage(ctx, buildCancelledAnswerMessage(ctx), "cancelled").catch(
12393
- (err) => {
12394
- params.log?.error?.(
12395
- `[DingTalk][AskUser] Failed to inject cancelled answer message: ${String(err)}`
12396
- );
12397
- }
12398
- );
12939
+ dispatchSyntheticAnswer({
12940
+ ctx,
12941
+ text: buildCancelledAnswerMessage(ctx),
12942
+ suffix: "cancelled",
12943
+ successReason: "cancelled",
12944
+ log: params.log
12399
12945
  });
12400
12946
  return { handled: true };
12401
12947
  }
12402
12948
  const form = asRecord5(parsed.params.form);
12403
12949
  if (!form) {
12404
- ctx.submitted = false;
12405
12950
  params.log?.warn?.(
12406
12951
  `[DingTalk][AskUser] Missing form payload question=${ctx.questionId} params=${JSON.stringify(parsed.params)}`
12407
12952
  );
12408
12953
  return { handled: true };
12409
12954
  }
12955
+ if (!claimPendingQuestionForDispatch(ctx)) {
12956
+ return { handled: true };
12957
+ }
12410
12958
  const answers = [];
12411
12959
  const selectedValues = [];
12412
12960
  for (const question of ctx.questions) {
@@ -12430,12 +12978,12 @@ async function handleDingTalkAskUserCardCallback(params) {
12430
12978
  });
12431
12979
  consumePendingQuestion(ctx);
12432
12980
  addHandledQuestionTombstone(ctx, "empty");
12433
- setImmediate(() => {
12434
- void injectAnswerSyntheticMessage(ctx, buildEmptyAnswerMessage(ctx), "empty").catch((err) => {
12435
- params.log?.error?.(
12436
- `[DingTalk][AskUser] Failed to inject empty answer message: ${String(err)}`
12437
- );
12438
- });
12981
+ dispatchSyntheticAnswer({
12982
+ ctx,
12983
+ text: buildEmptyAnswerMessage(ctx),
12984
+ suffix: "empty",
12985
+ successReason: "empty",
12986
+ log: params.log
12439
12987
  });
12440
12988
  return { handled: true };
12441
12989
  }
@@ -12450,10 +12998,12 @@ async function handleDingTalkAskUserCardCallback(params) {
12450
12998
  consumePendingQuestion(ctx);
12451
12999
  addHandledQuestionTombstone(ctx, "submitted");
12452
13000
  const message = buildAnswerMessage(ctx, answers);
12453
- setImmediate(() => {
12454
- void injectAnswerSyntheticMessage(ctx, message, "submitted").catch((err) => {
12455
- params.log?.error?.(`[DingTalk][AskUser] Failed to inject answer message: ${String(err)}`);
12456
- });
13001
+ dispatchSyntheticAnswer({
13002
+ ctx,
13003
+ text: message,
13004
+ suffix: "submitted",
13005
+ successReason: "submitted",
13006
+ log: params.log
12457
13007
  });
12458
13008
  return { handled: true };
12459
13009
  }
@@ -12620,6 +13170,16 @@ function registerDingTalkAskUserQuestionTool(api) {
12620
13170
  selected_values: "[]",
12621
13171
  form: { fields }
12622
13172
  };
13173
+ const storeOptions = getAskUserStoreOptions(context);
13174
+ const canPersistLifecycle = Boolean(storeOptions && context.questionScopeKey);
13175
+ if (storeOptions && context.questionScopeKey) {
13176
+ reserveAskUserQuestion(storeOptions, {
13177
+ questionId,
13178
+ questionScopeKey: context.questionScopeKey,
13179
+ outTrackId,
13180
+ title
13181
+ });
13182
+ }
12623
13183
  try {
12624
13184
  await createAndDeliverQuestionCard({
12625
13185
  config: context.dingtalkConfig,
@@ -12631,6 +13191,9 @@ function registerDingTalkAskUserQuestionTool(api) {
12631
13191
  log: context.log
12632
13192
  });
12633
13193
  } catch (err) {
13194
+ if (storeOptions && canPersistLifecycle) {
13195
+ terminateAskUserQuestion(storeOptions, questionId, "delivery_failed");
13196
+ }
12634
13197
  const detail = formatDingTalkErrorPayloadLog("ask_user_create", err, "[DingTalk]");
12635
13198
  return jsonToolResult({
12636
13199
  status: "failed",
@@ -12641,20 +13204,75 @@ function registerDingTalkAskUserQuestionTool(api) {
12641
13204
  ...context,
12642
13205
  onQuestionCardSent: void 0
12643
13206
  };
12644
- storePendingQuestion({
13207
+ const pendingQuestion = {
12645
13208
  ...pendingContext,
12646
13209
  questionId,
12647
13210
  outTrackId,
12648
13211
  title,
12649
13212
  questions: parsed,
12650
13213
  submitted: false
13214
+ };
13215
+ storePendingQuestion(pendingQuestion, {
13216
+ supersedeExisting: !canPersistLifecycle
12651
13217
  });
13218
+ if (storeOptions && canPersistLifecycle) {
13219
+ const activation = activateAskUserQuestion(storeOptions, questionId);
13220
+ if (activation.record?.state !== "pending") {
13221
+ const terminalReason = activation.record?.terminalReason ?? "superseded_by_message";
13222
+ if (activation.record) {
13223
+ consumeLifecyclePendingContext(activation.record);
13224
+ } else {
13225
+ pendingQuestion.submitted = true;
13226
+ consumePendingQuestion(pendingQuestion);
13227
+ addHandledQuestionTombstone(pendingQuestion, "superseded");
13228
+ }
13229
+ await updateQuestionCardBestEffort(
13230
+ pendingQuestion,
13231
+ terminalCardVariables(terminalReason)
13232
+ );
13233
+ return jsonToolResult({
13234
+ status: "failed",
13235
+ questionId,
13236
+ outTrackId,
13237
+ error: "\u95EE\u9898\u5361\u7247\u5728\u53D1\u9001\u671F\u95F4\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u3002"
13238
+ });
13239
+ }
13240
+ for (const superseded of activation.superseded) {
13241
+ const supersededContext = consumeLifecyclePendingContext(superseded);
13242
+ if (supersededContext) {
13243
+ void updateQuestionCardBestEffort(
13244
+ supersededContext,
13245
+ terminalCardVariables("superseded_by_question")
13246
+ );
13247
+ } else {
13248
+ void updateLifecycleRecordCardBestEffort({
13249
+ record: superseded,
13250
+ config: context.dingtalkConfig,
13251
+ log: context.log
13252
+ });
13253
+ }
13254
+ }
13255
+ }
13256
+ let takeoverSucceeded = void 0;
12652
13257
  try {
12653
- await context.onQuestionCardSent?.({ questionId, outTrackId });
13258
+ takeoverSucceeded = await context.onQuestionCardSent?.({ questionId, outTrackId });
12654
13259
  } catch (err) {
12655
13260
  context.log?.warn?.(
12656
13261
  `[DingTalk][AskUser] onQuestionCardSent hook failed: ${err instanceof Error ? err.message : String(err)}`
12657
13262
  );
13263
+ takeoverSucceeded = false;
13264
+ }
13265
+ if (takeoverSucceeded === false) {
13266
+ await terminatePendingQuestion({
13267
+ ctx: pendingQuestion,
13268
+ reason: "pause_failed"
13269
+ });
13270
+ return jsonToolResult({
13271
+ status: "failed",
13272
+ questionId,
13273
+ outTrackId,
13274
+ error: "\u5F53\u524D\u4EFB\u52A1\u672A\u80FD\u6682\u505C\uFF0C\u6B64\u5361\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u53D1\u8D77\u3002"
13275
+ });
12658
13276
  }
12659
13277
  context.log?.info?.(
12660
13278
  `[DingTalk][AskUser] question card sent question=${questionId} outTrackId=${outTrackId}`
@@ -12859,6 +13477,7 @@ async function handleCardAction(params) {
12859
13477
  payload: params.payload,
12860
13478
  cfg: params.cfg,
12861
13479
  accountId: params.accountId,
13480
+ storePath: params.storePath,
12862
13481
  config: params.config,
12863
13482
  clickerUserId: params.analysis.userId,
12864
13483
  log: params.log
@@ -12999,8 +13618,8 @@ var ConnectionManager = class _ConnectionManager {
12999
13618
  `[${this.accountId}] Runtime counters (${reason}): healthUnhealthyChecks=${c.healthUnhealthyChecks}, healthTriggeredReconnects=${c.healthTriggeredReconnects}, heartbeatMisses=${c.heartbeatMisses}, heartbeatTriggeredReconnects=${c.heartbeatTriggeredReconnects}, serverDisconnectMessages=${c.serverDisconnectMessages}, socketCloseEvents=${c.socketCloseEvents}, runtimeDisconnects=${c.runtimeDisconnects}, reconnectAttempts=${c.reconnectAttempts}, reconnectSuccess=${c.reconnectSuccess}, reconnectFailures=${c.reconnectFailures}`
13000
13619
  );
13001
13620
  }
13002
- recordSocketActivity(now = Date.now()) {
13003
- this.lastSocketActivityAt = now;
13621
+ recordSocketActivity(now2 = Date.now()) {
13622
+ this.lastSocketActivityAt = now2;
13004
13623
  this.consecutiveHeartbeatMisses = 0;
13005
13624
  }
13006
13625
  setupHeartbeat(socket) {
@@ -13012,13 +13631,13 @@ var ConnectionManager = class _ConnectionManager {
13012
13631
  if (socket.readyState !== 1) {
13013
13632
  return;
13014
13633
  }
13015
- const now = Date.now();
13016
- const idleMs = this.lastSocketActivityAt !== void 0 ? now - this.lastSocketActivityAt : _ConnectionManager.HEARTBEAT_INTERVAL_MS;
13634
+ const now2 = Date.now();
13635
+ const idleMs = this.lastSocketActivityAt !== void 0 ? now2 - this.lastSocketActivityAt : _ConnectionManager.HEARTBEAT_INTERVAL_MS;
13017
13636
  if (idleMs >= _ConnectionManager.HEARTBEAT_INTERVAL_MS) {
13018
13637
  this.consecutiveHeartbeatMisses += 1;
13019
13638
  this.runtimeCounters.heartbeatMisses += 1;
13020
13639
  if (this.consecutiveHeartbeatMisses >= _ConnectionManager.HEARTBEAT_MISS_THRESHOLD) {
13021
- const lastPingAgoMs = this.lastHeartbeatPingAt !== void 0 ? now - this.lastHeartbeatPingAt : void 0;
13640
+ const lastPingAgoMs = this.lastHeartbeatPingAt !== void 0 ? now2 - this.lastHeartbeatPingAt : void 0;
13022
13641
  this.log?.warn?.(
13023
13642
  `[${this.accountId}] Connection heartbeat missed ${this.consecutiveHeartbeatMisses}/${_ConnectionManager.HEARTBEAT_MISS_THRESHOLD} checks, triggering reconnection (lastPingAgoMs=${lastPingAgoMs ?? "n/a"})`
13024
13643
  );
@@ -13032,7 +13651,7 @@ var ConnectionManager = class _ConnectionManager {
13032
13651
  `[${this.accountId}] Connection heartbeat missed (${this.consecutiveHeartbeatMisses}/${_ConnectionManager.HEARTBEAT_MISS_THRESHOLD})`
13033
13652
  );
13034
13653
  }
13035
- this.lastHeartbeatPingAt = now;
13654
+ this.lastHeartbeatPingAt = now2;
13036
13655
  try {
13037
13656
  socket.ping("", true);
13038
13657
  } catch (err) {
@@ -13273,8 +13892,8 @@ var ConnectionManager = class _ConnectionManager {
13273
13892
  this.consecutiveUnhealthyChecks = 0;
13274
13893
  return;
13275
13894
  }
13276
- const now = Date.now();
13277
- const withinGraceWindow = this.connectedAt !== void 0 && now - this.connectedAt < _ConnectionManager.HEALTH_CHECK_GRACE_MS;
13895
+ const now2 = Date.now();
13896
+ const withinGraceWindow = this.connectedAt !== void 0 && now2 - this.connectedAt < _ConnectionManager.HEALTH_CHECK_GRACE_MS;
13278
13897
  if (withinGraceWindow) {
13279
13898
  this.consecutiveUnhealthyChecks = 0;
13280
13899
  return;
@@ -13632,12 +14251,12 @@ var MESSAGE_DEDUP_TTL = 6e4;
13632
14251
  var MESSAGE_DEDUP_MAX_SIZE = 1e3;
13633
14252
  var messageCounter = 0;
13634
14253
  function isMessageProcessed(dedupKey) {
13635
- const now = Date.now();
14254
+ const now2 = Date.now();
13636
14255
  const expiresAt = processedMessages.get(dedupKey);
13637
14256
  if (expiresAt === void 0) {
13638
14257
  return false;
13639
14258
  }
13640
- if (now >= expiresAt) {
14259
+ if (now2 >= expiresAt) {
13641
14260
  processedMessages.delete(dedupKey);
13642
14261
  return false;
13643
14262
  }
@@ -13647,9 +14266,9 @@ function markMessageProcessed(dedupKey) {
13647
14266
  const expiresAt = Date.now() + MESSAGE_DEDUP_TTL;
13648
14267
  processedMessages.set(dedupKey, expiresAt);
13649
14268
  if (processedMessages.size > MESSAGE_DEDUP_MAX_SIZE) {
13650
- const now = Date.now();
14269
+ const now2 = Date.now();
13651
14270
  for (const [key, expiry] of processedMessages.entries()) {
13652
- if (now >= expiry) {
14271
+ if (now2 >= expiry) {
13653
14272
  processedMessages.delete(key);
13654
14273
  }
13655
14274
  }
@@ -13668,9 +14287,9 @@ function markMessageProcessed(dedupKey) {
13668
14287
  messageCounter++;
13669
14288
  if (messageCounter >= 10) {
13670
14289
  messageCounter = 0;
13671
- const now = Date.now();
14290
+ const now2 = Date.now();
13672
14291
  for (const [key, expiry] of processedMessages.entries()) {
13673
- if (now >= expiry) {
14292
+ if (now2 >= expiry) {
13674
14293
  processedMessages.delete(key);
13675
14294
  }
13676
14295
  }
@@ -13797,6 +14416,23 @@ function createDingTalkGateway() {
13797
14416
  `[${account.accountId}] Failed to recover unfinished cards: ${err.message}`
13798
14417
  );
13799
14418
  }
14419
+ try {
14420
+ const recoveredQuestions = await recoverAskUserQuestionsForAccount({
14421
+ storePath: accountStorePath,
14422
+ accountId: account.accountId,
14423
+ config,
14424
+ log: pluginLog
14425
+ });
14426
+ if (recoveredQuestions > 0) {
14427
+ pluginLog?.info?.(
14428
+ `[${account.accountId}] Invalidated ${recoveredQuestions} unfinished Ask User card(s) from previous runtime`
14429
+ );
14430
+ }
14431
+ } catch (err) {
14432
+ pluginLog?.warn?.(
14433
+ `[${account.accountId}] Failed to recover Ask User cards: ${err.message}`
14434
+ );
14435
+ }
13800
14436
  const useConnectionManager = config.useConnectionManager ?? true;
13801
14437
  const applyStatusPatch = (patch) => {
13802
14438
  ctx.setStatus({
@@ -13967,6 +14603,7 @@ function createDingTalkGateway() {
13967
14603
  analysis,
13968
14604
  cfg,
13969
14605
  accountId: account.accountId,
14606
+ storePath: accountStorePath,
13970
14607
  config,
13971
14608
  log: pluginLog
13972
14609
  });
@@ -14637,9 +15274,9 @@ async function configureDingTalkAccount(params) {
14637
15274
  let lastWaitingNote = 0;
14638
15275
  const result = await session.waitForResult({
14639
15276
  onWaiting: () => {
14640
- const now = Date.now();
14641
- if (now - lastWaitingNote >= 15e3) {
14642
- lastWaitingNote = now;
15277
+ const now2 = Date.now();
15278
+ if (now2 - lastWaitingNote >= 15e3) {
15279
+ lastWaitingNote = now2;
14643
15280
  prompter.note("Waiting for authorization. Please finish the scan in DingTalk...", "Polling").catch(() => {
14644
15281
  });
14645
15282
  }