@soimy/dingtalk 3.6.4 → 3.6.6
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/README.md +14 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7207 -6169
- package/dist/index.js.map +4 -4
- package/dist/src/card/ask-user-question-context.d.ts +17 -0
- package/dist/src/card/ask-user-question-context.d.ts.map +1 -0
- package/dist/src/card/ask-user-question.d.ts +241 -0
- package/dist/src/card/ask-user-question.d.ts.map +1 -0
- 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/card-template.d.ts +5 -0
- package/dist/src/card/card-template.d.ts.map +1 -1
- package/dist/src/card/task-model-metadata.d.ts +11 -0
- package/dist/src/card/task-model-metadata.d.ts.map +1 -0
- package/dist/src/gateway/channel-gateway.d.ts.map +1 -1
- package/dist/src/inbound-handler.d.ts.map +1 -1
- package/dist/src/reply-strategy-card.d.ts.map +1 -1
- package/dist/src/reply-strategy-types.d.ts +1 -0
- package/dist/src/reply-strategy-types.d.ts.map +1 -1
- package/dist/src/session-state.d.ts +11 -5
- package/dist/src/session-state.d.ts.map +1 -1
- package/docs/assets/dingtalk-ask-user-card-template.json +6 -0
- package/index.ts +18 -1
- package/openclaw.plugin.json +3 -0
- package/package.json +2 -1
- package/src/card/ask-user-question-context.ts +31 -0
- package/src/card/ask-user-question.ts +1068 -0
- package/src/card/card-action-handler.ts +14 -0
- package/src/card/card-template.ts +10 -0
- package/src/card/task-model-metadata.ts +51 -0
- package/src/gateway/channel-gateway.ts +5 -5
- package/src/inbound-handler.ts +124 -21
- package/src/reply-strategy-card.ts +30 -6
- package/src/reply-strategy-types.ts +1 -0
- package/src/session-state.ts +44 -13
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import type { CardCallbackAnalysis } from "../card-callback-service";
|
|
3
3
|
import type { DingTalkConfig, Logger } from "../types";
|
|
4
|
+
import { handleDingTalkAskUserCardCallback } from "./ask-user-question";
|
|
4
5
|
import { resolveCardRun } from "./card-run-registry";
|
|
5
6
|
import { stopCardRun } from "./card-stop-handler";
|
|
6
7
|
|
|
@@ -9,12 +10,25 @@ export interface CardActionResult {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export async function handleCardAction(params: {
|
|
13
|
+
payload: unknown;
|
|
12
14
|
analysis: CardCallbackAnalysis;
|
|
13
15
|
cfg: OpenClawConfig;
|
|
14
16
|
accountId: string;
|
|
15
17
|
config: DingTalkConfig;
|
|
16
18
|
log?: Logger;
|
|
17
19
|
}): Promise<CardActionResult> {
|
|
20
|
+
const askUserResult = await handleDingTalkAskUserCardCallback({
|
|
21
|
+
payload: params.payload,
|
|
22
|
+
cfg: params.cfg,
|
|
23
|
+
accountId: params.accountId,
|
|
24
|
+
config: params.config,
|
|
25
|
+
clickerUserId: params.analysis.userId,
|
|
26
|
+
log: params.log,
|
|
27
|
+
});
|
|
28
|
+
if (askUserResult.handled) {
|
|
29
|
+
return askUserResult;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
if (params.analysis.actionId !== "btn_stop") {
|
|
19
33
|
return { handled: false };
|
|
20
34
|
}
|
|
@@ -8,6 +8,8 @@ export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID =
|
|
|
8
8
|
export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
|
|
9
9
|
export const BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
|
|
10
10
|
export const BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
|
|
11
|
+
export const BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID =
|
|
12
|
+
"c2a6355b-9724-4f7e-9653-d33fcb3311bb.schema";
|
|
11
13
|
|
|
12
14
|
export interface DingTalkCardTemplateContract {
|
|
13
15
|
templateId: string;
|
|
@@ -20,6 +22,10 @@ export interface DingTalkCardTemplateContract {
|
|
|
20
22
|
copyContentKey: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
export interface DingTalkAskUserCardTemplateContract {
|
|
26
|
+
templateId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
/** Frozen singleton — no allocation on every call. */
|
|
24
30
|
export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
|
|
25
31
|
templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
|
|
@@ -29,3 +35,7 @@ export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Ob
|
|
|
29
35
|
copyContentKey: BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY,
|
|
30
36
|
});
|
|
31
37
|
|
|
38
|
+
export const DINGTALK_ASK_USER_CARD_TEMPLATE: Readonly<DingTalkAskUserCardTemplateContract> =
|
|
39
|
+
Object.freeze({
|
|
40
|
+
templateId: BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID,
|
|
41
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
export interface InitialTaskModelMetadata {
|
|
4
|
+
model?: string;
|
|
5
|
+
effort?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type AgentModelConfigLike = string | { primary?: unknown } | undefined;
|
|
9
|
+
|
|
10
|
+
function readPrimaryModelRef(value: AgentModelConfigLike): string | undefined {
|
|
11
|
+
if (typeof value === "string") {
|
|
12
|
+
return value.trim() || undefined;
|
|
13
|
+
}
|
|
14
|
+
if (value && typeof value === "object" && typeof value.primary === "string") {
|
|
15
|
+
return value.primary.trim() || undefined;
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeModelDisplayName(modelRef: string | undefined): string | undefined {
|
|
21
|
+
const trimmed = typeof modelRef === "string" ? modelRef.trim() : "";
|
|
22
|
+
if (!trimmed) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const parts = trimmed.split("/").map((part) => part.trim()).filter(Boolean);
|
|
26
|
+
return parts.at(-1) || undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveConfiguredTaskModelMetadata(params: {
|
|
30
|
+
cfg: OpenClawConfig;
|
|
31
|
+
agentId?: string | null;
|
|
32
|
+
}): InitialTaskModelMetadata {
|
|
33
|
+
const agents = params.cfg.agents;
|
|
34
|
+
const agentId = String(params.agentId || "").trim();
|
|
35
|
+
const agent = Array.isArray(agents?.list)
|
|
36
|
+
? agents.list.find((entry) => String(entry.id || "").trim() === agentId)
|
|
37
|
+
: undefined;
|
|
38
|
+
|
|
39
|
+
const modelRef = readPrimaryModelRef(agent?.model) ?? readPrimaryModelRef(agents?.defaults?.model);
|
|
40
|
+
const effort =
|
|
41
|
+
typeof agent?.thinkingDefault === "string" && agent.thinkingDefault.trim()
|
|
42
|
+
? agent.thinkingDefault.trim()
|
|
43
|
+
: typeof agents?.defaults?.thinkingDefault === "string" && agents.defaults.thinkingDefault.trim()
|
|
44
|
+
? agents.defaults.thinkingDefault.trim()
|
|
45
|
+
: undefined;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
model: normalizeModelDisplayName(modelRef),
|
|
49
|
+
effort,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
|
|
2
2
|
import { analyzeCardCallback } from "../card-callback-service";
|
|
3
|
+
import { finalizeActiveCardsForAccount, recoverPendingCardsForAccount } from "../card-service";
|
|
3
4
|
import { handleCardAction } from "../card/card-action-handler";
|
|
4
|
-
import {
|
|
5
|
-
finalizeActiveCardsForAccount,
|
|
6
|
-
recoverPendingCardsForAccount,
|
|
7
|
-
} from "../card-service";
|
|
8
5
|
import { resolveRobotCode, resolveRuntimeConfig } from "../config";
|
|
9
6
|
import { ConnectionManager } from "../connection-manager";
|
|
10
7
|
import { isMessageProcessed, markMessageProcessed } from "../dedup";
|
|
@@ -381,6 +378,7 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
|
|
|
381
378
|
}
|
|
382
379
|
}
|
|
383
380
|
const actionResult = await handleCardAction({
|
|
381
|
+
payload,
|
|
384
382
|
analysis,
|
|
385
383
|
cfg,
|
|
386
384
|
accountId: account.accountId,
|
|
@@ -500,7 +498,9 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
|
|
|
500
498
|
lastStartAt: getCurrentTimestamp(),
|
|
501
499
|
lastError: null,
|
|
502
500
|
});
|
|
503
|
-
pluginLog?.info?.(
|
|
501
|
+
pluginLog?.info?.(
|
|
502
|
+
`[${account.accountId}] DingTalk Stream client connected successfully`,
|
|
503
|
+
);
|
|
504
504
|
await nativeStopPromise;
|
|
505
505
|
}
|
|
506
506
|
} catch (err: any) {
|
package/src/inbound-handler.ts
CHANGED
|
@@ -7,9 +7,20 @@ import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
|
|
|
7
7
|
import { attachNativeAckReaction } from "./ack-reaction-service";
|
|
8
8
|
import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
|
|
9
9
|
import { getAccessToken } from "./auth";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createAICard,
|
|
12
|
+
commitAICardBlocks,
|
|
13
|
+
isCardInTerminalState,
|
|
14
|
+
recallAICardMessage,
|
|
15
|
+
} from "./card-service";
|
|
16
|
+
import {
|
|
17
|
+
getDingTalkQuestionContext,
|
|
18
|
+
withDingTalkQuestionContext,
|
|
19
|
+
} from "./card/ask-user-question-context";
|
|
11
20
|
import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
|
|
12
21
|
import { renderStatusLine } from "./card/statusline-renderer";
|
|
22
|
+
import { resolveConfiguredTaskModelMetadata } from "./card/task-model-metadata";
|
|
23
|
+
import { dispatchDingTalkCardStopCommand } from "./command/card-stop-command";
|
|
13
24
|
import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
|
|
14
25
|
import {
|
|
15
26
|
resolveAckReactionSetting,
|
|
@@ -418,6 +429,7 @@ function buildGroupTurnContextPrompt(params: {
|
|
|
418
429
|
|
|
419
430
|
type ReplyStreamPayload = {
|
|
420
431
|
text?: string;
|
|
432
|
+
isError?: boolean;
|
|
421
433
|
isReasoning?: boolean;
|
|
422
434
|
mediaUrl?: string;
|
|
423
435
|
mediaUrls?: string[];
|
|
@@ -548,6 +560,23 @@ export async function downloadMedia(
|
|
|
548
560
|
}
|
|
549
561
|
|
|
550
562
|
export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
563
|
+
// Keep context creation inside this public inbound entry. Ask-user synthetic
|
|
564
|
+
// reinjections call handleDingTalkMessage directly, so moving this wrapper to
|
|
565
|
+
// gateway callbacks would lose per-message isolation for reinjected answers.
|
|
566
|
+
return withDingTalkQuestionContext(
|
|
567
|
+
{
|
|
568
|
+
cfg: params.cfg,
|
|
569
|
+
accountId: params.accountId,
|
|
570
|
+
data: params.data,
|
|
571
|
+
sessionWebhook: params.sessionWebhook,
|
|
572
|
+
log: params.log,
|
|
573
|
+
dingtalkConfig: params.dingtalkConfig,
|
|
574
|
+
},
|
|
575
|
+
() => handleDingTalkMessageInner(params),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams): Promise<void> {
|
|
551
580
|
const {
|
|
552
581
|
cfg,
|
|
553
582
|
accountId,
|
|
@@ -809,6 +838,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
809
838
|
peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
|
|
810
839
|
});
|
|
811
840
|
}
|
|
841
|
+
const questionContext = getDingTalkQuestionContext();
|
|
842
|
+
if (questionContext) {
|
|
843
|
+
questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
|
|
844
|
+
}
|
|
812
845
|
|
|
813
846
|
// @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
|
|
814
847
|
// Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
|
|
@@ -905,17 +938,27 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
905
938
|
const content = extractedContent;
|
|
906
939
|
const isBtwBypass = isBtwRequestText(stripLeadingMentions(content.text).trim());
|
|
907
940
|
const taskInfoConversationId = groupId || to;
|
|
908
|
-
const
|
|
941
|
+
const agentDisplayName = getAgentDisplayName({
|
|
942
|
+
subAgentOptions,
|
|
943
|
+
agentId: route.agentId,
|
|
944
|
+
agentsList: cfg.agents?.list,
|
|
945
|
+
});
|
|
946
|
+
const configuredTaskMetadata = resolveConfiguredTaskModelMetadata({
|
|
947
|
+
cfg,
|
|
948
|
+
agentId: route.agentId,
|
|
949
|
+
});
|
|
950
|
+
const sessionTaskStateScope = {
|
|
951
|
+
accountId,
|
|
952
|
+
conversationId: taskInfoConversationId,
|
|
953
|
+
agentId: route.agentId,
|
|
954
|
+
};
|
|
955
|
+
const sessionTaskState = initSessionState(sessionTaskStateScope, configuredTaskMetadata);
|
|
909
956
|
const initialStatusLine =
|
|
910
957
|
renderStatusLine(
|
|
911
958
|
{
|
|
912
959
|
model: sessionTaskState.model,
|
|
913
960
|
effort: sessionTaskState.effort,
|
|
914
|
-
agent:
|
|
915
|
-
subAgentOptions,
|
|
916
|
-
agentId: route.agentId,
|
|
917
|
-
agentsList: cfg.agents?.list,
|
|
918
|
-
}),
|
|
961
|
+
agent: agentDisplayName,
|
|
919
962
|
},
|
|
920
963
|
dingtalkConfig,
|
|
921
964
|
) || undefined;
|
|
@@ -930,8 +973,52 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
930
973
|
// sending a separate plain-text message.
|
|
931
974
|
let useCardMode = dingtalkConfig.messageType === "card";
|
|
932
975
|
let currentAICard: import("./types").AICardInstance | undefined;
|
|
976
|
+
let questionCardTookOver = false;
|
|
933
977
|
|
|
934
978
|
let cardFlightKey: string | undefined;
|
|
979
|
+
if (questionContext) {
|
|
980
|
+
questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
|
|
981
|
+
questionCardTookOver = true;
|
|
982
|
+
if (cardFlightKey) {
|
|
983
|
+
cardCreationInFlight.delete(cardFlightKey);
|
|
984
|
+
cardFlightKey = undefined;
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
await dispatchDingTalkCardStopCommand({
|
|
988
|
+
cfg,
|
|
989
|
+
accountId,
|
|
990
|
+
agentId: route.agentId,
|
|
991
|
+
targetSessionKey: route.sessionKey,
|
|
992
|
+
clickerUserId: senderId || "unknown",
|
|
993
|
+
log,
|
|
994
|
+
});
|
|
995
|
+
log?.info?.(
|
|
996
|
+
`[DingTalk][AskUser] Dispatched targeted stop after question card sent question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}`,
|
|
997
|
+
);
|
|
998
|
+
} catch (err) {
|
|
999
|
+
log?.warn?.(
|
|
1000
|
+
`[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
if (!currentAICard) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (isCardInTerminalState(currentAICard.state)) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const recalled = await recallAICardMessage(currentAICard, log);
|
|
1010
|
+
if (!recalled) {
|
|
1011
|
+
log?.warn?.(
|
|
1012
|
+
`[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
|
|
1013
|
+
);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
log?.info?.(
|
|
1017
|
+
`[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
|
|
1018
|
+
);
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
935
1022
|
if (useCardMode && !isBtwBypass) {
|
|
936
1023
|
const key = `${accountId}:${to}`;
|
|
937
1024
|
if (cardCreationInFlight.has(key)) {
|
|
@@ -2100,7 +2187,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2100
2187
|
legacyCardStreamReasoning === undefined
|
|
2101
2188
|
? dingtalkConfig
|
|
2102
2189
|
: { ...dingtalkConfig, cardStreamReasoning: legacyCardStreamReasoning };
|
|
2103
|
-
const sessionTaskState = getSessionState(
|
|
2190
|
+
const sessionTaskState = getSessionState(sessionTaskStateScope);
|
|
2104
2191
|
const taskMeta = {
|
|
2105
2192
|
model: sessionTaskState?.model,
|
|
2106
2193
|
effort: sessionTaskState?.effort,
|
|
@@ -2108,11 +2195,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2108
2195
|
typeof sessionTaskState?.taskStartTime === "number"
|
|
2109
2196
|
? Math.max(0, Date.now() - sessionTaskState.taskStartTime)
|
|
2110
2197
|
: undefined,
|
|
2111
|
-
agent:
|
|
2112
|
-
subAgentOptions,
|
|
2113
|
-
agentId: route.agentId,
|
|
2114
|
-
agentsList: cfg.agents?.list,
|
|
2115
|
-
}),
|
|
2198
|
+
agent: agentDisplayName,
|
|
2116
2199
|
};
|
|
2117
2200
|
|
|
2118
2201
|
const strategy = createReplyStrategy({
|
|
@@ -2163,11 +2246,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2163
2246
|
const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
|
|
2164
2247
|
const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
|
|
2165
2248
|
const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
|
|
2249
|
+
const replyKind = (info?.kind as DeliverPayload["kind"]) || "block";
|
|
2250
|
+
if (questionCardTookOver) {
|
|
2251
|
+
log?.info?.(
|
|
2252
|
+
`[DingTalk][AskUser] Suppressed ${replyKind} reply after question card took over`,
|
|
2253
|
+
);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2166
2256
|
await strategy.deliver({
|
|
2167
2257
|
text: inlineReplyPayload.text,
|
|
2168
2258
|
mediaUrls,
|
|
2169
2259
|
audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
|
|
2170
|
-
kind:
|
|
2260
|
+
kind: replyKind,
|
|
2261
|
+
isError: payload.isError === true,
|
|
2171
2262
|
isReasoning: richPayload.isReasoning === true,
|
|
2172
2263
|
});
|
|
2173
2264
|
} catch (err: unknown) {
|
|
@@ -2212,13 +2303,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2212
2303
|
typeof bufferedFinalPayload.text === "string" &&
|
|
2213
2304
|
bufferedFinalPayload.text.trim().length > 0;
|
|
2214
2305
|
if (hasBufferedText || mediaUrls.length > 0) {
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2306
|
+
if (questionCardTookOver) {
|
|
2307
|
+
log?.info?.(
|
|
2308
|
+
"[DingTalk][AskUser] Suppressed buffered final after question card took over",
|
|
2309
|
+
);
|
|
2310
|
+
} else {
|
|
2311
|
+
await strategy.deliver({
|
|
2312
|
+
text: inlineReplyPayload.text,
|
|
2313
|
+
mediaUrls,
|
|
2314
|
+
audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
|
|
2315
|
+
kind: "final",
|
|
2316
|
+
isReasoning: bufferedFinalPayload.isReasoning === true,
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2222
2319
|
}
|
|
2223
2320
|
}
|
|
2224
2321
|
} catch (dispatchErr: unknown) {
|
|
@@ -2228,6 +2325,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
2228
2325
|
throw dispatchErr;
|
|
2229
2326
|
}
|
|
2230
2327
|
|
|
2328
|
+
if (questionCardTookOver) {
|
|
2329
|
+
log?.info?.(
|
|
2330
|
+
"[DingTalk][AskUser] Skipping normal AI card finalize after question card took over",
|
|
2331
|
+
);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2231
2334
|
await strategy.finalize();
|
|
2232
2335
|
} finally {
|
|
2233
2336
|
// Only remove the registry entry if no stop was requested. When a stop is
|
|
@@ -49,6 +49,14 @@ export function createCardReplyStrategy(
|
|
|
49
49
|
ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
|
|
50
50
|
): ReplyStrategy {
|
|
51
51
|
const { card, config, log, isStopRequested } = ctx;
|
|
52
|
+
const sessionTaskStateScope =
|
|
53
|
+
card.accountId && card.conversationId && ctx.sessionAgentId
|
|
54
|
+
? {
|
|
55
|
+
accountId: card.accountId,
|
|
56
|
+
conversationId: card.contextConversationId || card.conversationId,
|
|
57
|
+
agentId: ctx.sessionAgentId,
|
|
58
|
+
}
|
|
59
|
+
: undefined;
|
|
52
60
|
|
|
53
61
|
const buildStatusLine = (): string | undefined => {
|
|
54
62
|
if (!ctx.taskMeta) {
|
|
@@ -61,8 +69,8 @@ export function createCardReplyStrategy(
|
|
|
61
69
|
? ctx.taskMeta.usage
|
|
62
70
|
: undefined;
|
|
63
71
|
|
|
64
|
-
const sessionTaskTimeSeconds =
|
|
65
|
-
? getTaskTimeSeconds(
|
|
72
|
+
const sessionTaskTimeSeconds = sessionTaskStateScope
|
|
73
|
+
? getTaskTimeSeconds(sessionTaskStateScope)
|
|
66
74
|
: undefined;
|
|
67
75
|
const cardElapsedMs = Math.max(0, Date.now() - card.createdAt);
|
|
68
76
|
const sessionElapsedMs = typeof sessionTaskTimeSeconds === "number"
|
|
@@ -439,10 +447,12 @@ export function createCardReplyStrategy(
|
|
|
439
447
|
if (!card.accountId || !card.conversationId) {
|
|
440
448
|
return;
|
|
441
449
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
450
|
+
if (sessionTaskStateScope) {
|
|
451
|
+
updateSessionState(sessionTaskStateScope, {
|
|
452
|
+
model: selected.model,
|
|
453
|
+
effort: selected.thinkLevel,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
446
456
|
if (ctx.taskMeta) {
|
|
447
457
|
ctx.taskMeta.model = selected.model;
|
|
448
458
|
ctx.taskMeta.effort = selected.thinkLevel;
|
|
@@ -470,6 +480,20 @@ export function createCardReplyStrategy(
|
|
|
470
480
|
|
|
471
481
|
// ---- final: defer to finalize, just save text ----
|
|
472
482
|
if (payload.kind === "final") {
|
|
483
|
+
// OpenClaw can append a non-terminal tool warning after a valid final.
|
|
484
|
+
// Do not let that plain-text warning replace the answer already captured by the card.
|
|
485
|
+
const hasPreservableReply = Boolean(
|
|
486
|
+
finalTextForFallback?.trim() || controller.getFinalAnswerContent().trim(),
|
|
487
|
+
) || processedMediaUrls.size > 0 || pendingNonImageMedia.length > 0;
|
|
488
|
+
if (
|
|
489
|
+
payload.isError === true
|
|
490
|
+
&& sawFinalDelivery
|
|
491
|
+
&& payload.mediaUrls.length === 0
|
|
492
|
+
&& hasPreservableReply
|
|
493
|
+
) {
|
|
494
|
+
log?.debug?.("[DingTalk][Finalize] Ignoring late error final after answer delivery");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
473
497
|
const isFirstFinalDelivery = !sawFinalDelivery;
|
|
474
498
|
lifecycleState = "final_seen";
|
|
475
499
|
await flushPendingReasoning();
|
package/src/session-state.ts
CHANGED
|
@@ -4,36 +4,67 @@ interface SessionState {
|
|
|
4
4
|
taskStartTime: number;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
interface SessionStateScope {
|
|
8
|
+
accountId: string;
|
|
9
|
+
conversationId: string;
|
|
10
|
+
agentId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type SessionStateInitialMetadata = Partial<Pick<SessionState, "model" | "effort">>;
|
|
14
|
+
|
|
7
15
|
const sessionStore = new Map<string, SessionState>();
|
|
8
16
|
|
|
9
|
-
function sessionKey(
|
|
10
|
-
return `${accountId}:${conversationId}`;
|
|
17
|
+
function sessionKey(scope: SessionStateScope): string {
|
|
18
|
+
return `${scope.accountId}:${scope.agentId}:${scope.conversationId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeMetadataValue(value: string | undefined): string | undefined {
|
|
22
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
23
|
+
return trimmed || undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function seedMissingSessionStateMetadata(
|
|
27
|
+
state: SessionState,
|
|
28
|
+
metadata?: SessionStateInitialMetadata,
|
|
29
|
+
): void {
|
|
30
|
+
const model = normalizeMetadataValue(metadata?.model);
|
|
31
|
+
const effort = normalizeMetadataValue(metadata?.effort);
|
|
32
|
+
if (state.model === undefined && model !== undefined) {
|
|
33
|
+
state.model = model;
|
|
34
|
+
}
|
|
35
|
+
if (state.effort === undefined && effort !== undefined) {
|
|
36
|
+
state.effort = effort;
|
|
37
|
+
}
|
|
11
38
|
}
|
|
12
39
|
|
|
13
|
-
export function initSessionState(
|
|
14
|
-
|
|
40
|
+
export function initSessionState(
|
|
41
|
+
scope: SessionStateScope,
|
|
42
|
+
metadata?: SessionStateInitialMetadata,
|
|
43
|
+
): SessionState {
|
|
44
|
+
const key = sessionKey(scope);
|
|
15
45
|
const existing = sessionStore.get(key);
|
|
16
46
|
if (existing) {
|
|
17
47
|
existing.taskStartTime = Date.now();
|
|
48
|
+
seedMissingSessionStateMetadata(existing, metadata);
|
|
18
49
|
return existing;
|
|
19
50
|
}
|
|
20
51
|
const state: SessionState = {
|
|
21
52
|
taskStartTime: Date.now(),
|
|
22
53
|
};
|
|
54
|
+
seedMissingSessionStateMetadata(state, metadata);
|
|
23
55
|
sessionStore.set(key, state);
|
|
24
56
|
return state;
|
|
25
57
|
}
|
|
26
58
|
|
|
27
|
-
export function getSessionState(
|
|
28
|
-
return sessionStore.get(sessionKey(
|
|
59
|
+
export function getSessionState(scope: SessionStateScope): SessionState | undefined {
|
|
60
|
+
return sessionStore.get(sessionKey(scope));
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
export function updateSessionState(
|
|
32
|
-
|
|
33
|
-
conversationId: string,
|
|
64
|
+
scope: SessionStateScope,
|
|
34
65
|
patch: Partial<Pick<SessionState, "model" | "effort">>,
|
|
35
66
|
): void {
|
|
36
|
-
const state = sessionStore.get(sessionKey(
|
|
67
|
+
const state = sessionStore.get(sessionKey(scope));
|
|
37
68
|
if (!state) {
|
|
38
69
|
return;
|
|
39
70
|
}
|
|
@@ -45,16 +76,16 @@ export function updateSessionState(
|
|
|
45
76
|
}
|
|
46
77
|
}
|
|
47
78
|
|
|
48
|
-
export function getTaskTimeSeconds(
|
|
49
|
-
const state = sessionStore.get(sessionKey(
|
|
79
|
+
export function getTaskTimeSeconds(scope: SessionStateScope): number | undefined {
|
|
80
|
+
const state = sessionStore.get(sessionKey(scope));
|
|
50
81
|
if (!state) {
|
|
51
82
|
return undefined;
|
|
52
83
|
}
|
|
53
84
|
return Math.round((Date.now() - state.taskStartTime) / 1000);
|
|
54
85
|
}
|
|
55
86
|
|
|
56
|
-
export function clearSessionState(
|
|
57
|
-
sessionStore.delete(sessionKey(
|
|
87
|
+
export function clearSessionState(scope: SessionStateScope): void {
|
|
88
|
+
sessionStore.delete(sessionKey(scope));
|
|
58
89
|
}
|
|
59
90
|
|
|
60
91
|
export function clearAllSessionStatesForTest(): void {
|