@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
@@ -351,10 +351,13 @@ interface CreateAICardOptions {
351
351
  statusLine?: string;
352
352
  }
353
353
 
354
+ type PendingCardRecoveryAction = "finalize" | "recall";
355
+
354
356
  interface PendingCardRecord {
355
357
  accountId: string;
356
358
  cardInstanceId: string;
357
359
  outTrackId?: string;
360
+ processQueryKey?: string;
358
361
  conversationId: string;
359
362
  contextConversationId?: string;
360
363
  createdAt: number;
@@ -363,6 +366,8 @@ interface PendingCardRecord {
363
366
  lastContent?: string;
364
367
  lastBlockListJson?: string;
365
368
  streamLifecycleOpened?: boolean;
369
+ /** Recovery action to retry after a remote card update failed. */
370
+ recoveryAction?: PendingCardRecoveryAction;
366
371
  }
367
372
 
368
373
  interface PendingCardStateFile {
@@ -399,10 +404,12 @@ function normalizePendingState(parsed: Partial<PendingCardStateFile>): PendingCa
399
404
  typeof entry.accountId === "string" &&
400
405
  typeof entry.cardInstanceId === "string" &&
401
406
  (entry.outTrackId === undefined || typeof entry.outTrackId === "string") &&
407
+ (entry.processQueryKey === undefined || typeof entry.processQueryKey === "string") &&
402
408
  typeof entry.conversationId === "string" &&
403
409
  (entry.lastContent === undefined || typeof entry.lastContent === "string") &&
404
410
  (entry.lastBlockListJson === undefined || typeof entry.lastBlockListJson === "string") &&
405
- (entry.streamLifecycleOpened === undefined || typeof entry.streamLifecycleOpened === "boolean"),
411
+ (entry.streamLifecycleOpened === undefined || typeof entry.streamLifecycleOpened === "boolean") &&
412
+ (entry.recoveryAction === undefined || entry.recoveryAction === "finalize" || entry.recoveryAction === "recall"),
406
413
  ),
407
414
  ),
408
415
  };
@@ -469,6 +476,7 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
469
476
  accountId: card.accountId,
470
477
  cardInstanceId: card.cardInstanceId,
471
478
  outTrackId: card.outTrackId,
479
+ processQueryKey: card.processQueryKey,
472
480
  conversationId: card.conversationId,
473
481
  contextConversationId: card.contextConversationId,
474
482
  createdAt: card.createdAt,
@@ -539,6 +547,33 @@ function removePendingCardById(cardInstanceId: string, storePath?: string, log?:
539
547
  writePendingCardState(state, storePath, log);
540
548
  }
541
549
 
550
+ function retainPendingCardForRecovery(
551
+ card: AICardInstance,
552
+ recoveryAction: PendingCardRecoveryAction,
553
+ log?: Logger,
554
+ ): void {
555
+ if (!card.accountId || !card.storePath) {
556
+ return;
557
+ }
558
+ const state = readPendingCardState(card.storePath, log);
559
+ const index = state.pendingCards.findIndex((item) => item.cardInstanceId === card.cardInstanceId);
560
+ if (index < 0) {
561
+ return;
562
+ }
563
+ const existing = state.pendingCards[index];
564
+ state.pendingCards[index] = {
565
+ ...existing,
566
+ state: AICardStatus.FAILED,
567
+ lastUpdated: Date.now(),
568
+ lastContent: card.lastStreamedContent ?? existing.lastContent,
569
+ lastBlockListJson: card.lastBlockListJson ?? existing.lastBlockListJson,
570
+ streamLifecycleOpened: card.streamLifecycleOpened,
571
+ recoveryAction,
572
+ };
573
+ state.updatedAt = Date.now();
574
+ writePendingCardState(state, card.storePath, log);
575
+ }
576
+
542
577
  function listPendingCardsByAccount(
543
578
  accountId: string,
544
579
  storePath?: string,
@@ -713,7 +748,9 @@ async function finalizePendingCardsByAccount(
713
748
  }
714
749
 
715
750
  const pendingCards = listPendingCardsByAccount(accountId, storePath, log).filter(
716
- (item) => !isCardInTerminalState(item.state),
751
+ (item) =>
752
+ (item.recoveryAction === "recall" && mode === "recover")
753
+ || (item.recoveryAction !== "recall" && (!isCardInTerminalState(item.state) || item.recoveryAction === "finalize")),
717
754
  );
718
755
  if (pendingCards.length === 0) {
719
756
  return 0;
@@ -741,6 +778,7 @@ async function finalizePendingCardsByAccount(
741
778
  accountId: entry.accountId,
742
779
  storePath,
743
780
  outTrackId: entry.outTrackId,
781
+ processQueryKey: entry.processQueryKey,
744
782
  createdAt: entry.createdAt || Date.now(),
745
783
  lastUpdated: entry.lastUpdated || Date.now(),
746
784
  state: normalizeRecoveredState(entry.state),
@@ -750,6 +788,12 @@ async function finalizePendingCardsByAccount(
750
788
  streamLifecycleOpened: entry.streamLifecycleOpened,
751
789
  };
752
790
  try {
791
+ if (entry.recoveryAction === "recall") {
792
+ if (await recallAICardMessage(card, log)) {
793
+ finalizedCount += 1;
794
+ }
795
+ continue;
796
+ }
753
797
  await finalizeStoppedAICard(card, {
754
798
  reason,
755
799
  previousContent: entry.lastContent,
@@ -1163,6 +1207,11 @@ export async function commitAICardBlocks(
1163
1207
  } catch (err: unknown) {
1164
1208
  const message = err instanceof Error ? err.message : String(err);
1165
1209
  log?.error?.(`[DingTalk][AICard] Finalize via instances API failed: ${message}`);
1210
+ // The caller cannot finish this card now. Persist a terminal-only recovery
1211
+ // marker so startup retries the neutral close, never the original reply.
1212
+ card.state = AICardStatus.FAILED;
1213
+ card.lastUpdated = Date.now();
1214
+ retainPendingCardForRecovery(card, "finalize", log);
1166
1215
  throw err;
1167
1216
  }
1168
1217
 
@@ -1193,6 +1242,7 @@ export async function streamAICard(
1193
1242
  content: string,
1194
1243
  finished: boolean = false,
1195
1244
  log?: Logger,
1245
+ options: { recoveryAction?: PendingCardRecoveryAction } = {},
1196
1246
  ): Promise<void> {
1197
1247
  if (isCardInTerminalState(card.state)) {
1198
1248
  log?.debug?.(
@@ -1215,7 +1265,9 @@ export async function streamAICard(
1215
1265
  } catch (err: any) {
1216
1266
  card.state = AICardStatus.FAILED;
1217
1267
  card.lastUpdated = Date.now();
1218
- removePendingCard(card, log);
1268
+ // The remote card may still be visible after any update fails. Keep it
1269
+ // recoverable so startup can close it instead of leaving an orphaned card.
1270
+ retainPendingCardForRecovery(card, options.recoveryAction ?? "finalize", log);
1219
1271
  if (err.response?.status === 500 && err.response?.data?.code === "unknownError") {
1220
1272
  const errorMsg =
1221
1273
  "⚠️ **[DingTalk] AI Card 串流更新失败 (500 unknownError)**\n\n"
@@ -1,6 +1,7 @@
1
1
  import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
2
2
  import { analyzeCardCallback } from "../card-callback-service";
3
3
  import { finalizeActiveCardsForAccount, recoverPendingCardsForAccount } from "../card-service";
4
+ import { recoverAskUserQuestionsForAccount } from "../card/ask-user-question";
4
5
  import { handleCardAction } from "../card/card-action-handler";
5
6
  import { resolveRobotCode, resolveRuntimeConfig } from "../config";
6
7
  import { ConnectionManager } from "../connection-manager";
@@ -107,7 +108,16 @@ function instrumentConnectionStages(client: DWClient): void {
107
108
  };
108
109
  }
109
110
 
110
- const INFLIGHT_TTL_MS = 5 * 60 * 1000;
111
+ // In-flight dedup keys are released ONLY by the `finally` block after
112
+ // `handleDingTalkMessage` settles. There is intentionally NO time-based TTL:
113
+ // the inbound session queue may hold a message for up to
114
+ // `MAX_INBOUND_SESSION_QUEUE_WAIT_MS` (15 minutes), which is longer than the
115
+ // previous 5-minute TTL. Releasing the lock by wall clock while the queued
116
+ // message is still pending let DingTalk retries enter a SECOND in-flight
117
+ // record, and both copies eventually executed — duplicating external side
118
+ // effects. A hung handler now permanently holds its msgId (acceptable: the
119
+ // user-visible symptom is "this message isn't being processed" and recovery
120
+ // is a process restart, not silent duplicate execution).
111
121
  const processingDedupKeys = new Map<string, number>();
112
122
  export const CHANNEL_INFLIGHT_NAMESPACE_POLICY = "memory-only" as const;
113
123
  const inboundCountersByAccount = new Map<
@@ -199,6 +209,23 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
199
209
  `[${account.accountId}] Failed to recover unfinished cards: ${err.message}`,
200
210
  );
201
211
  }
212
+ try {
213
+ const recoveredQuestions = await recoverAskUserQuestionsForAccount({
214
+ storePath: accountStorePath,
215
+ accountId: account.accountId,
216
+ config,
217
+ log: pluginLog,
218
+ });
219
+ if (recoveredQuestions > 0) {
220
+ pluginLog?.info?.(
221
+ `[${account.accountId}] Invalidated ${recoveredQuestions} unfinished Ask User card(s) from previous runtime`,
222
+ );
223
+ }
224
+ } catch (err: any) {
225
+ pluginLog?.warn?.(
226
+ `[${account.accountId}] Failed to recover Ask User cards: ${err.message}`,
227
+ );
228
+ }
202
229
 
203
230
  const useConnectionManager = config.useConnectionManager ?? true;
204
231
  const applyStatusPatch = (patch: Record<string, unknown>) => {
@@ -264,6 +291,8 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
264
291
  sessionWebhook: data.sessionWebhook,
265
292
  log: pluginLog,
266
293
  dingtalkConfig: config,
294
+ inboundOrigin: "stream",
295
+ inboundQueueEligible: true,
267
296
  });
268
297
  stats.processed += 1;
269
298
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
@@ -280,22 +309,17 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
280
309
  return;
281
310
  }
282
311
 
283
- const inflightSince = processingDedupKeys.get(dedupKey);
284
- if (inflightSince !== undefined) {
285
- if (Date.now() - inflightSince > INFLIGHT_TTL_MS) {
286
- pluginLog?.warn?.(
287
- `[${account.accountId}] Releasing stale in-flight lock for ${dedupKey} (held ${Date.now() - inflightSince}ms > TTL ${INFLIGHT_TTL_MS}ms)`,
288
- );
289
- processingDedupKeys.delete(dedupKey);
290
- } else {
291
- pluginLog?.debug?.(
292
- `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
293
- );
294
- stats.inflightSkipped += 1;
295
- acknowledge();
296
- logInboundCounters(pluginLog, account.accountId, "inflight-skipped");
297
- return;
298
- }
312
+ // In-flight lock: only the `finally` block below ever releases
313
+ // this key. See `processingDedupKeys` declaration for why we no
314
+ // longer release by time-based TTL.
315
+ if (processingDedupKeys.has(dedupKey)) {
316
+ pluginLog?.debug?.(
317
+ `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
318
+ );
319
+ stats.inflightSkipped += 1;
320
+ acknowledge();
321
+ logInboundCounters(pluginLog, account.accountId, "inflight-skipped");
322
+ return;
299
323
  }
300
324
 
301
325
  acknowledge();
@@ -308,6 +332,8 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
308
332
  sessionWebhook: data.sessionWebhook,
309
333
  log: pluginLog,
310
334
  dingtalkConfig: config,
335
+ inboundOrigin: "stream",
336
+ inboundQueueEligible: true,
311
337
  });
312
338
  stats.processed += 1;
313
339
  markMessageProcessed(dedupKey);
@@ -382,6 +408,7 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
382
408
  analysis,
383
409
  cfg,
384
410
  accountId: account.accountId,
411
+ storePath: accountStorePath,
385
412
  config,
386
413
  log: pluginLog,
387
414
  });
@@ -551,19 +578,13 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
551
578
  lastError: null,
552
579
  });
553
580
  } else if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
554
- const robotKey = resolveRobotCode(config) || account.accountId;
555
- let cleared = 0;
556
- for (const key of processingDedupKeys.keys()) {
557
- if (key.startsWith(`${robotKey}:`)) {
558
- processingDedupKeys.delete(key);
559
- cleared++;
560
- }
561
- }
562
- if (cleared > 0) {
563
- pluginLog?.info?.(
564
- `[${account.accountId}] Cleared ${cleared} stale in-flight lock(s) on disconnect`,
565
- );
566
- }
581
+ // Intentionally do NOT clear `processingDedupKeys` here: a stream
582
+ // disconnect must not release in-flight locks for messages whose
583
+ // handler is still executing (or queued behind an active run),
584
+ // because DingTalk will resend on reconnect and a cleared lock
585
+ // would let the resend enter a SECOND handler invocation —
586
+ // duplicating external side effects. Only `finally` after the
587
+ // handler settles releases a key.
567
588
  applyStatusPatch({
568
589
  running: false,
569
590
  connected: false,
@@ -0,0 +1,304 @@
1
+ // Authorized inbound serializer: wraps the already-authorized, route-resolved
2
+ // portion of `handleDingTalkMessage` with the promise-chain queue from
3
+ // `inbound-session-queue.ts`.
4
+ //
5
+ // The gateway intentionally does not call this module. At the gateway boundary
6
+ // we only have raw accountId + conversationId, before DM/group policy, allowlist
7
+ // checks, special-command bypasses, and trusted agent routing. Queueing there
8
+ // could acknowledge an unauthorized sender or block /stop and /btw. The caller
9
+ // supplies the resolved route.sessionKey only after those decisions, so a message
10
+ // that arrives while the same real session is active is queued and reprocessed
11
+ // once the active run finishes instead of racing into the core's
12
+ // "reply session initialization conflicted for <sessionKey>"
13
+ // and being dropped silently at the gateway catch block (the
14
+ // "钉钉'确认'消息无响应" regression.
15
+ //
16
+ // While a message is queued, a pre-created AI Card shows an immediate
17
+ // "已排队" acknowledgement; the handler later reuses that same card
18
+ // (`params.preCreatedCard`) to stream the real reply in place.
19
+ //
20
+ // Ported from DingTalk-Real-AI/dingtalk-openclaw-connector's session-queue
21
+ // orchestrator, adapted to soimy's blocking gateway contract (we await each
22
+ // task so the gateway's per-message dedup stays correct).
23
+
24
+ import { attachNativeAckReaction } from "../ack-reaction-service";
25
+ import {
26
+ createAICard,
27
+ isCardInTerminalState,
28
+ recallAICardMessage,
29
+ streamAICard,
30
+ } from "../card-service";
31
+ import {
32
+ chainInboundSessionTask,
33
+ getInboundSessionQueueDepth,
34
+ InboundSessionQueueWaitTimeoutError,
35
+ isInboundSessionQueueBusy,
36
+ MAX_INBOUND_SESSION_QUEUE_DEPTH,
37
+ MAX_INBOUND_SESSION_QUEUE_WAIT_MS,
38
+ pickQueueBusyAckPhrase,
39
+ } from "./inbound-session-queue";
40
+ import { sendMessage } from "../send-service";
41
+ import type { AICardInstance, DingTalkConfig, DingTalkInboundMessage, Logger } from "../types";
42
+
43
+ export interface InboundQueueDispatchInput {
44
+ accountId: string;
45
+ data: DingTalkInboundMessage;
46
+ dingtalkConfig: DingTalkConfig;
47
+ /** Trusted route.sessionKey, resolved after access control. */
48
+ sessionKey: string;
49
+ /** Resolved DingTalk reply target for this authorized route. */
50
+ to: string;
51
+ storePath?: string;
52
+ quoteContent?: string;
53
+ log?: Logger;
54
+ }
55
+
56
+ const QUEUE_FULL_ACK = "当前消息较多,已达到本会话排队上限;请等待上一轮完成后再发送。";
57
+ const QUEUE_WAIT_TIMEOUT_ACK = "上一轮处理时间较长,这条消息未执行;请稍后重新发送。";
58
+ const QUEUE_HANDLER_FAILURE_ACK = "本次处理异常,未能完成;请稍后重新发送。";
59
+ const MIN_QUEUE_ACK_CARD_VISIBLE_MS = 750;
60
+
61
+ const queuedAckVisibleAt = new WeakMap<AICardInstance, number>();
62
+
63
+ function shouldPrepareQueueAckCard(input: InboundQueueDispatchInput): boolean {
64
+ return input.dingtalkConfig.messageType === "card";
65
+ }
66
+
67
+ async function keepQueueAckCardVisible(card: AICardInstance): Promise<void> {
68
+ const visibleAt = queuedAckVisibleAt.get(card);
69
+ if (!visibleAt) {
70
+ return;
71
+ }
72
+ const remainingMs = MIN_QUEUE_ACK_CARD_VISIBLE_MS - (Date.now() - visibleAt);
73
+ if (remainingMs > 0) {
74
+ await new Promise<void>((resolve) => setTimeout(resolve, remainingMs));
75
+ }
76
+ }
77
+
78
+ async function settleUnusedQueueAckCard(
79
+ input: InboundQueueDispatchInput,
80
+ card: AICardInstance,
81
+ ): Promise<void> {
82
+ if (isCardInTerminalState(card.state)) {
83
+ return;
84
+ }
85
+ try {
86
+ if (await recallAICardMessage(card, input.log)) {
87
+ return;
88
+ }
89
+ } catch (err: unknown) {
90
+ input.log?.warn?.(
91
+ `[DingTalk] Failed to recall unused queue acknowledgement card: ${err instanceof Error ? err.message : String(err)}`,
92
+ );
93
+ }
94
+ await sendQueueTerminalAck(input, "已结束排队确认,请以本次实际回复为准。", card);
95
+ }
96
+
97
+ /**
98
+ * A visible queue ACK promises that the message will be handled. If the
99
+ * queued continuation fails before consuming that card, finish it with a
100
+ * retryable outcome instead of recalling the only user-visible feedback.
101
+ */
102
+ async function settleFailedQueueAckCard(
103
+ input: InboundQueueDispatchInput,
104
+ card: AICardInstance,
105
+ ): Promise<void> {
106
+ if (isCardInTerminalState(card.state)) {
107
+ return;
108
+ }
109
+ await sendQueueTerminalAck(input, QUEUE_HANDLER_FAILURE_ACK, card);
110
+ }
111
+
112
+ /**
113
+ * Serialize an inbound message per conversation, then invoke `handler` (which
114
+ * should call `handleDingTalkMessage` with the provided `preCreatedCard`).
115
+ *
116
+ * The returned promise settles with the handler's own outcome, so the caller
117
+ * (gateway) can await it and keep its per-message dedup correct
118
+ * (`markMessageProcessed` only runs once the message truly completes).
119
+ */
120
+ export async function dispatchInboundViaSessionQueue<T>(
121
+ input: InboundQueueDispatchInput,
122
+ handler: (preCreatedCard?: AICardInstance) => Promise<T>,
123
+ ): Promise<T> {
124
+ const queueKey = input.sessionKey;
125
+ if (!queueKey) {
126
+ // A trusted route must include a session key. Keep this defensive fallback
127
+ // for alternate callers rather than inventing a raw gateway-level key.
128
+ return handler(undefined);
129
+ }
130
+ const wasBusy = isInboundSessionQueueBusy(queueKey);
131
+ if (getInboundSessionQueueDepth(queueKey) >= MAX_INBOUND_SESSION_QUEUE_DEPTH) {
132
+ await sendQueueTerminalAck(input, QUEUE_FULL_ACK);
133
+ return undefined as T;
134
+ }
135
+ // Detect busyness BEFORE chaining: this call is "busy" only if a PRIOR task
136
+ // for this conversation is still running.
137
+ // Start preparing a busy ACK without awaiting it before we reserve a queue
138
+ // slot below. Otherwise a burst of inbound messages can all observe the
139
+ // same pre-await depth and each pass the cap check.
140
+ let queuedAckState: "queued" | "timed-out" = "queued";
141
+ const preCreatedCardPromise = wasBusy && shouldPrepareQueueAckCard(input)
142
+ ? tryPrepareQueueAckCard(input, () =>
143
+ queuedAckState === "timed-out"
144
+ ? { content: QUEUE_WAIT_TIMEOUT_ACK, finished: true }
145
+ : { content: pickQueueBusyAckPhrase(), finished: false },
146
+ )
147
+ : undefined;
148
+ // Chain onto the prior task for this conversation and AWAIT. Awaiting (rather
149
+ // than fire-and-forget) preserves the gateway's per-message dedup:
150
+ // `markMessageProcessed` runs only after this message truly completes, so a
151
+ // still-queued message is never marked processed.
152
+ try {
153
+ return await chainInboundSessionTask(
154
+ queueKey,
155
+ async () => {
156
+ const preCreatedCard = preCreatedCardPromise
157
+ ? await preCreatedCardPromise
158
+ : undefined;
159
+ if (!preCreatedCard) {
160
+ return handler(undefined);
161
+ }
162
+ await keepQueueAckCardVisible(preCreatedCard);
163
+ let handlerFailed = false;
164
+ try {
165
+ return await handler(preCreatedCard);
166
+ } catch (err: unknown) {
167
+ handlerFailed = true;
168
+ await settleFailedQueueAckCard(input, preCreatedCard);
169
+ throw err;
170
+ } finally {
171
+ if (!handlerFailed && !isCardInTerminalState(preCreatedCard.state)) {
172
+ await settleUnusedQueueAckCard(input, preCreatedCard);
173
+ }
174
+ }
175
+ },
176
+ {
177
+ maxQueueWaitMs: wasBusy ? MAX_INBOUND_SESSION_QUEUE_WAIT_MS : undefined,
178
+ },
179
+ );
180
+ } catch (err: unknown) {
181
+ if (err instanceof InboundSessionQueueWaitTimeoutError) {
182
+ queuedAckState = "timed-out";
183
+ await sendQueueTerminalAck(
184
+ input,
185
+ QUEUE_WAIT_TIMEOUT_ACK,
186
+ preCreatedCardPromise ? await preCreatedCardPromise : undefined,
187
+ );
188
+ return undefined as T;
189
+ }
190
+ throw err;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Pre-create an AI Card showing a "已排队" acknowledgement for a message that
196
+ * arrived while its conversation was busy. The handler later reuses this card
197
+ * to stream the real reply in place. Best-effort: any failure returns
198
+ * undefined and the handler falls back to creating a fresh card (or markdown).
199
+ */
200
+ async function tryPrepareQueueAckCard(
201
+ input: InboundQueueDispatchInput,
202
+ ack: () => { content: string; finished: boolean },
203
+ ): Promise<AICardInstance | undefined> {
204
+ const { dingtalkConfig, data, log, to, storePath, quoteContent } = input;
205
+ if (!data) {
206
+ return undefined;
207
+ }
208
+ if (!to) {
209
+ return undefined;
210
+ }
211
+ let card: AICardInstance | null = null;
212
+ let ackFinished = false;
213
+ try {
214
+ card = await createAICard(dingtalkConfig, to, log, {
215
+ accountId: input.accountId,
216
+ storePath,
217
+ quoteContent,
218
+ });
219
+ if (!card) {
220
+ return undefined;
221
+ }
222
+ const { content, finished } = ack();
223
+ ackFinished = finished;
224
+ await streamAICard(card, content, finished, log, {
225
+ recoveryAction: finished ? "finalize" : "recall",
226
+ });
227
+ if (!finished) {
228
+ queuedAckVisibleAt.set(card, Date.now());
229
+ }
230
+ if (finished) {
231
+ return card;
232
+ }
233
+ // Best-effort thinking reaction; failures must not block the queue.
234
+ void attachNativeAckReaction(
235
+ dingtalkConfig,
236
+ { msgId: data.msgId, conversationId: data.conversationId },
237
+ log,
238
+ ).catch((err: unknown) => {
239
+ log?.debug?.(
240
+ `[DingTalk] Queue-busy ack reaction attach failed: ${err instanceof Error ? err.message : String(err)}`,
241
+ );
242
+ });
243
+ log?.info?.(
244
+ `[DingTalk] Inbound message queued behind active run for session=${input.sessionKey}; pre-created ACK card outTrackId=${card.cardInstanceId}.`,
245
+ );
246
+ return card;
247
+ } catch (err: unknown) {
248
+ if (card && !ackFinished) {
249
+ try {
250
+ await recallAICardMessage(card, log);
251
+ } catch (recallErr: unknown) {
252
+ log?.warn?.(
253
+ `[DingTalk] Failed to recall queue ACK card after prepare failure: ${recallErr instanceof Error ? recallErr.message : String(recallErr)}`,
254
+ );
255
+ }
256
+ }
257
+ log?.warn?.(
258
+ `[DingTalk] Queue-busy ACK card prepare failed: ${err instanceof Error ? err.message : String(err)}`,
259
+ );
260
+ return undefined;
261
+ }
262
+ }
263
+
264
+ async function sendQueueTerminalAck(
265
+ input: InboundQueueDispatchInput,
266
+ content: string,
267
+ preCreatedCard?: AICardInstance,
268
+ ): Promise<void> {
269
+ const { dingtalkConfig, data, log, to, storePath } = input;
270
+ try {
271
+ if (preCreatedCard) {
272
+ try {
273
+ await streamAICard(preCreatedCard, content, true, log);
274
+ return;
275
+ } catch (err: unknown) {
276
+ log?.warn?.(
277
+ `[DingTalk] Queue acknowledgement card finalization failed; falling back to text: ${err instanceof Error ? err.message : String(err)}`,
278
+ );
279
+ }
280
+ } else {
281
+ const card = await tryPrepareQueueAckCard(input, () => ({ content, finished: true }));
282
+ if (card) {
283
+ return;
284
+ }
285
+ }
286
+ if (!to) {
287
+ return;
288
+ }
289
+ const result = await sendMessage(dingtalkConfig, to, content, {
290
+ sessionWebhook: data.sessionWebhook,
291
+ log,
292
+ accountId: input.accountId,
293
+ storePath,
294
+ conversationId: data.conversationId,
295
+ });
296
+ if (!result.ok) {
297
+ log?.warn?.(`[DingTalk] Queue terminal acknowledgement failed: ${result.error || "unknown"}`);
298
+ }
299
+ } catch (err: unknown) {
300
+ log?.warn?.(
301
+ `[DingTalk] Queue terminal acknowledgement delivery failed: ${err instanceof Error ? err.message : String(err)}`,
302
+ );
303
+ }
304
+ }