@soimy/dingtalk 3.6.6 → 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.
@@ -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,
@@ -81,7 +85,13 @@ import {
81
85
  upsertObservedUserTarget,
82
86
  } from "./targeting/target-directory-store";
83
87
  import { AICardStatus } from "./types";
84
- import type { DingTalkConfig, HandleDingTalkMessageParams, Logger, MediaFile } from "./types";
88
+ import type {
89
+ DingTalkConfig,
90
+ HandleDingTalkMessageParams,
91
+ Logger,
92
+ MediaFile,
93
+ ResolvedDingTalkRoute,
94
+ } from "./types";
85
95
  import {
86
96
  formatDingTalkErrorPayloadLog,
87
97
  getErrorMessage,
@@ -584,6 +594,8 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
584
594
  sessionWebhook,
585
595
  log,
586
596
  dingtalkConfig,
597
+ inboundOrigin = "stream",
598
+ routeOverride,
587
599
  subAgentOptions,
588
600
  preDownloadedMedia,
589
601
  } = params;
@@ -810,38 +822,42 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
810
822
  ? null
811
823
  : resolveMessageTarget({ extractedContent, cfg, isGroup });
812
824
 
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 },
825
+ const invalidateQuestionRoutes = (routes: readonly ResolvedDingTalkRoute[]): void => {
826
+ if (inboundOrigin === "ask-user") {
827
+ return;
828
+ }
829
+ const scopeKeys = new Set(
830
+ routes.map((resolvedRoute) => `${accountId}:${resolvedRoute.sessionKey}:${senderId}`),
831
+ );
832
+ const invalidatedRecords = [];
833
+ for (const questionScopeKey of scopeKeys) {
834
+ try {
835
+ invalidatedRecords.push(
836
+ ...invalidateAskUserQuestionsForScope({
837
+ storePath: accountStorePath,
838
+ accountId,
839
+ questionScopeKey,
840
+ reason: "superseded_by_message",
841
+ log,
842
+ }),
843
+ );
844
+ } catch (err) {
845
+ log?.warn?.(
846
+ `[DingTalk][AskUser] Failed to invalidate pending cards before inbound dispatch scope=${questionScopeKey}: ${String(err)}`,
847
+ );
848
+ }
849
+ }
850
+ if (invalidatedRecords.length === 0) {
851
+ return;
852
+ }
853
+ void syncInvalidatedAskUserQuestionCards({
854
+ records: invalidatedRecords,
855
+ config: dingtalkConfig,
856
+ log,
857
+ }).catch((err) => {
858
+ log?.warn?.(`[DingTalk][AskUser] Card invalidation sync failed: ${String(err)}`);
839
859
  });
840
- }
841
- const questionContext = getDingTalkQuestionContext();
842
- if (questionContext) {
843
- questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
844
- }
860
+ };
845
861
 
846
862
  // @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
847
863
  // Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
@@ -858,6 +874,9 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
858
874
  dingtalkConfig,
859
875
  sessionWebhook,
860
876
  extractedContent,
877
+ sessionPeer,
878
+ onRoutesResolved: (targets) =>
879
+ invalidateQuestionRoutes(targets.map((target) => target.route)),
861
880
  handleMessage: handleDingTalkMessage,
862
881
  downloadMedia,
863
882
  log,
@@ -889,6 +908,9 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
889
908
  dingtalkConfig,
890
909
  sessionWebhook,
891
910
  extractedContent,
911
+ sessionPeer,
912
+ onRoutesResolved: (targets) =>
913
+ invalidateQuestionRoutes(targets.map((target) => target.route)),
892
914
  handleMessage: handleDingTalkMessage,
893
915
  downloadMedia,
894
916
  log,
@@ -898,6 +920,47 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
898
920
  // Only invalid agent names and no match: fall through to the default route.
899
921
  }
900
922
 
923
+ let route: ResolvedDingTalkRoute;
924
+ if (routeOverride) {
925
+ route = routeOverride;
926
+ } else if (subAgentOptions) {
927
+ route = {
928
+ agentId: subAgentOptions.agentId,
929
+ sessionKey: buildAgentSessionKey({
930
+ rt,
931
+ cfg,
932
+ accountId,
933
+ agentId: subAgentOptions.agentId,
934
+ peerKind: sessionPeer.kind,
935
+ peerId: sessionPeer.peerId,
936
+ }),
937
+ mainSessionKey: "",
938
+ };
939
+ } else {
940
+ route = rt.channel.routing.resolveAgentRoute({
941
+ cfg,
942
+ channel: "dingtalk",
943
+ accountId,
944
+ peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
945
+ });
946
+ }
947
+ const questionContext = getDingTalkQuestionContext();
948
+ if (questionContext) {
949
+ questionContext.resolvedRoute = route;
950
+ questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
951
+ questionContext.storePath = accountStorePath;
952
+ questionContext.continuationSubAgentOptions = subAgentOptions
953
+ ? {
954
+ agentId: subAgentOptions.agentId,
955
+ responsePrefix: subAgentOptions.responsePrefix,
956
+ matchedName: subAgentOptions.matchedName,
957
+ }
958
+ : undefined;
959
+ }
960
+ if (!subAgentOptions) {
961
+ invalidateQuestionRoutes([route]);
962
+ }
963
+
901
964
  // Route resolved before media download for session context and routing metadata.
902
965
  const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
903
966
  agentId: route.agentId,
@@ -978,7 +1041,6 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
978
1041
  let cardFlightKey: string | undefined;
979
1042
  if (questionContext) {
980
1043
  questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
981
- questionCardTookOver = true;
982
1044
  if (cardFlightKey) {
983
1045
  cardCreationInFlight.delete(cardFlightKey);
984
1046
  cardFlightKey = undefined;
@@ -999,23 +1061,34 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
999
1061
  log?.warn?.(
1000
1062
  `[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
1001
1063
  );
1064
+ return false;
1002
1065
  }
1066
+ questionCardTookOver = true;
1003
1067
  if (!currentAICard) {
1004
- return;
1068
+ return true;
1005
1069
  }
1006
1070
  if (isCardInTerminalState(currentAICard.state)) {
1007
- return;
1071
+ return true;
1072
+ }
1073
+ let recalled = false;
1074
+ try {
1075
+ recalled = await recallAICardMessage(currentAICard, log);
1076
+ } catch (err) {
1077
+ log?.warn?.(
1078
+ `[DingTalk][AskUser] Question card took over, but AI card recall errored question=${questionId} outTrackId=${outTrackId}: ${err instanceof Error ? err.message : String(err)}`,
1079
+ );
1080
+ return true;
1008
1081
  }
1009
- const recalled = await recallAICardMessage(currentAICard, log);
1010
1082
  if (!recalled) {
1011
1083
  log?.warn?.(
1012
1084
  `[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
1013
1085
  );
1014
- return;
1086
+ return true;
1015
1087
  }
1016
1088
  log?.info?.(
1017
1089
  `[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
1018
1090
  );
1091
+ return true;
1019
1092
  };
1020
1093
  }
1021
1094
 
@@ -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,
@@ -215,11 +224,64 @@ export async function dispatchSubAgents(params: {
215
224
  dingtalkConfig,
216
225
  sessionWebhook,
217
226
  extractedContent,
227
+ sessionPeer,
228
+ onRoutesResolved,
218
229
  handleMessage,
219
230
  downloadMedia: download,
220
231
  log,
221
232
  } = params;
222
233
 
234
+ let helperMissingWarningSent = false;
235
+ const sendHelperMissingWarning = async (): Promise<void> => {
236
+ if (helperMissingWarningSent) {
237
+ return;
238
+ }
239
+ helperMissingWarningSent = true;
240
+ try {
241
+ const isGroup = data.conversationType !== "1";
242
+ const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
243
+ await sendBySession(
244
+ dingtalkConfig,
245
+ sessionWebhook,
246
+ "⚠️ 当前宿主版本不支持 DingTalk 子助手路由所需的 session helper,请升级 OpenClaw 后重试。",
247
+ sendOptions,
248
+ );
249
+ } catch (notifyError: unknown) {
250
+ log?.debug?.(
251
+ `[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`,
252
+ );
253
+ }
254
+ };
255
+
256
+ let resolvedTargets: ResolvedSubAgentTarget[];
257
+ try {
258
+ const rt = getDingTalkRuntime();
259
+ resolvedTargets = matchedAgents.map((agent) => ({
260
+ agent,
261
+ route: {
262
+ agentId: agent.agentId,
263
+ sessionKey: buildAgentSessionKey({
264
+ rt,
265
+ cfg,
266
+ accountId,
267
+ agentId: agent.agentId,
268
+ peerKind: sessionPeer.kind,
269
+ peerId: sessionPeer.peerId,
270
+ }),
271
+ mainSessionKey: "",
272
+ },
273
+ }));
274
+ } catch (error) {
275
+ const message = getErrorMessage(error);
276
+ log?.error?.(`[DingTalk] Failed to resolve sub-agent routes: ${message}`);
277
+ if (error instanceof HostRoutingHelperUnavailableError) {
278
+ await sendHelperMissingWarning();
279
+ return;
280
+ }
281
+ throw error;
282
+ }
283
+ onRoutesResolved?.(resolvedTargets);
284
+
223
285
  // Pre-download media once to avoid duplication across sub-agents
224
286
  let preDownloadedMedia:
225
287
  | {
@@ -255,9 +317,8 @@ export async function dispatchSubAgents(params: {
255
317
  };
256
318
  }
257
319
  }
258
- let helperMissingWarningSent = false;
259
-
260
- for (const agentMatch of matchedAgents) {
320
+ for (const target of resolvedTargets) {
321
+ const agentMatch = target.agent;
261
322
  try {
262
323
  await handleMessage({
263
324
  cfg,
@@ -266,6 +327,7 @@ export async function dispatchSubAgents(params: {
266
327
  sessionWebhook,
267
328
  log,
268
329
  dingtalkConfig,
330
+ routeOverride: target.route,
269
331
  subAgentOptions: {
270
332
  agentId: agentMatch.agentId,
271
333
  responsePrefix: commandText
@@ -279,22 +341,8 @@ export async function dispatchSubAgents(params: {
279
341
  } catch (error) {
280
342
  const message = getErrorMessage(error);
281
343
  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
- }
344
+ if (error instanceof HostRoutingHelperUnavailableError) {
345
+ await sendHelperMissingWarning();
298
346
  }
299
347
  }
300
348
  }
package/src/types.ts CHANGED
@@ -462,6 +462,16 @@ export interface SubAgentOptions {
462
462
  commandText?: string;
463
463
  }
464
464
 
465
+ /**
466
+ * A trusted route resolved by the plugin before recursive or synthetic inbound handling.
467
+ * It is runtime-only and must never be populated from DingTalk callback payload fields.
468
+ */
469
+ export interface ResolvedDingTalkRoute {
470
+ agentId: string;
471
+ sessionKey: string;
472
+ mainSessionKey: string;
473
+ }
474
+
465
475
  /**
466
476
  * Message handler parameters
467
477
  */
@@ -472,6 +482,10 @@ export interface HandleDingTalkMessageParams {
472
482
  sessionWebhook: string;
473
483
  log?: Logger;
474
484
  dingtalkConfig: DingTalkConfig;
485
+ /** Distinguishes real Stream messages from Ask User callback reinjection. */
486
+ inboundOrigin?: "stream" | "ask-user";
487
+ /** Reuses an already-resolved trusted route for recursive or synthetic handling. */
488
+ routeOverride?: ResolvedDingTalkRoute;
475
489
  /**
476
490
  * When set, routes message to the specified sub-agent instead of main agent.
477
491
  * This enables reuse of the main message handling logic for sub-agents.