@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.
- package/dist/index.js +1433 -344
- package/dist/index.js.map +4 -4
- package/dist/src/card/ask-user-question-context.d.ts +5 -2
- package/dist/src/card/ask-user-question-context.d.ts.map +1 -1
- package/dist/src/card/ask-user-question-store.d.ts +42 -0
- package/dist/src/card/ask-user-question-store.d.ts.map +1 -0
- package/dist/src/card/ask-user-question.d.ts +20 -0
- package/dist/src/card/ask-user-question.d.ts.map +1 -1
- package/dist/src/card/card-action-handler.d.ts +1 -0
- package/dist/src/card/card-action-handler.d.ts.map +1 -1
- package/dist/src/card-service.d.ts +4 -1
- package/dist/src/card-service.d.ts.map +1 -1
- package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
- package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts +23 -0
- package/dist/src/gateway/inbound-session-queue-dispatcher.d.ts.map +1 -0
- package/dist/src/gateway/inbound-session-queue.d.ts +46 -0
- package/dist/src/gateway/inbound-session-queue.d.ts.map +1 -0
- package/dist/src/gateway/reply-session-conflict.d.ts +22 -0
- package/dist/src/gateway/reply-session-conflict.d.ts.map +1 -0
- package/dist/src/inbound-handler.d.ts.map +1 -1
- package/dist/src/onboarding.d.ts.map +1 -1
- package/dist/src/targeting/agent-routing.d.ts +17 -1
- package/dist/src/targeting/agent-routing.d.ts.map +1 -1
- package/dist/src/types.d.ts +35 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/access-control.ts +1 -1
- package/src/card/ask-user-question-context.ts +9 -1
- package/src/card/ask-user-question-store.ts +294 -0
- package/src/card/ask-user-question.ts +398 -37
- package/src/card/card-action-handler.ts +2 -0
- package/src/card-service.ts +55 -3
- package/src/gateway/channel-gateway.ts +51 -30
- package/src/gateway/inbound-session-queue-dispatcher.ts +304 -0
- package/src/gateway/inbound-session-queue.ts +244 -0
- package/src/gateway/reply-session-conflict.ts +82 -0
- package/src/inbound-handler.ts +254 -57
- package/src/onboarding.ts +6 -2
- package/src/targeting/agent-routing.ts +84 -19
- package/src/types.ts +36 -0
package/src/card-service.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
+
}
|