@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
package/src/inbound-handler.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "./config";
|
|
35
35
|
import { buildLearningContextBlock, isLearningEnabled } from "./feedback-learning-service";
|
|
36
36
|
import axios from "./http-client";
|
|
37
|
+
import { dispatchInboundViaSessionQueue } from "./gateway/inbound-session-queue-dispatcher";
|
|
37
38
|
import { setCurrentLogger } from "./logger-context";
|
|
38
39
|
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
39
40
|
import {
|
|
@@ -60,6 +61,10 @@ import {
|
|
|
60
61
|
clearProactiveRiskObservationsForTest,
|
|
61
62
|
getProactiveRiskObservationForAny,
|
|
62
63
|
} from "./proactive-risk-registry";
|
|
64
|
+
import {
|
|
65
|
+
isReplySessionConflictError,
|
|
66
|
+
withReplySessionConflictRetry,
|
|
67
|
+
} from "./gateway/reply-session-conflict";
|
|
63
68
|
import { createReplyStrategy } from "./reply-strategy";
|
|
64
69
|
import type { DeliverPayload } from "./reply-strategy-types";
|
|
65
70
|
import { getDingTalkRuntime } from "./runtime";
|
|
@@ -571,8 +576,9 @@ export async function downloadMedia(
|
|
|
571
576
|
|
|
572
577
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
573
578
|
// Keep context creation inside this public inbound entry. Ask-user synthetic
|
|
574
|
-
// reinjections call handleDingTalkMessage directly
|
|
575
|
-
//
|
|
579
|
+
// reinjections call handleDingTalkMessage directly. The normal inbound queue
|
|
580
|
+
// is therefore entered later, after this handler has completed authorization
|
|
581
|
+
// and trusted route/session resolution; ask-user stays outside that queue.
|
|
576
582
|
return withDingTalkQuestionContext(
|
|
577
583
|
{
|
|
578
584
|
cfg: params.cfg,
|
|
@@ -880,6 +886,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
880
886
|
handleMessage: handleDingTalkMessage,
|
|
881
887
|
downloadMedia,
|
|
882
888
|
log,
|
|
889
|
+
inboundQueueEligible: params.inboundQueueEligible,
|
|
883
890
|
});
|
|
884
891
|
return;
|
|
885
892
|
}
|
|
@@ -914,6 +921,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
914
921
|
handleMessage: handleDingTalkMessage,
|
|
915
922
|
downloadMedia,
|
|
916
923
|
log,
|
|
924
|
+
inboundQueueEligible: params.inboundQueueEligible,
|
|
917
925
|
});
|
|
918
926
|
return;
|
|
919
927
|
}
|
|
@@ -999,7 +1007,45 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
999
1007
|
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
1000
1008
|
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
1001
1009
|
const content = extractedContent;
|
|
1002
|
-
|
|
1010
|
+
// Decide control-message bypasses once from the user-authored text. Reusing
|
|
1011
|
+
// the result below keeps /stop out of the FIFO queue without calling the SDK
|
|
1012
|
+
// classifier a second time after attachment/OCR enrichment.
|
|
1013
|
+
const controlText = stripLeadingMentions(content.text).trim();
|
|
1014
|
+
const isBtwBypass = isBtwRequestText(controlText);
|
|
1015
|
+
const isAbortBypass = isAbortRequestText(controlText);
|
|
1016
|
+
|
|
1017
|
+
// Queue only after all access checks and the trusted route.sessionKey above.
|
|
1018
|
+
// Gateway-level queueing uses only raw conversationId, which can both
|
|
1019
|
+
// acknowledge an unauthorized sender and block /stop or /btw behind a long
|
|
1020
|
+
// run. A queued continuation re-enters with the guard set and reuses the
|
|
1021
|
+
// visible queue ACK card for the actual answer.
|
|
1022
|
+
if (
|
|
1023
|
+
params.inboundQueueEligible &&
|
|
1024
|
+
!params.inboundQueueHandled &&
|
|
1025
|
+
inboundOrigin !== "ask-user" &&
|
|
1026
|
+
!isBtwBypass &&
|
|
1027
|
+
!isAbortBypass
|
|
1028
|
+
) {
|
|
1029
|
+
await dispatchInboundViaSessionQueue(
|
|
1030
|
+
{
|
|
1031
|
+
accountId,
|
|
1032
|
+
data,
|
|
1033
|
+
dingtalkConfig,
|
|
1034
|
+
sessionKey: route.sessionKey,
|
|
1035
|
+
to,
|
|
1036
|
+
storePath: accountStorePath,
|
|
1037
|
+
quoteContent: rawInboundText.slice(0, 200),
|
|
1038
|
+
log,
|
|
1039
|
+
},
|
|
1040
|
+
(preCreatedCard) =>
|
|
1041
|
+
handleDingTalkMessage({
|
|
1042
|
+
...params,
|
|
1043
|
+
preCreatedCard,
|
|
1044
|
+
inboundQueueHandled: true,
|
|
1045
|
+
}),
|
|
1046
|
+
);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1003
1049
|
const taskInfoConversationId = groupId || to;
|
|
1004
1050
|
const agentDisplayName = getAgentDisplayName({
|
|
1005
1051
|
subAgentOptions,
|
|
@@ -1092,7 +1138,7 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
1092
1138
|
};
|
|
1093
1139
|
}
|
|
1094
1140
|
|
|
1095
|
-
if (useCardMode && !isBtwBypass) {
|
|
1141
|
+
if (useCardMode && !isBtwBypass && !params.preCreatedCard) {
|
|
1096
1142
|
const key = `${accountId}:${to}`;
|
|
1097
1143
|
if (cardCreationInFlight.has(key)) {
|
|
1098
1144
|
useCardMode = false;
|
|
@@ -1115,13 +1161,18 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
1115
1161
|
// Use rawInboundText ( preserved before sub-agent rewriting) to avoid
|
|
1116
1162
|
// showing internal routing context like "[你被 @ 为...]" in the card UI.
|
|
1117
1163
|
const inboundQuoteText = rawInboundText.slice(0, 200);
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1164
|
+
// Reuse the pre-created card (shown while this message was queued behind
|
|
1165
|
+
// an active run) instead of creating a new one: the real reply streams
|
|
1166
|
+
// INTO the same card (in-place update).
|
|
1167
|
+
const aiCard =
|
|
1168
|
+
params.preCreatedCard ??
|
|
1169
|
+
(await createAICard(dingtalkConfig, to, log, {
|
|
1170
|
+
accountId,
|
|
1171
|
+
storePath: accountStorePath,
|
|
1172
|
+
contextConversationId: groupId,
|
|
1173
|
+
quoteContent: inboundQuoteText,
|
|
1174
|
+
statusLine: initialStatusLine,
|
|
1175
|
+
}));
|
|
1125
1176
|
if (aiCard) {
|
|
1126
1177
|
currentAICard = aiCard;
|
|
1127
1178
|
if (aiCard.outTrackId) {
|
|
@@ -1830,12 +1881,11 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
1830
1881
|
// tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
|
|
1831
1882
|
// rather than waiting for it to finish before the stop message is processed.
|
|
1832
1883
|
//
|
|
1833
|
-
//
|
|
1834
|
-
// "@Agent /stop" are correctly recognised
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
1837
|
-
|
|
1838
|
-
if (isAbortRequestText(textForAbortCheck)) {
|
|
1884
|
+
// The early control-text decision strips leading @mentions, so messages like
|
|
1885
|
+
// "@Agent /stop" are correctly recognised in both DM and group chats. It is
|
|
1886
|
+
// intentionally reused here instead of reclassifying attachment/OCR-enriched
|
|
1887
|
+
// input, which would make queue admission and the actual abort path disagree.
|
|
1888
|
+
if (isAbortBypass) {
|
|
1839
1889
|
log?.info?.(
|
|
1840
1890
|
`[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
1841
1891
|
);
|
|
@@ -2298,9 +2348,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
2298
2348
|
taskMeta,
|
|
2299
2349
|
});
|
|
2300
2350
|
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2351
|
+
let deliveredFinalCount = 0;
|
|
2352
|
+
// Extracted as a thunk so reply-session init conflicts (raised by the
|
|
2353
|
+
// core when an active run still occupies this session) can be retried
|
|
2354
|
+
// with backoff instead of dropping the inbound message.
|
|
2355
|
+
const runDispatch = () =>
|
|
2356
|
+
rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2304
2357
|
ctx,
|
|
2305
2358
|
cfg,
|
|
2306
2359
|
dispatcherOptions: {
|
|
@@ -2347,6 +2400,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
2347
2400
|
replyOptions: strategy.getReplyOptions(),
|
|
2348
2401
|
});
|
|
2349
2402
|
|
|
2403
|
+
try {
|
|
2404
|
+
const dispatchResult = await withReplySessionConflictRetry(runDispatch, {
|
|
2405
|
+
log,
|
|
2406
|
+
sessionKey: route.sessionKey,
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2350
2409
|
const bufferedFinal =
|
|
2351
2410
|
dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
|
|
2352
2411
|
? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
|
|
@@ -2394,6 +2453,71 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
2394
2453
|
} catch (dispatchErr: unknown) {
|
|
2395
2454
|
const error =
|
|
2396
2455
|
dispatchErr instanceof Error ? dispatchErr : new Error(getErrorMessage(dispatchErr));
|
|
2456
|
+
if (isReplySessionConflictError(error)) {
|
|
2457
|
+
// Fallback (兜底): the active run for this session did not drain within
|
|
2458
|
+
// the retry budget, so dispatching the inbound message still conflicts.
|
|
2459
|
+
// Rather than silently dropping it (outcome=error, no reply — the
|
|
2460
|
+
// "钉钉确认消息无响应" regression), send an immediate acknowledgement so
|
|
2461
|
+
// the user knows the message was received while the prior turn is still
|
|
2462
|
+
// busy; they can re-send once it finishes.
|
|
2463
|
+
log?.warn?.(
|
|
2464
|
+
`[DingTalk] Reply session still conflicted after retries for session=${route.sessionKey}; ` +
|
|
2465
|
+
`sending "processing" acknowledgement instead of dropping the message.`,
|
|
2466
|
+
);
|
|
2467
|
+
const ackText = "收到,上一轮还在处理中,请稍候再试。";
|
|
2468
|
+
// A card may already be visible (including the card created while a
|
|
2469
|
+
// gateway-queued message waited). Put the busy acknowledgement into
|
|
2470
|
+
// that same delivery strategy and finalize it normally. Calling
|
|
2471
|
+
// `strategy.abort()` after a separate acknowledgement would overwrite
|
|
2472
|
+
// the visible card with "❌ 处理失败", which contradicts the actual
|
|
2473
|
+
// recoverable-busy state.
|
|
2474
|
+
if (replyMode === "card") {
|
|
2475
|
+
try {
|
|
2476
|
+
await strategy.deliver({
|
|
2477
|
+
text: ackText,
|
|
2478
|
+
mediaUrls: [],
|
|
2479
|
+
kind: "final",
|
|
2480
|
+
isReasoning: false,
|
|
2481
|
+
});
|
|
2482
|
+
await strategy.finalize();
|
|
2483
|
+
return;
|
|
2484
|
+
} catch (cardAckErr: unknown) {
|
|
2485
|
+
log?.warn?.(
|
|
2486
|
+
`[DingTalk] Processing acknowledgement card finalize failed: ${getErrorMessage(cardAckErr)}`,
|
|
2487
|
+
);
|
|
2488
|
+
// The card was already the reply surface for this inbound
|
|
2489
|
+
// message. If its busy acknowledgement cannot be committed,
|
|
2490
|
+
// do not emit a second text acknowledgement and then abort the
|
|
2491
|
+
// card: abort renders "❌ 处理失败", which contradicts the
|
|
2492
|
+
// recoverable busy state and leaves two visible outcomes.
|
|
2493
|
+
// Keep the existing card untouched and let the next inbound
|
|
2494
|
+
// message / normal card lifecycle recover it instead.
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
try {
|
|
2499
|
+
if (sessionWebhook) {
|
|
2500
|
+
await sendBySession(dingtalkConfig, sessionWebhook, ackText, {
|
|
2501
|
+
log,
|
|
2502
|
+
accountId,
|
|
2503
|
+
storePath: accountStorePath,
|
|
2504
|
+
});
|
|
2505
|
+
} else {
|
|
2506
|
+
await sendMessage(dingtalkConfig, to, ackText, {
|
|
2507
|
+
log,
|
|
2508
|
+
accountId,
|
|
2509
|
+
storePath: accountStorePath,
|
|
2510
|
+
conversationId: groupId,
|
|
2511
|
+
});
|
|
2512
|
+
}
|
|
2513
|
+
} catch (ackErr: unknown) {
|
|
2514
|
+
log?.warn?.(
|
|
2515
|
+
`[DingTalk] Processing acknowledgement delivery failed: ${getErrorMessage(ackErr)}`,
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
await strategy.abort(error);
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2397
2521
|
await strategy.abort(error);
|
|
2398
2522
|
throw dispatchErr;
|
|
2399
2523
|
}
|
package/src/onboarding.ts
CHANGED
|
@@ -20,6 +20,10 @@ import type { DingTalkConfig, DingTalkChannelConfig } from "./types.js";
|
|
|
20
20
|
|
|
21
21
|
const channel = "dingtalk" as const;
|
|
22
22
|
|
|
23
|
+
type DingTalkSetupInput = ChannelSetupInput & {
|
|
24
|
+
password?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
23
27
|
function isConfigured(account: DingTalkConfig): boolean {
|
|
24
28
|
return Boolean(account.clientId && hasConfiguredSecretInput(account.clientSecret));
|
|
25
29
|
}
|
|
@@ -349,7 +353,7 @@ function applyAccountConfig(params: {
|
|
|
349
353
|
function applyGenericSetupInput(params: {
|
|
350
354
|
cfg: OpenClawConfig;
|
|
351
355
|
accountId: string;
|
|
352
|
-
input:
|
|
356
|
+
input: DingTalkSetupInput;
|
|
353
357
|
}): OpenClawConfig {
|
|
354
358
|
return applyAccountConfig({
|
|
355
359
|
cfg: params.cfg,
|
|
@@ -547,7 +551,7 @@ export const dingtalkSetupAdapter: ChannelSetupAdapter = {
|
|
|
547
551
|
applyGenericSetupInput({
|
|
548
552
|
cfg,
|
|
549
553
|
accountId,
|
|
550
|
-
input,
|
|
554
|
+
input: input as DingTalkSetupInput,
|
|
551
555
|
}),
|
|
552
556
|
};
|
|
553
557
|
|
|
@@ -214,6 +214,15 @@ export async function dispatchSubAgents(params: {
|
|
|
214
214
|
log?: Logger,
|
|
215
215
|
) => Promise<{ path: string; mimeType: string } | null>;
|
|
216
216
|
log?: Logger;
|
|
217
|
+
/**
|
|
218
|
+
* Whether the recursive sub-agent handler invocations should participate in
|
|
219
|
+
* the handler-owned inbound session queue. Propagated from the parent
|
|
220
|
+
* handler's `inboundQueueEligible` so `@子Agent` messages reuse the same
|
|
221
|
+
* serialization path and avoid reply-session conflicts on the sub-agent's
|
|
222
|
+
* own `target.route.sessionKey`. Default false preserves the legacy
|
|
223
|
+
* direct-dispatch behavior for synthetic callers that did not opt in.
|
|
224
|
+
*/
|
|
225
|
+
inboundQueueEligible?: boolean;
|
|
217
226
|
}): Promise<void> {
|
|
218
227
|
const {
|
|
219
228
|
matchedAgents,
|
|
@@ -229,6 +238,7 @@ export async function dispatchSubAgents(params: {
|
|
|
229
238
|
handleMessage,
|
|
230
239
|
downloadMedia: download,
|
|
231
240
|
log,
|
|
241
|
+
inboundQueueEligible,
|
|
232
242
|
} = params;
|
|
233
243
|
|
|
234
244
|
let helperMissingWarningSent = false;
|
|
@@ -337,6 +347,13 @@ export async function dispatchSubAgents(params: {
|
|
|
337
347
|
commandText,
|
|
338
348
|
},
|
|
339
349
|
preDownloadedMedia,
|
|
350
|
+
// Propagate queue eligibility so each recursive sub-agent handler
|
|
351
|
+
// enters the handler-owned queue on its own `target.route.sessionKey`
|
|
352
|
+
// instead of bypassing it (which previously left @子Agent messages
|
|
353
|
+
// exposed to reply-session conflicts when the sub-agent session was
|
|
354
|
+
// already busy). Synthetic callers without this flag keep the legacy
|
|
355
|
+
// direct-dispatch behavior.
|
|
356
|
+
inboundQueueEligible,
|
|
340
357
|
});
|
|
341
358
|
} catch (error) {
|
|
342
359
|
const message = getErrorMessage(error);
|
package/src/types.ts
CHANGED
|
@@ -484,6 +484,12 @@ export interface HandleDingTalkMessageParams {
|
|
|
484
484
|
dingtalkConfig: DingTalkConfig;
|
|
485
485
|
/** Distinguishes real Stream messages from Ask User callback reinjection. */
|
|
486
486
|
inboundOrigin?: "stream" | "ask-user";
|
|
487
|
+
/**
|
|
488
|
+
* Explicitly enables handler-owned queueing for a real gateway Stream
|
|
489
|
+
* callback. Direct/synthetic callers must not be inferred from raw message
|
|
490
|
+
* shape, because they may already be inside another handler lifecycle.
|
|
491
|
+
*/
|
|
492
|
+
inboundQueueEligible?: boolean;
|
|
487
493
|
/** Reuses an already-resolved trusted route for recursive or synthetic handling. */
|
|
488
494
|
routeOverride?: ResolvedDingTalkRoute;
|
|
489
495
|
/**
|
|
@@ -501,6 +507,22 @@ export interface HandleDingTalkMessageParams {
|
|
|
501
507
|
mediaPaths?: string[];
|
|
502
508
|
mediaTypes?: string[];
|
|
503
509
|
};
|
|
510
|
+
/**
|
|
511
|
+
* A pre-created AI Card to reuse instead of creating a new one. Set by the
|
|
512
|
+
* inbound session-queue dispatcher when a message was queued behind an active
|
|
513
|
+
* run: the card was already created and is showing a "已排队" acknowledgement,
|
|
514
|
+
* so the handler streams the real reply INTO this same card (in-place update)
|
|
515
|
+
* rather than spawning a second card. When unset, the handler creates a fresh
|
|
516
|
+
* card as usual.
|
|
517
|
+
*/
|
|
518
|
+
preCreatedCard?: AICardInstance;
|
|
519
|
+
/**
|
|
520
|
+
* Internal recursion guard for the authorized inbound session queue. The
|
|
521
|
+
* first handler pass performs access control and trusted route resolution;
|
|
522
|
+
* the queued continuation re-enters with this set so it can consume the
|
|
523
|
+
* pre-created card without queueing itself again.
|
|
524
|
+
*/
|
|
525
|
+
inboundQueueHandled?: boolean;
|
|
504
526
|
}
|
|
505
527
|
|
|
506
528
|
/**
|