@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.
- package/dist/index.js +702 -183
- package/dist/index.js.map +4 -4
- package/dist/src/card/ask-user-question.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 +9 -0
- package/dist/src/targeting/agent-routing.d.ts.map +1 -1
- package/dist/src/types.d.ts +22 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/card/ask-user-question.ts +18 -3
- package/src/card-service.ts +55 -3
- package/src/gateway/channel-gateway.ts +32 -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 +144 -20
- package/src/onboarding.ts +6 -2
- package/src/targeting/agent-routing.ts +17 -0
- package/src/types.ts +22 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Per-conversation promise-chain serializer for inbound DingTalk messages.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: the openclaw core rejects a dispatch whose target session
|
|
4
|
+
// already has an active (processing / paused) run with
|
|
5
|
+
// "reply session initialization conflicted for <sessionKey>".
|
|
6
|
+
// Without serialization, a message that arrives while another is still being
|
|
7
|
+
// processed races into that conflict and is dropped silently at the gateway
|
|
8
|
+
// catch block (the "钉钉'确认'消息无响应" regression — the bot shows nothing
|
|
9
|
+
// for tens of minutes).
|
|
10
|
+
//
|
|
11
|
+
// How it works: every inbound message for a conversation is chained onto the
|
|
12
|
+
// previous task for that conversation (`sessionQueues`), so a message that
|
|
13
|
+
// arrives while another is still running is QUEUED and auto-runs once the
|
|
14
|
+
// active run finishes — zero re-send required. While queued, a pre-created AI
|
|
15
|
+
// Card shows an immediate "已排队" acknowledgement (see
|
|
16
|
+
// `tryPrepareQueueBusyAckCard` in inbound-handler.ts) which the real reply
|
|
17
|
+
// later updates in place.
|
|
18
|
+
//
|
|
19
|
+
// The chain tail stored in the map is rejection-safe: a failed task never
|
|
20
|
+
// blocks the next queued message. `chainInboundSessionTask` returns the
|
|
21
|
+
// caller-visible promise (which CAN reject) so the gateway's per-message dedup
|
|
22
|
+
// (markMessageProcessed runs only after the awaited handler settles) stays
|
|
23
|
+
// correct — we never mark a still-queued message as processed.
|
|
24
|
+
//
|
|
25
|
+
// Ported from the session-queue orchestrator in
|
|
26
|
+
// DingTalk-Real-AI/dingtalk-openclaw-connector, adapted to soimy's blocking
|
|
27
|
+
// gateway contract.
|
|
28
|
+
|
|
29
|
+
const SESSION_QUEUE_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
const SESSION_QUEUE_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|
31
|
+
// Bound one conversation independently so one hung core run cannot retain an
|
|
32
|
+
// unbounded number of user messages in memory. The active task counts toward
|
|
33
|
+
// this limit, so at most seven messages may wait behind a running task.
|
|
34
|
+
export const MAX_INBOUND_SESSION_QUEUE_DEPTH = 8;
|
|
35
|
+
export const MAX_INBOUND_SESSION_QUEUE_WAIT_MS = 15 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
const sessionQueues = new Map<string, Promise<void>>();
|
|
38
|
+
const sessionLastActivity = new Map<string, number>();
|
|
39
|
+
const sessionQueueDepths = new Map<string, number>();
|
|
40
|
+
let cleanupTimer: NodeJS.Timeout | null = null;
|
|
41
|
+
|
|
42
|
+
export class InboundSessionQueueWaitTimeoutError extends Error {
|
|
43
|
+
constructor(queueKey: string) {
|
|
44
|
+
super(`Inbound session queue wait timed out for ${queueKey}`);
|
|
45
|
+
this.name = "InboundSessionQueueWaitTimeoutError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureCleanupTimer(): void {
|
|
50
|
+
if (cleanupTimer) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
cleanupTimer = setInterval(() => {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
for (const [key, lastSeen] of sessionLastActivity) {
|
|
56
|
+
if (now - lastSeen > SESSION_QUEUE_TTL_MS && !sessionQueues.has(key)) {
|
|
57
|
+
sessionLastActivity.delete(key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, SESSION_QUEUE_CLEANUP_INTERVAL_MS);
|
|
61
|
+
if (typeof cleanupTimer?.unref === "function") {
|
|
62
|
+
// Do not keep the Node event loop alive solely for queue bookkeeping.
|
|
63
|
+
cleanupTimer.unref();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the serialization key for an inbound message. We group by
|
|
69
|
+
* (accountId, conversationId): a 1:1 DM maps to one conversationId per user,
|
|
70
|
+
* and a group maps to its conversationId, which is exactly the granularity at
|
|
71
|
+
* which reply-session conflicts occur. Returns null when the key inputs are
|
|
72
|
+
* missing (caller falls back to a direct, un-queued dispatch).
|
|
73
|
+
*/
|
|
74
|
+
export function deriveInboundQueueKey(parts: {
|
|
75
|
+
accountId: string;
|
|
76
|
+
conversationId?: string;
|
|
77
|
+
}): string | null {
|
|
78
|
+
const accountId = (parts.accountId || "").trim();
|
|
79
|
+
const conversationId = (parts.conversationId || "").trim();
|
|
80
|
+
if (!accountId || !conversationId) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return `${accountId}:${conversationId}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isInboundSessionQueueBusy(queueKey: string): boolean {
|
|
87
|
+
return sessionQueues.has(queueKey);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Number of active + queued tasks for one conversation. */
|
|
91
|
+
export function getInboundSessionQueueDepth(queueKey: string): number {
|
|
92
|
+
return sessionQueueDepths.get(queueKey) ?? 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface InboundSessionTaskOptions {
|
|
96
|
+
/** Maximum time this task may wait before it starts. Does not abort a running task. */
|
|
97
|
+
maxQueueWaitMs?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Chain `task` onto the previous task for `queueKey`. Returns a promise that
|
|
102
|
+
* settles with `task`'s own outcome (so the caller observes the real result /
|
|
103
|
+
* error), while the stored chain tail is rejection-safe so one failed message
|
|
104
|
+
* never blocks the next queued one.
|
|
105
|
+
*/
|
|
106
|
+
export function chainInboundSessionTask<T>(
|
|
107
|
+
queueKey: string,
|
|
108
|
+
task: () => Promise<T>,
|
|
109
|
+
options: InboundSessionTaskOptions = {},
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
const hadPriorTask = sessionQueues.has(queueKey);
|
|
112
|
+
const previousTail = sessionQueues.get(queueKey) ?? Promise.resolve();
|
|
113
|
+
sessionLastActivity.set(queueKey, Date.now());
|
|
114
|
+
sessionQueueDepths.set(queueKey, getInboundSessionQueueDepth(queueKey) + 1);
|
|
115
|
+
ensureCleanupTimer();
|
|
116
|
+
|
|
117
|
+
let timedOut = false;
|
|
118
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
119
|
+
let resolveWaitingCaller: ((value: T) => void) | undefined;
|
|
120
|
+
let rejectWaitingCaller: ((error: Error) => void) | undefined;
|
|
121
|
+
const maxQueueWaitMs = Math.max(0, options.maxQueueWaitMs ?? 0);
|
|
122
|
+
const caller =
|
|
123
|
+
maxQueueWaitMs > 0 && hadPriorTask
|
|
124
|
+
? new Promise<T>((resolve, reject) => {
|
|
125
|
+
resolveWaitingCaller = resolve;
|
|
126
|
+
rejectWaitingCaller = reject;
|
|
127
|
+
timeout = setTimeout(() => {
|
|
128
|
+
timedOut = true;
|
|
129
|
+
reject(new InboundSessionQueueWaitTimeoutError(queueKey));
|
|
130
|
+
}, maxQueueWaitMs);
|
|
131
|
+
if (typeof timeout.unref === "function") {
|
|
132
|
+
timeout.unref();
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
: undefined;
|
|
136
|
+
|
|
137
|
+
// A queue timeout can fire while the gateway is still between awaits. Mark
|
|
138
|
+
// this caller-visible rejection as observed immediately so Node/Vitest does
|
|
139
|
+
// not report a transient unhandled rejection; returning `caller` below
|
|
140
|
+
// preserves the same rejection for the gateway to handle normally.
|
|
141
|
+
if (caller) {
|
|
142
|
+
void caller.catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const current = previousTail.then(() => {
|
|
146
|
+
if (timeout) {
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
timeout = undefined;
|
|
149
|
+
}
|
|
150
|
+
if (timedOut) {
|
|
151
|
+
throw new InboundSessionQueueWaitTimeoutError(queueKey);
|
|
152
|
+
}
|
|
153
|
+
return task();
|
|
154
|
+
});
|
|
155
|
+
const tail: Promise<void> = current.then(
|
|
156
|
+
() => undefined,
|
|
157
|
+
() => {
|
|
158
|
+
// Swallow so the next link in the chain still runs even if this task
|
|
159
|
+
// rejected.
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
sessionQueues.set(queueKey, tail);
|
|
163
|
+
|
|
164
|
+
// Early cleanup, attached to `current` (not `tail`) so it runs in the SAME
|
|
165
|
+
// microtask batch as `current`'s settlement, BEFORE the caller's `await` of
|
|
166
|
+
// `current` resumes. This avoids a brief window where a queue that just
|
|
167
|
+
// settled (e.g. the prior task threw) is still reported busy to the very next
|
|
168
|
+
// inbound message — which would wrongly trigger a queue-busy ACK card.
|
|
169
|
+
//
|
|
170
|
+
// Uses `.then(onSettled, onSettled)` rather than `.finally(...)`: `.finally`
|
|
171
|
+
// would propagate `current`'s rejection to a new promise we never await,
|
|
172
|
+
// surfacing as an unhandled rejection. `.then(fn, fn)` returns a promise that
|
|
173
|
+
// resolves once cleanup finishes, so no rejection escapes.
|
|
174
|
+
const cleanup = (): void => {
|
|
175
|
+
const nextDepth = Math.max(0, getInboundSessionQueueDepth(queueKey) - 1);
|
|
176
|
+
if (nextDepth) {
|
|
177
|
+
sessionQueueDepths.set(queueKey, nextDepth);
|
|
178
|
+
} else {
|
|
179
|
+
sessionQueueDepths.delete(queueKey);
|
|
180
|
+
}
|
|
181
|
+
if (sessionQueues.get(queueKey) === tail) {
|
|
182
|
+
sessionQueues.delete(queueKey);
|
|
183
|
+
sessionLastActivity.delete(queueKey);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
void current.then(cleanup, cleanup);
|
|
187
|
+
if (!caller) {
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
void current.then(
|
|
191
|
+
(value) => {
|
|
192
|
+
if (timeout) {
|
|
193
|
+
clearTimeout(timeout);
|
|
194
|
+
}
|
|
195
|
+
// The timeout may already have rejected this promise; subsequent resolve
|
|
196
|
+
// is intentionally ignored by Promise semantics.
|
|
197
|
+
resolveWaitingCaller?.(value);
|
|
198
|
+
},
|
|
199
|
+
(error: Error) => {
|
|
200
|
+
if (timeout) {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
}
|
|
203
|
+
rejectWaitingCaller?.(error);
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
return caller;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Phrases shown on the pre-created AI Card when a message is queued behind an
|
|
211
|
+
* active run. Ported from DingTalk-Real-AI/dingtalk-openclaw-connector
|
|
212
|
+
* `QUEUE_BUSY_ACK_PHRASES`.
|
|
213
|
+
*/
|
|
214
|
+
export const QUEUE_BUSY_ACK_PHRASES = [
|
|
215
|
+
"上一条还没结束,这条我已经记下,稍后按顺序继续处理。",
|
|
216
|
+
"当前还在忙,你的新消息已经排队,上一条完成后我马上继续。",
|
|
217
|
+
"我这边还在处理上一条,这条已加入队列,完成后继续处理。",
|
|
218
|
+
] as const;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Pick a queue-busy acknowledgement phrase. Pass `seed` for deterministic
|
|
222
|
+
* selection in tests.
|
|
223
|
+
*/
|
|
224
|
+
export function pickQueueBusyAckPhrase(seed?: number): string {
|
|
225
|
+
const list = QUEUE_BUSY_ACK_PHRASES;
|
|
226
|
+
const index = seed === undefined ? Math.floor(Math.random() * list.length) : seed % list.length;
|
|
227
|
+
return list[index];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @internal Visible for tests. */
|
|
231
|
+
export function inboundSessionQueueBusyKeysForTest(): string[] {
|
|
232
|
+
return [...sessionQueues.keys()];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** @internal Reset all queue state. Tests must call this in afterEach. */
|
|
236
|
+
export function resetInboundSessionQueueForTest(): void {
|
|
237
|
+
sessionQueues.clear();
|
|
238
|
+
sessionLastActivity.clear();
|
|
239
|
+
sessionQueueDepths.clear();
|
|
240
|
+
if (cleanupTimer) {
|
|
241
|
+
clearInterval(cleanupTimer);
|
|
242
|
+
cleanupTimer = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// When a new inbound message arrives while the openclaw core still has an
|
|
2
|
+
// active (processing / paused) run for the same session key, the core's reply
|
|
3
|
+
// resolver rejects the dispatch with:
|
|
4
|
+
// "reply session initialization conflicted for <sessionKey>"
|
|
5
|
+
//
|
|
6
|
+
// Without handling, this error propagates to the gateway catch block and the
|
|
7
|
+
// message is silently dropped — the user sees no reply at all (the
|
|
8
|
+
// "钉钉'确认'消息无响应" regression).
|
|
9
|
+
//
|
|
10
|
+
// This module lets the DingTalk channel treat that conflict as a transient,
|
|
11
|
+
// queue-like condition: retry the dispatch a few times with backoff so a run
|
|
12
|
+
// that drains within seconds is picked up, instead of failing fast.
|
|
13
|
+
|
|
14
|
+
const REPLY_SESSION_CONFLICT_PATTERN = /reply session initialization conflicted/i;
|
|
15
|
+
|
|
16
|
+
function readErrorMessage(error: unknown): string {
|
|
17
|
+
if (error instanceof Error) {
|
|
18
|
+
return error.message;
|
|
19
|
+
}
|
|
20
|
+
if (typeof error === "string") {
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
23
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
24
|
+
const msg = (error as Record<string, unknown>).message;
|
|
25
|
+
return typeof msg === "string" ? msg : "";
|
|
26
|
+
}
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isReplySessionConflictError(error: unknown): boolean {
|
|
31
|
+
return REPLY_SESSION_CONFLICT_PATTERN.test(readErrorMessage(error));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ReplySessionConflictRetryOptions {
|
|
35
|
+
/** How many times to retry after the first conflict (default 3). */
|
|
36
|
+
maxRetries?: number;
|
|
37
|
+
/** Base backoff in ms; actual delay grows linearly per attempt (default 1500). */
|
|
38
|
+
baseDelayMs?: number;
|
|
39
|
+
/** Optional structured logger used for diagnostic warnings. */
|
|
40
|
+
log?: {
|
|
41
|
+
warn?: (message: string) => void;
|
|
42
|
+
info?: (message: string) => void;
|
|
43
|
+
};
|
|
44
|
+
/** Session key included in log lines for correlation. */
|
|
45
|
+
sessionKey?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sleep = (ms: number): Promise<void> =>
|
|
49
|
+
new Promise((resolve) => {
|
|
50
|
+
setTimeout(resolve, ms);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run `fn`, retrying only when it fails with a reply-session initialization
|
|
55
|
+
* conflict. Any other error is re-thrown immediately. After `maxRetries`
|
|
56
|
+
* conflicts the last conflict error is re-thrown so the caller can apply its
|
|
57
|
+
* own fallback (e.g. an immediate "processing" acknowledgement).
|
|
58
|
+
*/
|
|
59
|
+
export async function withReplySessionConflictRetry<T>(
|
|
60
|
+
fn: () => Promise<T>,
|
|
61
|
+
options: ReplySessionConflictRetryOptions = {},
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
const maxRetries = options.maxRetries ?? 3;
|
|
64
|
+
const baseDelayMs = options.baseDelayMs ?? 1500;
|
|
65
|
+
const sessionLabel = options.sessionKey ?? "?";
|
|
66
|
+
|
|
67
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
68
|
+
try {
|
|
69
|
+
return await fn();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!isReplySessionConflictError(error) || attempt >= maxRetries) {
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
const delay = baseDelayMs * (attempt + 1);
|
|
75
|
+
options.log?.warn?.(
|
|
76
|
+
`[DingTalk] Reply session initialization conflicted for session=${sessionLabel}; ` +
|
|
77
|
+
`active run still draining. Retry ${attempt + 1}/${maxRetries} after ${delay}ms.`,
|
|
78
|
+
);
|
|
79
|
+
await sleep(delay);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|