@soimy/dingtalk 3.6.7 → 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.
@@ -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
+ }