@soimy/dingtalk 3.6.6 → 3.6.8

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 (40) hide show
  1. package/dist/index.js +1433 -344
  2. package/dist/index.js.map +4 -4
  3. package/dist/src/card/ask-user-question-context.d.ts +5 -2
  4. package/dist/src/card/ask-user-question-context.d.ts.map +1 -1
  5. package/dist/src/card/ask-user-question-store.d.ts +42 -0
  6. package/dist/src/card/ask-user-question-store.d.ts.map +1 -0
  7. package/dist/src/card/ask-user-question.d.ts +20 -0
  8. package/dist/src/card/ask-user-question.d.ts.map +1 -1
  9. package/dist/src/card/card-action-handler.d.ts +1 -0
  10. package/dist/src/card/card-action-handler.d.ts.map +1 -1
  11. package/dist/src/card-service.d.ts +4 -1
  12. package/dist/src/card-service.d.ts.map +1 -1
  13. package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
  14. package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts +23 -0
  15. package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts.map +1 -0
  16. package/dist/src/gateway/inbound-session-queue.d.ts +46 -0
  17. package/dist/src/gateway/inbound-session-queue.d.ts.map +1 -0
  18. package/dist/src/gateway/reply-session-conflict.d.ts +22 -0
  19. package/dist/src/gateway/reply-session-conflict.d.ts.map +1 -0
  20. package/dist/src/inbound-handler.d.ts.map +1 -1
  21. package/dist/src/onboarding.d.ts.map +1 -1
  22. package/dist/src/targeting/agent-routing.d.ts +17 -1
  23. package/dist/src/targeting/agent-routing.d.ts.map +1 -1
  24. package/dist/src/types.d.ts +35 -0
  25. package/dist/src/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/access-control.ts +1 -1
  28. package/src/card/ask-user-question-context.ts +9 -1
  29. package/src/card/ask-user-question-store.ts +294 -0
  30. package/src/card/ask-user-question.ts +398 -37
  31. package/src/card/card-action-handler.ts +2 -0
  32. package/src/card-service.ts +55 -3
  33. package/src/gateway/channel-gateway.ts +51 -30
  34. package/src/gateway/inbound-session-queue-dispatcher.ts +304 -0
  35. package/src/gateway/inbound-session-queue.ts +244 -0
  36. package/src/gateway/reply-session-conflict.ts +82 -0
  37. package/src/inbound-handler.ts +254 -57
  38. package/src/onboarding.ts +6 -2
  39. package/src/targeting/agent-routing.ts +84 -19
  40. package/src/types.ts +36 -0
@@ -13,6 +13,10 @@ import {
13
13
  isCardInTerminalState,
14
14
  recallAICardMessage,
15
15
  } from "./card-service";
16
+ import {
17
+ invalidateAskUserQuestionsForScope,
18
+ syncInvalidatedAskUserQuestionCards,
19
+ } from "./card/ask-user-question";
16
20
  import {
17
21
  getDingTalkQuestionContext,
18
22
  withDingTalkQuestionContext,
@@ -30,6 +34,7 @@ import {
30
34
  } from "./config";
31
35
  import { buildLearningContextBlock, isLearningEnabled } from "./feedback-learning-service";
32
36
  import axios from "./http-client";
37
+ import { dispatchInboundViaSessionQueue } from "./gateway/inbound-session-queue-dispatcher";
33
38
  import { setCurrentLogger } from "./logger-context";
34
39
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
35
40
  import {
@@ -56,6 +61,10 @@ import {
56
61
  clearProactiveRiskObservationsForTest,
57
62
  getProactiveRiskObservationForAny,
58
63
  } from "./proactive-risk-registry";
64
+ import {
65
+ isReplySessionConflictError,
66
+ withReplySessionConflictRetry,
67
+ } from "./gateway/reply-session-conflict";
59
68
  import { createReplyStrategy } from "./reply-strategy";
60
69
  import type { DeliverPayload } from "./reply-strategy-types";
61
70
  import { getDingTalkRuntime } from "./runtime";
@@ -81,7 +90,13 @@ import {
81
90
  upsertObservedUserTarget,
82
91
  } from "./targeting/target-directory-store";
83
92
  import { AICardStatus } from "./types";
84
- import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
93
+ import type {
94
+ DingTalkConfig,
95
+ HandleDingTalkMessageParams,
96
+ Logger,
97
+ MediaFile,
98
+ ResolvedDingTalkRoute,
99
+ } from "./types";
85
100
  import {
86
101
  formatDingTalkErrorPayloadLog,
87
102
  getErrorMessage,
@@ -561,8 +576,9 @@ export async function downloadMedia(
561
576
 
562
577
  export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
563
578
  // Keep context creation inside this public inbound entry. Ask-user synthetic
564
- // reinjections call handleDingTalkMessage directly, so moving this wrapper to
565
- // gateway callbacks would lose per-message isolation for reinjected answers.
579
+ // reinjections call handleDingTalkMessage directly. The normal inbound queue
580
+ // is therefore entered later, after this handler has completed authorization
581
+ // and trusted route/session resolution; ask-user stays outside that queue.
566
582
  return withDingTalkQuestionContext(
567
583
  {
568
584
  cfg: params.cfg,
@@ -584,6 +600,8 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
584
600
  sessionWebhook,
585
601
  log,
586
602
  dingtalkConfig,
603
+ inboundOrigin = "stream",
604
+ routeOverride,
587
605
  subAgentOptions,
588
606
  preDownloadedMedia,
589
607
  } = params;
@@ -810,38 +828,42 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
810
828
  ? null
811
829
  : resolveMessageTarget({ extractedContent, cfg, isGroup });
812
830
 
813
- let route: { agentId: string; sessionKey: string; mainSessionKey: string };
814
- if (subAgentOptions) {
815
- // Recursive sub-agent dispatch (content or targeted command): route to the
816
- // agent's own session. A missing host helper throws here and is caught by
817
- // dispatchSubAgents in the parent call.
818
- route = {
819
- agentId: subAgentOptions.agentId,
820
- sessionKey: buildAgentSessionKey({
821
- rt,
822
- cfg,
823
- accountId,
824
- agentId: subAgentOptions.agentId,
825
- peerKind: sessionPeer.kind,
826
- peerId: sessionPeer.peerId,
827
- }),
828
- mainSessionKey: "",
829
- };
830
- } else {
831
- // Default route. For subagent-content / subagent-command targets the
832
- // dispatch below re-enters this function with subAgentOptions set, so this
833
- // route is only consumed when the target is "default".
834
- route = rt.channel.routing.resolveAgentRoute({
835
- cfg,
836
- channel: "dingtalk",
837
- accountId,
838
- peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
831
+ const invalidateQuestionRoutes = (routes: readonly ResolvedDingTalkRoute[]): void => {
832
+ if (inboundOrigin === "ask-user") {
833
+ return;
834
+ }
835
+ const scopeKeys = new Set(
836
+ routes.map((resolvedRoute) => `${accountId}:${resolvedRoute.sessionKey}:${senderId}`),
837
+ );
838
+ const invalidatedRecords = [];
839
+ for (const questionScopeKey of scopeKeys) {
840
+ try {
841
+ invalidatedRecords.push(
842
+ ...invalidateAskUserQuestionsForScope({
843
+ storePath: accountStorePath,
844
+ accountId,
845
+ questionScopeKey,
846
+ reason: "superseded_by_message",
847
+ log,
848
+ }),
849
+ );
850
+ } catch (err) {
851
+ log?.warn?.(
852
+ `[DingTalk][AskUser] Failed to invalidate pending cards before inbound dispatch scope=${questionScopeKey}: ${String(err)}`,
853
+ );
854
+ }
855
+ }
856
+ if (invalidatedRecords.length === 0) {
857
+ return;
858
+ }
859
+ void syncInvalidatedAskUserQuestionCards({
860
+ records: invalidatedRecords,
861
+ config: dingtalkConfig,
862
+ log,
863
+ }).catch((err) => {
864
+ log?.warn?.(`[DingTalk][AskUser] Card invalidation sync failed: ${String(err)}`);
839
865
  });
840
- }
841
- const questionContext = getDingTalkQuestionContext();
842
- if (questionContext) {
843
- questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
844
- }
866
+ };
845
867
 
846
868
  // @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
847
869
  // Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
@@ -858,9 +880,13 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
858
880
  dingtalkConfig,
859
881
  sessionWebhook,
860
882
  extractedContent,
883
+ sessionPeer,
884
+ onRoutesResolved: (targets) =>
885
+ invalidateQuestionRoutes(targets.map((target) => target.route)),
861
886
  handleMessage: handleDingTalkMessage,
862
887
  downloadMedia,
863
888
  log,
889
+ inboundQueueEligible: params.inboundQueueEligible,
864
890
  });
865
891
  return;
866
892
  }
@@ -889,15 +915,60 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
889
915
  dingtalkConfig,
890
916
  sessionWebhook,
891
917
  extractedContent,
918
+ sessionPeer,
919
+ onRoutesResolved: (targets) =>
920
+ invalidateQuestionRoutes(targets.map((target) => target.route)),
892
921
  handleMessage: handleDingTalkMessage,
893
922
  downloadMedia,
894
923
  log,
924
+ inboundQueueEligible: params.inboundQueueEligible,
895
925
  });
896
926
  return;
897
927
  }
898
928
  // Only invalid agent names and no match: fall through to the default route.
899
929
  }
900
930
 
931
+ let route: ResolvedDingTalkRoute;
932
+ if (routeOverride) {
933
+ route = routeOverride;
934
+ } else if (subAgentOptions) {
935
+ route = {
936
+ agentId: subAgentOptions.agentId,
937
+ sessionKey: buildAgentSessionKey({
938
+ rt,
939
+ cfg,
940
+ accountId,
941
+ agentId: subAgentOptions.agentId,
942
+ peerKind: sessionPeer.kind,
943
+ peerId: sessionPeer.peerId,
944
+ }),
945
+ mainSessionKey: "",
946
+ };
947
+ } else {
948
+ route = rt.channel.routing.resolveAgentRoute({
949
+ cfg,
950
+ channel: "dingtalk",
951
+ accountId,
952
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
953
+ });
954
+ }
955
+ const questionContext = getDingTalkQuestionContext();
956
+ if (questionContext) {
957
+ questionContext.resolvedRoute = route;
958
+ questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
959
+ questionContext.storePath = accountStorePath;
960
+ questionContext.continuationSubAgentOptions = subAgentOptions
961
+ ? {
962
+ agentId: subAgentOptions.agentId,
963
+ responsePrefix: subAgentOptions.responsePrefix,
964
+ matchedName: subAgentOptions.matchedName,
965
+ }
966
+ : undefined;
967
+ }
968
+ if (!subAgentOptions) {
969
+ invalidateQuestionRoutes([route]);
970
+ }
971
+
901
972
  // Route resolved before media download for session context and routing metadata.
902
973
  const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
903
974
  agentId: route.agentId,
@@ -936,7 +1007,45 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
936
1007
  const quotedRef = buildInboundQuotedRef(data, extractedContent);
937
1008
  const replyQuotedRef = createReplyQuotedRef(data.msgId);
938
1009
  const content = extractedContent;
939
- const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
1010
+ // Decide control-message bypasses once from the user-authored text. Reusing
1011
+ // the result below keeps /stop out of the FIFO queue without calling the SDK
1012
+ // classifier a second time after attachment/OCR enrichment.
1013
+ const controlText = stripLeadingMentions(content.text).trim();
1014
+ const isBtwBypass = isBtwRequestText(controlText);
1015
+ const isAbortBypass = isAbortRequestText(controlText);
1016
+
1017
+ // Queue only after all access checks and the trusted route.sessionKey above.
1018
+ // Gateway-level queueing uses only raw conversationId, which can both
1019
+ // acknowledge an unauthorized sender and block /stop or /btw behind a long
1020
+ // run. A queued continuation re-enters with the guard set and reuses the
1021
+ // visible queue ACK card for the actual answer.
1022
+ if (
1023
+ params.inboundQueueEligible &&
1024
+ !params.inboundQueueHandled &&
1025
+ inboundOrigin !== "ask-user" &&
1026
+ !isBtwBypass &&
1027
+ !isAbortBypass
1028
+ ) {
1029
+ await dispatchInboundViaSessionQueue(
1030
+ {
1031
+ accountId,
1032
+ data,
1033
+ dingtalkConfig,
1034
+ sessionKey: route.sessionKey,
1035
+ to,
1036
+ storePath: accountStorePath,
1037
+ quoteContent: rawInboundText.slice(0, 200),
1038
+ log,
1039
+ },
1040
+ (preCreatedCard) =>
1041
+ handleDingTalkMessage({
1042
+ ...params,
1043
+ preCreatedCard,
1044
+ inboundQueueHandled: true,
1045
+ }),
1046
+ );
1047
+ return;
1048
+ }
940
1049
  const taskInfoConversationId = groupId || to;
941
1050
  const agentDisplayName = getAgentDisplayName({
942
1051
  subAgentOptions,
@@ -978,7 +1087,6 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
978
1087
  let cardFlightKey: string | undefined;
979
1088
  if (questionContext) {
980
1089
  questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
981
- questionCardTookOver = true;
982
1090
  if (cardFlightKey) {
983
1091
  cardCreationInFlight.delete(cardFlightKey);
984
1092
  cardFlightKey = undefined;
@@ -999,27 +1107,38 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
999
1107
  log?.warn?.(
1000
1108
  `[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
1001
1109
  );
1110
+ return false;
1002
1111
  }
1112
+ questionCardTookOver = true;
1003
1113
  if (!currentAICard) {
1004
- return;
1114
+ return true;
1005
1115
  }
1006
1116
  if (isCardInTerminalState(currentAICard.state)) {
1007
- return;
1117
+ return true;
1118
+ }
1119
+ let recalled = false;
1120
+ try {
1121
+ recalled = await recallAICardMessage(currentAICard, log);
1122
+ } catch (err) {
1123
+ log?.warn?.(
1124
+ `[DingTalk][AskUser] Question card took over, but AI card recall errored question=${questionId} outTrackId=${outTrackId}: ${err instanceof Error ? err.message : String(err)}`,
1125
+ );
1126
+ return true;
1008
1127
  }
1009
- const recalled = await recallAICardMessage(currentAICard, log);
1010
1128
  if (!recalled) {
1011
1129
  log?.warn?.(
1012
1130
  `[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
1013
1131
  );
1014
- return;
1132
+ return true;
1015
1133
  }
1016
1134
  log?.info?.(
1017
1135
  `[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
1018
1136
  );
1137
+ return true;
1019
1138
  };
1020
1139
  }
1021
1140
 
1022
- if (useCardMode && !isBtwBypass) {
1141
+ if (useCardMode && !isBtwBypass && !params.preCreatedCard) {
1023
1142
  const key = `${accountId}:${to}`;
1024
1143
  if (cardCreationInFlight.has(key)) {
1025
1144
  useCardMode = false;
@@ -1042,13 +1161,18 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
1042
1161
  // Use rawInboundText ( preserved before sub-agent rewriting) to avoid
1043
1162
  // showing internal routing context like "[你被 @ 为...]" in the card UI.
1044
1163
  const inboundQuoteText = rawInboundText.slice(0, 200);
1045
- const aiCard = await createAICard(dingtalkConfig, to, log, {
1046
- accountId,
1047
- storePath: accountStorePath,
1048
- contextConversationId: groupId,
1049
- quoteContent: inboundQuoteText,
1050
- statusLine: initialStatusLine,
1051
- });
1164
+ // Reuse the pre-created card (shown while this message was queued behind
1165
+ // an active run) instead of creating a new one: the real reply streams
1166
+ // INTO the same card (in-place update).
1167
+ const aiCard =
1168
+ params.preCreatedCard ??
1169
+ (await createAICard(dingtalkConfig, to, log, {
1170
+ accountId,
1171
+ storePath: accountStorePath,
1172
+ contextConversationId: groupId,
1173
+ quoteContent: inboundQuoteText,
1174
+ statusLine: initialStatusLine,
1175
+ }));
1052
1176
  if (aiCard) {
1053
1177
  currentAICard = aiCard;
1054
1178
  if (aiCard.outTrackId) {
@@ -1757,12 +1881,11 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
1757
1881
  // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1758
1882
  // rather than waiting for it to finish before the stop message is processed.
1759
1883
  //
1760
- // Strip leading @mention tokens before the abort check so that messages like
1761
- // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1762
- // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1763
- // in DMs with multi-agent routing the @mention prefix survives all the way here.
1764
- const textForAbortCheck = stripLeadingMentions(inboundText).trim();
1765
- if (isAbortRequestText(textForAbortCheck)) {
1884
+ // The early control-text decision strips leading @mentions, so messages like
1885
+ // "@Agent /stop" are correctly recognised in both DM and group chats. It is
1886
+ // intentionally reused here instead of reclassifying attachment/OCR-enriched
1887
+ // input, which would make queue admission and the actual abort path disagree.
1888
+ if (isAbortBypass) {
1766
1889
  log?.info?.(
1767
1890
  `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
1768
1891
  );
@@ -2225,9 +2348,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2225
2348
  taskMeta,
2226
2349
  });
2227
2350
 
2228
- try {
2229
- let deliveredFinalCount = 0;
2230
- const dispatchResult = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2351
+ let deliveredFinalCount = 0;
2352
+ // Extracted as a thunk so reply-session init conflicts (raised by the
2353
+ // core when an active run still occupies this session) can be retried
2354
+ // with backoff instead of dropping the inbound message.
2355
+ const runDispatch = () =>
2356
+ rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2231
2357
  ctx,
2232
2358
  cfg,
2233
2359
  dispatcherOptions: {
@@ -2274,6 +2400,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2274
2400
  replyOptions: strategy.getReplyOptions(),
2275
2401
  });
2276
2402
 
2403
+ try {
2404
+ const dispatchResult = await withReplySessionConflictRetry(runDispatch, {
2405
+ log,
2406
+ sessionKey: route.sessionKey,
2407
+ });
2408
+
2277
2409
  const bufferedFinal =
2278
2410
  dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
2279
2411
  ? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
@@ -2321,6 +2453,71 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2321
2453
  } catch (dispatchErr: unknown) {
2322
2454
  const error =
2323
2455
  dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
2456
+ if (isReplySessionConflictError(error)) {
2457
+ // Fallback (兜底): the active run for this session did not drain within
2458
+ // the retry budget, so dispatching the inbound message still conflicts.
2459
+ // Rather than silently dropping it (outcome=error, no reply — the
2460
+ // "钉钉确认消息无响应" regression), send an immediate acknowledgement so
2461
+ // the user knows the message was received while the prior turn is still
2462
+ // busy; they can re-send once it finishes.
2463
+ log?.warn?.(
2464
+ `[DingTalk] Reply session still conflicted after retries for session=${route.sessionKey}; ` +
2465
+ `sending "processing" acknowledgement instead of dropping the message.`,
2466
+ );
2467
+ const ackText = "收到,上一轮还在处理中,请稍候再试。";
2468
+ // A card may already be visible (including the card created while a
2469
+ // gateway-queued message waited). Put the busy acknowledgement into
2470
+ // that same delivery strategy and finalize it normally. Calling
2471
+ // `strategy.abort()` after a separate acknowledgement would overwrite
2472
+ // the visible card with "❌ 处理失败", which contradicts the actual
2473
+ // recoverable-busy state.
2474
+ if (replyMode === "card") {
2475
+ try {
2476
+ await strategy.deliver({
2477
+ text: ackText,
2478
+ mediaUrls: [],
2479
+ kind: "final",
2480
+ isReasoning: false,
2481
+ });
2482
+ await strategy.finalize();
2483
+ return;
2484
+ } catch (cardAckErr: unknown) {
2485
+ log?.warn?.(
2486
+ `[DingTalk] Processing acknowledgement card finalize failed: ${getErrorMessage(cardAckErr)}`,
2487
+ );
2488
+ // The card was already the reply surface for this inbound
2489
+ // message. If its busy acknowledgement cannot be committed,
2490
+ // do not emit a second text acknowledgement and then abort the
2491
+ // card: abort renders "❌ 处理失败", which contradicts the
2492
+ // recoverable busy state and leaves two visible outcomes.
2493
+ // Keep the existing card untouched and let the next inbound
2494
+ // message / normal card lifecycle recover it instead.
2495
+ return;
2496
+ }
2497
+ }
2498
+ try {
2499
+ if (sessionWebhook) {
2500
+ await sendBySession(dingtalkConfig, sessionWebhook, ackText, {
2501
+ log,
2502
+ accountId,
2503
+ storePath: accountStorePath,
2504
+ });
2505
+ } else {
2506
+ await sendMessage(dingtalkConfig, to, ackText, {
2507
+ log,
2508
+ accountId,
2509
+ storePath: accountStorePath,
2510
+ conversationId: groupId,
2511
+ });
2512
+ }
2513
+ } catch (ackErr: unknown) {
2514
+ log?.warn?.(
2515
+ `[DingTalk] Processing acknowledgement delivery failed: ${getErrorMessage(ackErr)}`,
2516
+ );
2517
+ }
2518
+ await strategy.abort(error);
2519
+ return;
2520
+ }
2324
2521
  await strategy.abort(error);
2325
2522
  throw dispatchErr;
2326
2523
  }
package/src/onboarding.ts CHANGED
@@ -20,6 +20,10 @@ import type { DingTalkConfig, DingTalkChannelConfig } from "./types.js";
20
20
 
21
21
  const channel = "dingtalk" as const;
22
22
 
23
+ type DingTalkSetupInput = ChannelSetupInput & {
24
+ password?: string;
25
+ };
26
+
23
27
  function isConfigured(account: DingTalkConfig): boolean {
24
28
  return Boolean(account.clientId && hasConfiguredSecretInput(account.clientSecret));
25
29
  }
@@ -349,7 +353,7 @@ function applyAccountConfig(params: {
349
353
  function applyGenericSetupInput(params: {
350
354
  cfg: OpenClawConfig;
351
355
  accountId: string;
352
- input: ChannelSetupInput;
356
+ input: DingTalkSetupInput;
353
357
  }): OpenClawConfig {
354
358
  return applyAccountConfig({
355
359
  cfg: params.cfg,
@@ -547,7 +551,7 @@ export const dingtalkSetupAdapter: ChannelSetupAdapter = {
547
551
  applyGenericSetupInput({
548
552
  cfg,
549
553
  accountId,
550
- input,
554
+ input: input as DingTalkSetupInput,
551
555
  }),
552
556
  };
553
557
 
@@ -20,9 +20,11 @@ import type {
20
20
  HandleDingTalkMessageParams,
21
21
  Logger,
22
22
  MessageContent,
23
+ ResolvedDingTalkRoute,
23
24
  } from "../types";
24
25
  import { getErrorMessage } from "../utils";
25
26
  import { resolveAtAgents } from "./agent-name-matcher";
27
+ import type { ResolvedDingTalkSessionPeer } from "../session-routing";
26
28
 
27
29
  export class HostRoutingHelperUnavailableError extends Error {
28
30
  constructor(
@@ -84,6 +86,11 @@ export type MessageTarget =
84
86
  }
85
87
  | { kind: "subagent-command"; agent: AgentNameMatch; commandText: string };
86
88
 
89
+ export interface ResolvedSubAgentTarget {
90
+ agent: AgentNameMatch;
91
+ route: ResolvedDingTalkRoute;
92
+ }
93
+
87
94
  /**
88
95
  * Resolve how an inbound message should be routed.
89
96
  *
@@ -198,6 +205,8 @@ export async function dispatchSubAgents(params: {
198
205
  dingtalkConfig: DingTalkConfig;
199
206
  sessionWebhook: string;
200
207
  extractedContent: MessageContent;
208
+ sessionPeer: ResolvedDingTalkSessionPeer;
209
+ onRoutesResolved?: (targets: readonly ResolvedSubAgentTarget[]) => void;
201
210
  handleMessage: (params: HandleDingTalkMessageParams) => Promise<void>;
202
211
  downloadMedia: (
203
212
  config: DingTalkConfig,
@@ -205,6 +214,15 @@ export async function dispatchSubAgents(params: {
205
214
  log?: Logger,
206
215
  ) => Promise<{ path: string; mimeType: string } | null>;
207
216
  log?: Logger;
217
+ /**
218
+ * Whether the recursive sub-agent handler invocations should participate in
219
+ * the handler-owned inbound session queue. Propagated from the parent
220
+ * handler's `inboundQueueEligible` so `@子Agent` messages reuse the same
221
+ * serialization path and avoid reply-session conflicts on the sub-agent's
222
+ * own `target.route.sessionKey`. Default false preserves the legacy
223
+ * direct-dispatch behavior for synthetic callers that did not opt in.
224
+ */
225
+ inboundQueueEligible?: boolean;
208
226
  }): Promise<void> {
209
227
  const {
210
228
  matchedAgents,
@@ -215,11 +233,65 @@ export async function dispatchSubAgents(params: {
215
233
  dingtalkConfig,
216
234
  sessionWebhook,
217
235
  extractedContent,
236
+ sessionPeer,
237
+ onRoutesResolved,
218
238
  handleMessage,
219
239
  downloadMedia: download,
220
240
  log,
241
+ inboundQueueEligible,
221
242
  } = params;
222
243
 
244
+ let helperMissingWarningSent = false;
245
+ const sendHelperMissingWarning = async (): Promise<void> => {
246
+ if (helperMissingWarningSent) {
247
+ return;
248
+ }
249
+ helperMissingWarningSent = true;
250
+ try {
251
+ const isGroup = data.conversationType !== "1";
252
+ const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
253
+ await sendBySession(
254
+ dingtalkConfig,
255
+ sessionWebhook,
256
+ "⚠️ 当前宿主版本不支持 DingTalk 子助手路由所需的 session helper,请升级 OpenClaw 后重试。",
257
+ sendOptions,
258
+ );
259
+ } catch (notifyError: unknown) {
260
+ log?.debug?.(
261
+ `[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`,
262
+ );
263
+ }
264
+ };
265
+
266
+ let resolvedTargets: ResolvedSubAgentTarget[];
267
+ try {
268
+ const rt = getDingTalkRuntime();
269
+ resolvedTargets = matchedAgents.map((agent) => ({
270
+ agent,
271
+ route: {
272
+ agentId: agent.agentId,
273
+ sessionKey: buildAgentSessionKey({
274
+ rt,
275
+ cfg,
276
+ accountId,
277
+ agentId: agent.agentId,
278
+ peerKind: sessionPeer.kind,
279
+ peerId: sessionPeer.peerId,
280
+ }),
281
+ mainSessionKey: "",
282
+ },
283
+ }));
284
+ } catch (error) {
285
+ const message = getErrorMessage(error);
286
+ log?.error?.(`[DingTalk] Failed to resolve sub-agent routes: ${message}`);
287
+ if (error instanceof HostRoutingHelperUnavailableError) {
288
+ await sendHelperMissingWarning();
289
+ return;
290
+ }
291
+ throw error;
292
+ }
293
+ onRoutesResolved?.(resolvedTargets);
294
+
223
295
  // Pre-download media once to avoid duplication across sub-agents
224
296
  let preDownloadedMedia:
225
297
  | {
@@ -255,9 +327,8 @@ export async function dispatchSubAgents(params: {
255
327
  };
256
328
  }
257
329
  }
258
- let helperMissingWarningSent = false;
259
-
260
- for (const agentMatch of matchedAgents) {
330
+ for (const target of resolvedTargets) {
331
+ const agentMatch = target.agent;
261
332
  try {
262
333
  await handleMessage({
263
334
  cfg,
@@ -266,6 +337,7 @@ export async function dispatchSubAgents(params: {
266
337
  sessionWebhook,
267
338
  log,
268
339
  dingtalkConfig,
340
+ routeOverride: target.route,
269
341
  subAgentOptions: {
270
342
  agentId: agentMatch.agentId,
271
343
  responsePrefix: commandText
@@ -275,26 +347,19 @@ export async function dispatchSubAgents(params: {
275
347
  commandText,
276
348
  },
277
349
  preDownloadedMedia,
350
+ // Propagate queue eligibility so each recursive sub-agent handler
351
+ // enters the handler-owned queue on its own `target.route.sessionKey`
352
+ // instead of bypassing it (which previously left @子Agent messages
353
+ // exposed to reply-session conflicts when the sub-agent session was
354
+ // already busy). Synthetic callers without this flag keep the legacy
355
+ // direct-dispatch behavior.
356
+ inboundQueueEligible,
278
357
  });
279
358
  } catch (error) {
280
359
  const message = getErrorMessage(error);
281
360
  log?.error?.(`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`);
282
- if (error instanceof HostRoutingHelperUnavailableError && !helperMissingWarningSent) {
283
- helperMissingWarningSent = true;
284
- try {
285
- const isGroup = data.conversationType !== "1";
286
- const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
287
- await sendBySession(
288
- dingtalkConfig,
289
- sessionWebhook,
290
- "⚠️ 当前宿主版本不支持 DingTalk 子助手路由所需的 session helper,请升级 OpenClaw 后重试。",
291
- sendOptions,
292
- );
293
- } catch (notifyError: unknown) {
294
- log?.debug?.(
295
- `[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`,
296
- );
297
- }
361
+ if (error instanceof HostRoutingHelperUnavailableError) {
362
+ await sendHelperMissingWarning();
298
363
  }
299
364
  }
300
365
  }