@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/inbound-handler.ts
CHANGED
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
isCardInTerminalState,
|
|
14
14
|
recallAICardMessage,
|
|
15
15
|
} from "./card-service";
|
|
16
|
+
import {
|
|
17
|
+
invalidateAskUserQuestionsForScope,
|
|
18
|
+
syncInvalidatedAskUserQuestionCards,
|
|
19
|
+
} from "./card/ask-user-question";
|
|
16
20
|
import {
|
|
17
21
|
getDingTalkQuestionContext,
|
|
18
22
|
withDingTalkQuestionContext,
|
|
@@ -30,6 +34,7 @@ import {
|
|
|
30
34
|
} from "./config";
|
|
31
35
|
import { buildLearningContextBlock, isLearningEnabled } from "./feedback-learning-service";
|
|
32
36
|
import axios from "./http-client";
|
|
37
|
+
import { dispatchInboundViaSessionQueue } from "./gateway/inbound-session-queue-dispatcher";
|
|
33
38
|
import { setCurrentLogger } from "./logger-context";
|
|
34
39
|
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
35
40
|
import {
|
|
@@ -56,6 +61,10 @@ import {
|
|
|
56
61
|
clearProactiveRiskObservationsForTest,
|
|
57
62
|
getProactiveRiskObservationForAny,
|
|
58
63
|
} from "./proactive-risk-registry";
|
|
64
|
+
import {
|
|
65
|
+
isReplySessionConflictError,
|
|
66
|
+
withReplySessionConflictRetry,
|
|
67
|
+
} from "./gateway/reply-session-conflict";
|
|
59
68
|
import { createReplyStrategy } from "./reply-strategy";
|
|
60
69
|
import type { DeliverPayload } from "./reply-strategy-types";
|
|
61
70
|
import { getDingTalkRuntime } from "./runtime";
|
|
@@ -81,7 +90,13 @@ import {
|
|
|
81
90
|
upsertObservedUserTarget,
|
|
82
91
|
} from "./targeting/target-directory-store";
|
|
83
92
|
import { AICardStatus } from "./types";
|
|
84
|
-
import type {
|
|
93
|
+
import type {
|
|
94
|
+
DingTalkConfig,
|
|
95
|
+
HandleDingTalkMessageParams,
|
|
96
|
+
Logger,
|
|
97
|
+
MediaFile,
|
|
98
|
+
ResolvedDingTalkRoute,
|
|
99
|
+
} from "./types";
|
|
85
100
|
import {
|
|
86
101
|
formatDingTalkErrorPayloadLog,
|
|
87
102
|
getErrorMessage,
|
|
@@ -561,8 +576,9 @@ export async function downloadMedia(
|
|
|
561
576
|
|
|
562
577
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
563
578
|
// Keep context creation inside this public inbound entry. Ask-user synthetic
|
|
564
|
-
// reinjections call handleDingTalkMessage directly
|
|
565
|
-
//
|
|
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.
|
|
566
582
|
return withDingTalkQuestionContext(
|
|
567
583
|
{
|
|
568
584
|
cfg: params.cfg,
|
|
@@ -584,6 +600,8 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
584
600
|
sessionWebhook,
|
|
585
601
|
log,
|
|
586
602
|
dingtalkConfig,
|
|
603
|
+
inboundOrigin = "stream",
|
|
604
|
+
routeOverride,
|
|
587
605
|
subAgentOptions,
|
|
588
606
|
preDownloadedMedia,
|
|
589
607
|
} = params;
|
|
@@ -810,38 +828,42 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
810
828
|
? null
|
|
811
829
|
: resolveMessageTarget({ extractedContent, cfg, isGroup });
|
|
812
830
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
831
|
+
const invalidateQuestionRoutes = (routes: readonly ResolvedDingTalkRoute[]): void => {
|
|
832
|
+
if (inboundOrigin === "ask-user") {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const scopeKeys = new Set(
|
|
836
|
+
routes.map((resolvedRoute) => `${accountId}:${resolvedRoute.sessionKey}:${senderId}`),
|
|
837
|
+
);
|
|
838
|
+
const invalidatedRecords = [];
|
|
839
|
+
for (const questionScopeKey of scopeKeys) {
|
|
840
|
+
try {
|
|
841
|
+
invalidatedRecords.push(
|
|
842
|
+
...invalidateAskUserQuestionsForScope({
|
|
843
|
+
storePath: accountStorePath,
|
|
844
|
+
accountId,
|
|
845
|
+
questionScopeKey,
|
|
846
|
+
reason: "superseded_by_message",
|
|
847
|
+
log,
|
|
848
|
+
}),
|
|
849
|
+
);
|
|
850
|
+
} catch (err) {
|
|
851
|
+
log?.warn?.(
|
|
852
|
+
`[DingTalk][AskUser] Failed to invalidate pending cards before inbound dispatch scope=${questionScopeKey}: ${String(err)}`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (invalidatedRecords.length === 0) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
void syncInvalidatedAskUserQuestionCards({
|
|
860
|
+
records: invalidatedRecords,
|
|
861
|
+
config: dingtalkConfig,
|
|
862
|
+
log,
|
|
863
|
+
}).catch((err) => {
|
|
864
|
+
log?.warn?.(`[DingTalk][AskUser] Card invalidation sync failed: ${String(err)}`);
|
|
839
865
|
});
|
|
840
|
-
}
|
|
841
|
-
const questionContext = getDingTalkQuestionContext();
|
|
842
|
-
if (questionContext) {
|
|
843
|
-
questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
|
|
844
|
-
}
|
|
866
|
+
};
|
|
845
867
|
|
|
846
868
|
// @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
|
|
847
869
|
// Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
|
|
@@ -858,9 +880,13 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
858
880
|
dingtalkConfig,
|
|
859
881
|
sessionWebhook,
|
|
860
882
|
extractedContent,
|
|
883
|
+
sessionPeer,
|
|
884
|
+
onRoutesResolved: (targets) =>
|
|
885
|
+
invalidateQuestionRoutes(targets.map((target) => target.route)),
|
|
861
886
|
handleMessage: handleDingTalkMessage,
|
|
862
887
|
downloadMedia,
|
|
863
888
|
log,
|
|
889
|
+
inboundQueueEligible: params.inboundQueueEligible,
|
|
864
890
|
});
|
|
865
891
|
return;
|
|
866
892
|
}
|
|
@@ -889,15 +915,60 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
889
915
|
dingtalkConfig,
|
|
890
916
|
sessionWebhook,
|
|
891
917
|
extractedContent,
|
|
918
|
+
sessionPeer,
|
|
919
|
+
onRoutesResolved: (targets) =>
|
|
920
|
+
invalidateQuestionRoutes(targets.map((target) => target.route)),
|
|
892
921
|
handleMessage: handleDingTalkMessage,
|
|
893
922
|
downloadMedia,
|
|
894
923
|
log,
|
|
924
|
+
inboundQueueEligible: params.inboundQueueEligible,
|
|
895
925
|
});
|
|
896
926
|
return;
|
|
897
927
|
}
|
|
898
928
|
// Only invalid agent names and no match: fall through to the default route.
|
|
899
929
|
}
|
|
900
930
|
|
|
931
|
+
let route: ResolvedDingTalkRoute;
|
|
932
|
+
if (routeOverride) {
|
|
933
|
+
route = routeOverride;
|
|
934
|
+
} else if (subAgentOptions) {
|
|
935
|
+
route = {
|
|
936
|
+
agentId: subAgentOptions.agentId,
|
|
937
|
+
sessionKey: buildAgentSessionKey({
|
|
938
|
+
rt,
|
|
939
|
+
cfg,
|
|
940
|
+
accountId,
|
|
941
|
+
agentId: subAgentOptions.agentId,
|
|
942
|
+
peerKind: sessionPeer.kind,
|
|
943
|
+
peerId: sessionPeer.peerId,
|
|
944
|
+
}),
|
|
945
|
+
mainSessionKey: "",
|
|
946
|
+
};
|
|
947
|
+
} else {
|
|
948
|
+
route = rt.channel.routing.resolveAgentRoute({
|
|
949
|
+
cfg,
|
|
950
|
+
channel: "dingtalk",
|
|
951
|
+
accountId,
|
|
952
|
+
peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
const questionContext = getDingTalkQuestionContext();
|
|
956
|
+
if (questionContext) {
|
|
957
|
+
questionContext.resolvedRoute = route;
|
|
958
|
+
questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
|
|
959
|
+
questionContext.storePath = accountStorePath;
|
|
960
|
+
questionContext.continuationSubAgentOptions = subAgentOptions
|
|
961
|
+
? {
|
|
962
|
+
agentId: subAgentOptions.agentId,
|
|
963
|
+
responsePrefix: subAgentOptions.responsePrefix,
|
|
964
|
+
matchedName: subAgentOptions.matchedName,
|
|
965
|
+
}
|
|
966
|
+
: undefined;
|
|
967
|
+
}
|
|
968
|
+
if (!subAgentOptions) {
|
|
969
|
+
invalidateQuestionRoutes([route]);
|
|
970
|
+
}
|
|
971
|
+
|
|
901
972
|
// Route resolved before media download for session context and routing metadata.
|
|
902
973
|
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
|
|
903
974
|
agentId: route.agentId,
|
|
@@ -936,7 +1007,45 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
936
1007
|
const quotedRef = buildInboundQuotedRef(data, extractedContent);
|
|
937
1008
|
const replyQuotedRef = createReplyQuotedRef(data.msgId);
|
|
938
1009
|
const content = extractedContent;
|
|
939
|
-
|
|
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
|
+
}
|
|
940
1049
|
const taskInfoConversationId = groupId || to;
|
|
941
1050
|
const agentDisplayName = getAgentDisplayName({
|
|
942
1051
|
subAgentOptions,
|
|
@@ -978,7 +1087,6 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
978
1087
|
let cardFlightKey: string | undefined;
|
|
979
1088
|
if (questionContext) {
|
|
980
1089
|
questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
|
|
981
|
-
questionCardTookOver = true;
|
|
982
1090
|
if (cardFlightKey) {
|
|
983
1091
|
cardCreationInFlight.delete(cardFlightKey);
|
|
984
1092
|
cardFlightKey = undefined;
|
|
@@ -999,27 +1107,38 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
999
1107
|
log?.warn?.(
|
|
1000
1108
|
`[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1001
1109
|
);
|
|
1110
|
+
return false;
|
|
1002
1111
|
}
|
|
1112
|
+
questionCardTookOver = true;
|
|
1003
1113
|
if (!currentAICard) {
|
|
1004
|
-
return;
|
|
1114
|
+
return true;
|
|
1005
1115
|
}
|
|
1006
1116
|
if (isCardInTerminalState(currentAICard.state)) {
|
|
1007
|
-
return;
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
let recalled = false;
|
|
1120
|
+
try {
|
|
1121
|
+
recalled = await recallAICardMessage(currentAICard, log);
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
log?.warn?.(
|
|
1124
|
+
`[DingTalk][AskUser] Question card took over, but AI card recall errored question=${questionId} outTrackId=${outTrackId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1125
|
+
);
|
|
1126
|
+
return true;
|
|
1008
1127
|
}
|
|
1009
|
-
const recalled = await recallAICardMessage(currentAICard, log);
|
|
1010
1128
|
if (!recalled) {
|
|
1011
1129
|
log?.warn?.(
|
|
1012
1130
|
`[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
|
|
1013
1131
|
);
|
|
1014
|
-
return;
|
|
1132
|
+
return true;
|
|
1015
1133
|
}
|
|
1016
1134
|
log?.info?.(
|
|
1017
1135
|
`[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
|
|
1018
1136
|
);
|
|
1137
|
+
return true;
|
|
1019
1138
|
};
|
|
1020
1139
|
}
|
|
1021
1140
|
|
|
1022
|
-
if (useCardMode && !isBtwBypass) {
|
|
1141
|
+
if (useCardMode && !isBtwBypass && !params.preCreatedCard) {
|
|
1023
1142
|
const key = `${accountId}:${to}`;
|
|
1024
1143
|
if (cardCreationInFlight.has(key)) {
|
|
1025
1144
|
useCardMode = false;
|
|
@@ -1042,13 +1161,18 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
1042
1161
|
// Use rawInboundText ( preserved before sub-agent rewriting) to avoid
|
|
1043
1162
|
// showing internal routing context like "[你被 @ 为...]" in the card UI.
|
|
1044
1163
|
const inboundQuoteText = rawInboundText.slice(0, 200);
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
+
}));
|
|
1052
1176
|
if (aiCard) {
|
|
1053
1177
|
currentAICard = aiCard;
|
|
1054
1178
|
if (aiCard.outTrackId) {
|
|
@@ -1757,12 +1881,11 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
1757
1881
|
// tryFastAbortFromMessage (inside the SDK) kill any in-flight generation immediately,
|
|
1758
1882
|
// rather than waiting for it to finish before the stop message is processed.
|
|
1759
1883
|
//
|
|
1760
|
-
//
|
|
1761
|
-
// "@Agent /stop" are correctly recognised
|
|
1762
|
-
//
|
|
1763
|
-
//
|
|
1764
|
-
|
|
1765
|
-
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) {
|
|
1766
1889
|
log?.info?.(
|
|
1767
1890
|
`[DingTalk] Abort request detected, bypassing session lock for session=${route.sessionKey}`,
|
|
1768
1891
|
);
|
|
@@ -2225,9 +2348,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
2225
2348
|
taskMeta,
|
|
2226
2349
|
});
|
|
2227
2350
|
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
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({
|
|
2231
2357
|
ctx,
|
|
2232
2358
|
cfg,
|
|
2233
2359
|
dispatcherOptions: {
|
|
@@ -2274,6 +2400,12 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
2274
2400
|
replyOptions: strategy.getReplyOptions(),
|
|
2275
2401
|
});
|
|
2276
2402
|
|
|
2403
|
+
try {
|
|
2404
|
+
const dispatchResult = await withReplySessionConflictRetry(runDispatch, {
|
|
2405
|
+
log,
|
|
2406
|
+
sessionKey: route.sessionKey,
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2277
2409
|
const bufferedFinal =
|
|
2278
2410
|
dispatchResult && typeof dispatchResult === "object" && "queuedFinal" in dispatchResult
|
|
2279
2411
|
? (dispatchResult as { queuedFinal?: unknown }).queuedFinal
|
|
@@ -2321,6 +2453,71 @@ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams):
|
|
|
2321
2453
|
} catch (dispatchErr: unknown) {
|
|
2322
2454
|
const error =
|
|
2323
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
|
+
}
|
|
2324
2521
|
await strategy.abort(error);
|
|
2325
2522
|
throw dispatchErr;
|
|
2326
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
|
|
|
@@ -20,9 +20,11 @@ import type {
|
|
|
20
20
|
HandleDingTalkMessageParams,
|
|
21
21
|
Logger,
|
|
22
22
|
MessageContent,
|
|
23
|
+
ResolvedDingTalkRoute,
|
|
23
24
|
} from "../types";
|
|
24
25
|
import { getErrorMessage } from "../utils";
|
|
25
26
|
import { resolveAtAgents } from "./agent-name-matcher";
|
|
27
|
+
import type { ResolvedDingTalkSessionPeer } from "../session-routing";
|
|
26
28
|
|
|
27
29
|
export class HostRoutingHelperUnavailableError extends Error {
|
|
28
30
|
constructor(
|
|
@@ -84,6 +86,11 @@ export type MessageTarget =
|
|
|
84
86
|
}
|
|
85
87
|
| { kind: "subagent-command"; agent: AgentNameMatch; commandText: string };
|
|
86
88
|
|
|
89
|
+
export interface ResolvedSubAgentTarget {
|
|
90
|
+
agent: AgentNameMatch;
|
|
91
|
+
route: ResolvedDingTalkRoute;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
/**
|
|
88
95
|
* Resolve how an inbound message should be routed.
|
|
89
96
|
*
|
|
@@ -198,6 +205,8 @@ export async function dispatchSubAgents(params: {
|
|
|
198
205
|
dingtalkConfig: DingTalkConfig;
|
|
199
206
|
sessionWebhook: string;
|
|
200
207
|
extractedContent: MessageContent;
|
|
208
|
+
sessionPeer: ResolvedDingTalkSessionPeer;
|
|
209
|
+
onRoutesResolved?: (targets: readonly ResolvedSubAgentTarget[]) => void;
|
|
201
210
|
handleMessage: (params: HandleDingTalkMessageParams) => Promise<void>;
|
|
202
211
|
downloadMedia: (
|
|
203
212
|
config: DingTalkConfig,
|
|
@@ -205,6 +214,15 @@ export async function dispatchSubAgents(params: {
|
|
|
205
214
|
log?: Logger,
|
|
206
215
|
) => Promise<{ path: string; mimeType: string } | null>;
|
|
207
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;
|
|
208
226
|
}): Promise<void> {
|
|
209
227
|
const {
|
|
210
228
|
matchedAgents,
|
|
@@ -215,11 +233,65 @@ export async function dispatchSubAgents(params: {
|
|
|
215
233
|
dingtalkConfig,
|
|
216
234
|
sessionWebhook,
|
|
217
235
|
extractedContent,
|
|
236
|
+
sessionPeer,
|
|
237
|
+
onRoutesResolved,
|
|
218
238
|
handleMessage,
|
|
219
239
|
downloadMedia: download,
|
|
220
240
|
log,
|
|
241
|
+
inboundQueueEligible,
|
|
221
242
|
} = params;
|
|
222
243
|
|
|
244
|
+
let helperMissingWarningSent = false;
|
|
245
|
+
const sendHelperMissingWarning = async (): Promise<void> => {
|
|
246
|
+
if (helperMissingWarningSent) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
helperMissingWarningSent = true;
|
|
250
|
+
try {
|
|
251
|
+
const isGroup = data.conversationType !== "1";
|
|
252
|
+
const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
|
|
253
|
+
await sendBySession(
|
|
254
|
+
dingtalkConfig,
|
|
255
|
+
sessionWebhook,
|
|
256
|
+
"⚠️ 当前宿主版本不支持 DingTalk 子助手路由所需的 session helper,请升级 OpenClaw 后重试。",
|
|
257
|
+
sendOptions,
|
|
258
|
+
);
|
|
259
|
+
} catch (notifyError: unknown) {
|
|
260
|
+
log?.debug?.(
|
|
261
|
+
`[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let resolvedTargets: ResolvedSubAgentTarget[];
|
|
267
|
+
try {
|
|
268
|
+
const rt = getDingTalkRuntime();
|
|
269
|
+
resolvedTargets = matchedAgents.map((agent) => ({
|
|
270
|
+
agent,
|
|
271
|
+
route: {
|
|
272
|
+
agentId: agent.agentId,
|
|
273
|
+
sessionKey: buildAgentSessionKey({
|
|
274
|
+
rt,
|
|
275
|
+
cfg,
|
|
276
|
+
accountId,
|
|
277
|
+
agentId: agent.agentId,
|
|
278
|
+
peerKind: sessionPeer.kind,
|
|
279
|
+
peerId: sessionPeer.peerId,
|
|
280
|
+
}),
|
|
281
|
+
mainSessionKey: "",
|
|
282
|
+
},
|
|
283
|
+
}));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const message = getErrorMessage(error);
|
|
286
|
+
log?.error?.(`[DingTalk] Failed to resolve sub-agent routes: ${message}`);
|
|
287
|
+
if (error instanceof HostRoutingHelperUnavailableError) {
|
|
288
|
+
await sendHelperMissingWarning();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
onRoutesResolved?.(resolvedTargets);
|
|
294
|
+
|
|
223
295
|
// Pre-download media once to avoid duplication across sub-agents
|
|
224
296
|
let preDownloadedMedia:
|
|
225
297
|
| {
|
|
@@ -255,9 +327,8 @@ export async function dispatchSubAgents(params: {
|
|
|
255
327
|
};
|
|
256
328
|
}
|
|
257
329
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
for (const agentMatch of matchedAgents) {
|
|
330
|
+
for (const target of resolvedTargets) {
|
|
331
|
+
const agentMatch = target.agent;
|
|
261
332
|
try {
|
|
262
333
|
await handleMessage({
|
|
263
334
|
cfg,
|
|
@@ -266,6 +337,7 @@ export async function dispatchSubAgents(params: {
|
|
|
266
337
|
sessionWebhook,
|
|
267
338
|
log,
|
|
268
339
|
dingtalkConfig,
|
|
340
|
+
routeOverride: target.route,
|
|
269
341
|
subAgentOptions: {
|
|
270
342
|
agentId: agentMatch.agentId,
|
|
271
343
|
responsePrefix: commandText
|
|
@@ -275,26 +347,19 @@ export async function dispatchSubAgents(params: {
|
|
|
275
347
|
commandText,
|
|
276
348
|
},
|
|
277
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,
|
|
278
357
|
});
|
|
279
358
|
} catch (error) {
|
|
280
359
|
const message = getErrorMessage(error);
|
|
281
360
|
log?.error?.(`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`);
|
|
282
|
-
if (error instanceof HostRoutingHelperUnavailableError
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
const isGroup = data.conversationType !== "1";
|
|
286
|
-
const sendOptions = isGroup ? { atUserId: data.senderId, log } : { log };
|
|
287
|
-
await sendBySession(
|
|
288
|
-
dingtalkConfig,
|
|
289
|
-
sessionWebhook,
|
|
290
|
-
"⚠️ 当前宿主版本不支持 DingTalk 子助手路由所需的 session helper,请升级 OpenClaw 后重试。",
|
|
291
|
-
sendOptions,
|
|
292
|
-
);
|
|
293
|
-
} catch (notifyError: unknown) {
|
|
294
|
-
log?.debug?.(
|
|
295
|
-
`[DingTalk] Failed to send sub-agent helper-missing notice: ${getErrorMessage(notifyError)}`,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
361
|
+
if (error instanceof HostRoutingHelperUnavailableError) {
|
|
362
|
+
await sendHelperMissingWarning();
|
|
298
363
|
}
|
|
299
364
|
}
|
|
300
365
|
}
|