@soimy/dingtalk 3.6.7 → 3.6.9

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.
@@ -34,6 +34,7 @@ import {
34
34
  } from "./config";
35
35
  import { buildLearningContextBlock, isLearningEnabled } from "./feedback-learning-service";
36
36
  import axios from "./http-client";
37
+ import { dispatchInboundViaSessionQueue } from "./gateway/inbound-session-queue-dispatcher";
37
38
  import { setCurrentLogger } from "./logger-context";
38
39
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
39
40
  import {
@@ -60,6 +61,10 @@ import {
60
61
  clearProactiveRiskObservationsForTest,
61
62
  getProactiveRiskObservationForAny,
62
63
  } from "./proactive-risk-registry";
64
+ import {
65
+ isReplySessionConflictError,
66
+ withReplySessionConflictRetry,
67
+ } from "./gateway/reply-session-conflict";
63
68
  import { createReplyStrategy } from "./reply-strategy";
64
69
  import type { DeliverPayload } from "./reply-strategy-types";
65
70
  import { getDingTalkRuntime } from "./runtime";
@@ -571,8 +576,9 @@ export async function downloadMedia(
571
576
 
572
577
  export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
573
578
  // Keep context creation inside this public inbound entry. Ask-user synthetic
574
- // reinjections call handleDingTalkMessage directly, so moving this wrapper to
575
- // 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.
576
582
  return withDingTalkQuestionContext(
577
583
  {
578
584
  cfg: params.cfg,
@@ -880,6 +886,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
880
886
  handleMessage: handleDingTalkMessage,
881
887
  downloadMedia,
882
888
  log,
889
+ inboundQueueEligible: params.inboundQueueEligible,
883
890
  });
884
891
  return;
885
892
  }
@@ -914,6 +921,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
914
921
  handleMessage: handleDingTalkMessage,
915
922
  downloadMedia,
916
923
  log,
924
+ inboundQueueEligible: params.inboundQueueEligible,
917
925
  });
918
926
  return;
919
927
  }
@@ -999,7 +1007,45 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
999
1007
  const quotedRef = buildInboundQuotedRef(data, extractedContent);
1000
1008
  const replyQuotedRef = createReplyQuotedRef(data.msgId);
1001
1009
  const content = extractedContent;
1002
- 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
+ }
1003
1049
  const taskInfoConversationId = groupId || to;
1004
1050
  const agentDisplayName = getAgentDisplayName({
1005
1051
  subAgentOptions,
@@ -1092,7 +1138,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
1092
1138
  };
1093
1139
  }
1094
1140
 
1095
- if (useCardMode && !isBtwBypass) {
1141
+ if (useCardMode && !isBtwBypass && !params.preCreatedCard) {
1096
1142
  const key = `${accountId}:${to}`;
1097
1143
  if (cardCreationInFlight.has(key)) {
1098
1144
  useCardMode = false;
@@ -1115,13 +1161,18 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
1115
1161
  // Use rawInboundText ( preserved before sub-agent rewriting) to avoid
1116
1162
  // showing internal routing context like "[你被 @ 为...]" in the card UI.
1117
1163
  const inboundQuoteText = rawInboundText.slice(0, 200);
1118
- const aiCard = await createAICard(dingtalkConfig, to, log, {
1119
- accountId,
1120
- storePath: accountStorePath,
1121
- contextConversationId: groupId,
1122
- quoteContent: inboundQuoteText,
1123
- statusLine: initialStatusLine,
1124
- });
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
+ }));
1125
1176
  if (aiCard) {
1126
1177
  currentAICard = aiCard;
1127
1178
  if (aiCard.outTrackId) {
@@ -1830,12 +1881,11 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
1830
1881
  // tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
1831
1882
  // rather than waiting for it to finish before the stop message is processed.
1832
1883
  //
1833
- // Strip leading @mention tokens before the abort check so that messages like
1834
- // "@Agent /stop" are correctly recognised as abort requests in both DM and group
1835
- // chats. In groups DingTalk usually strips @BotName at the protocol level, but
1836
- // in DMs with multi-agent routing the @mention prefix survives all the way here.
1837
- const textForAbortCheck = stripLeadingMentions(inboundText).trim();
1838
- 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) {
1839
1889
  log?.info?.(
1840
1890
  `[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
1841
1891
  );
@@ -2298,9 +2348,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2298
2348
  taskMeta,
2299
2349
  });
2300
2350
 
2301
- try {
2302
- let deliveredFinalCount = 0;
2303
- 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({
2304
2357
  ctx,
2305
2358
  cfg,
2306
2359
  dispatcherOptions: {
@@ -2347,6 +2400,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2347
2400
  replyOptions: strategy.getReplyOptions(),
2348
2401
  });
2349
2402
 
2403
+ try {
2404
+ const dispatchResult = await withReplySessionConflictRetry(runDispatch, {
2405
+ log,
2406
+ sessionKey: route.sessionKey,
2407
+ });
2408
+
2350
2409
  const bufferedFinal =
2351
2410
  dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
2352
2411
  ? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
@@ -2394,6 +2453,71 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
2394
2453
  } catch (dispatchErr: unknown) {
2395
2454
  const error =
2396
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
+ }
2397
2521
  await strategy.abort(error);
2398
2522
  throw dispatchErr;
2399
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
 
@@ -214,6 +214,15 @@ export async function dispatchSubAgents(params: {
214
214
  log?: Logger,
215
215
  ) => Promise<{ path: string; mimeType: string } | null>;
216
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;
217
226
  }): Promise<void> {
218
227
  const {
219
228
  matchedAgents,
@@ -229,6 +238,7 @@ export async function dispatchSubAgents(params: {
229
238
  handleMessage,
230
239
  downloadMedia: download,
231
240
  log,
241
+ inboundQueueEligible,
232
242
  } = params;
233
243
 
234
244
  let helperMissingWarningSent = false;
@@ -337,6 +347,13 @@ export async function dispatchSubAgents(params: {
337
347
  commandText,
338
348
  },
339
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,
340
357
  });
341
358
  } catch (error) {
342
359
  const message = getErrorMessage(error);
package/src/types.ts CHANGED
@@ -484,6 +484,12 @@ export interface HandleDingTalkMessageParams {
484
484
  dingtalkConfig: DingTalkConfig;
485
485
  /** Distinguishes real Stream messages from Ask User callback reinjection. */
486
486
  inboundOrigin?: "stream" | "ask-user";
487
+ /**
488
+ * Explicitly enables handler-owned queueing for a real gateway Stream
489
+ * callback. Direct/synthetic callers must not be inferred from raw message
490
+ * shape, because they may already be inside another handler lifecycle.
491
+ */
492
+ inboundQueueEligible?: boolean;
487
493
  /** Reuses an already-resolved trusted route for recursive or synthetic handling. */
488
494
  routeOverride?: ResolvedDingTalkRoute;
489
495
  /**
@@ -501,6 +507,22 @@ export interface HandleDingTalkMessageParams {
501
507
  mediaPaths?: string[];
502
508
  mediaTypes?: string[];
503
509
  };
510
+ /**
511
+ * A pre-created AI Card to reuse instead of creating a new one. Set by the
512
+ * inbound session-queue dispatcher when a message was queued behind an active
513
+ * run: the card was already created and is showing a "已排队" acknowledgement,
514
+ * so the handler streams the real reply INTO this same card (in-place update)
515
+ * rather than spawning a second card. When unset, the handler creates a fresh
516
+ * card as usual.
517
+ */
518
+ preCreatedCard?: AICardInstance;
519
+ /**
520
+ * Internal recursion guard for the authorized inbound session queue. The
521
+ * first handler pass performs access control and trusted route resolution;
522
+ * the queued continuation re-enters with this set so it can consume the
523
+ * pre-created card without queueing itself again.
524
+ */
525
+ inboundQueueHandled?: boolean;
504
526
  }
505
527
 
506
528
  /**