@soimy/dingtalk 3.4.1 → 3.5.0
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 +86 -1421
- package/package.json +11 -5
- package/src/ack-reaction-service.ts +45 -27
- package/src/card-draft-controller.ts +270 -52
- package/src/card-service.ts +10 -11
- package/src/channel.ts +8 -7
- package/src/config-schema.ts +0 -18
- package/src/config.ts +28 -11
- package/src/feedback-learning-service.ts +4 -6
- package/src/inbound-handler.ts +152 -28
- package/src/message-context-store.ts +74 -0
- package/src/message-utils.ts +23 -1
- package/src/onboarding.ts +0 -45
- package/src/reply-strategy-card.ts +25 -31
- package/src/reply-strategy.ts +3 -3
- package/src/send-service.ts +30 -50
- package/src/targeting/agent-routing.ts +12 -6
- package/src/types.ts +4 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soimy/dingtalk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "DingTalk (钉钉) channel plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"clawbot.plugin.json"
|
|
26
26
|
],
|
|
27
27
|
"type": "module",
|
|
28
|
+
"packageManager": "pnpm@10.33.0",
|
|
28
29
|
"main": "index.ts",
|
|
29
30
|
"publishConfig": {
|
|
30
31
|
"access": "public",
|
|
@@ -35,6 +36,10 @@
|
|
|
35
36
|
"format:check": "oxfmt --check package.json tsconfig.json index.ts src/*.ts",
|
|
36
37
|
"lint": "oxlint --type-aware index.ts src",
|
|
37
38
|
"lint:fix": "oxlint --type-aware --fix index.ts src && pnpm format",
|
|
39
|
+
"docs:dev": "vitepress dev docs --host 127.0.0.1 --port 8000",
|
|
40
|
+
"docs:serv": "vitepress dev docs --host 127.0.0.1 --port 8000",
|
|
41
|
+
"docs:build": "vitepress build docs",
|
|
42
|
+
"docs:preview": "vitepress preview docs --host 127.0.0.1 --port 4173",
|
|
38
43
|
"monitor:stream": "node scripts/dingtalk-stream-monitor.mjs",
|
|
39
44
|
"test": "vitest run",
|
|
40
45
|
"test:coverage": "vitest run --coverage",
|
|
@@ -51,15 +56,15 @@
|
|
|
51
56
|
"devDependencies": {
|
|
52
57
|
"@types/node": "^25.2.0",
|
|
53
58
|
"@vitest/coverage-v8": "^3.2.4",
|
|
54
|
-
"openclaw": "2026.3.22",
|
|
55
59
|
"oxfmt": "0.34.0",
|
|
56
60
|
"oxlint": "^1.49.0",
|
|
57
61
|
"oxlint-tsgolint": "^0.15.0",
|
|
58
62
|
"typescript": "^5.3.0",
|
|
63
|
+
"vitepress": "1.6.4",
|
|
59
64
|
"vitest": "^3.2.4"
|
|
60
65
|
},
|
|
61
66
|
"peerDependencies": {
|
|
62
|
-
"openclaw": ">=2026.3.
|
|
67
|
+
"openclaw": ">=2026.3.24"
|
|
63
68
|
},
|
|
64
69
|
"openclaw": {
|
|
65
70
|
"extensions": [
|
|
@@ -73,8 +78,8 @@
|
|
|
73
78
|
"id": "dingtalk",
|
|
74
79
|
"label": "DingTalk",
|
|
75
80
|
"selectionLabel": "DingTalk (钉钉)",
|
|
76
|
-
"docsPath": "https://github.
|
|
77
|
-
"docsLabel": "
|
|
81
|
+
"docsPath": "https://soimy.github.io/openclaw-channel-dingtalk/",
|
|
82
|
+
"docsLabel": "documentation",
|
|
78
83
|
"blurb": "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
|
|
79
84
|
"order": 70,
|
|
80
85
|
"aliases": [
|
|
@@ -83,6 +88,7 @@
|
|
|
83
88
|
]
|
|
84
89
|
},
|
|
85
90
|
"install": {
|
|
91
|
+
"minHostVersion": ">=2026.3.24",
|
|
86
92
|
"npmSpec": "@soimy/dingtalk",
|
|
87
93
|
"localPath": ".",
|
|
88
94
|
"defaultChoice": "npm"
|
|
@@ -23,59 +23,69 @@ type AckReactionLogger = {
|
|
|
23
23
|
type AckReactionTarget = {
|
|
24
24
|
msgId: string;
|
|
25
25
|
conversationId: string;
|
|
26
|
-
robotCode?: string;
|
|
27
26
|
reactionName?: string;
|
|
28
27
|
};
|
|
29
28
|
|
|
29
|
+
type ResolvedAckReactionRequest = {
|
|
30
|
+
msgId: string;
|
|
31
|
+
conversationId: string;
|
|
32
|
+
robotCode: string;
|
|
33
|
+
reactionName: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
30
36
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
31
37
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
function formatAckReactionTarget(data:
|
|
40
|
+
function formatAckReactionTarget(data: {
|
|
41
|
+
msgId: string;
|
|
42
|
+
conversationId: string;
|
|
43
|
+
reactionName: string;
|
|
44
|
+
}): string {
|
|
35
45
|
return `msgId=${data.msgId || "-"} conversationId=${data.conversationId || "-"} reactionName=${data.reactionName || DINGTALK_NATIVE_ACK_REACTION}`;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const robotCode = (
|
|
48
|
+
function resolveAckReactionRequest(
|
|
49
|
+
config: DingTalkConfig,
|
|
50
|
+
data: AckReactionTarget,
|
|
51
|
+
): ResolvedAckReactionRequest | null {
|
|
52
|
+
const robotCode = (config.clientId || "").trim();
|
|
43
53
|
const reactionName =
|
|
44
54
|
(data.reactionName || DINGTALK_NATIVE_ACK_REACTION).trim() || DINGTALK_NATIVE_ACK_REACTION;
|
|
45
55
|
if (!robotCode || !data.msgId || !data.conversationId) {
|
|
46
56
|
return null;
|
|
47
57
|
}
|
|
48
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
msgId: data.msgId,
|
|
60
|
+
conversationId: data.conversationId,
|
|
61
|
+
robotCode,
|
|
62
|
+
reactionName,
|
|
63
|
+
};
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
async function callEmotionApi(
|
|
52
67
|
config: DingTalkConfig,
|
|
53
|
-
|
|
68
|
+
request: ResolvedAckReactionRequest,
|
|
54
69
|
endpoint: "reply" | "recall",
|
|
55
70
|
successLog: string,
|
|
56
71
|
errorLogPrefix: string,
|
|
57
72
|
errorPayloadKey: "inbound.ackReactionAttach" | "inbound.ackReactionRecall",
|
|
58
73
|
log?: AckReactionLogger,
|
|
59
74
|
): Promise<{ ok: boolean; error?: unknown }> {
|
|
60
|
-
const payload = resolveAckReactionPayload(config, data);
|
|
61
|
-
if (!payload) {
|
|
62
|
-
return { ok: false };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
75
|
try {
|
|
66
76
|
const token = await getAccessToken(config, log as any);
|
|
67
77
|
await axios.post(
|
|
68
78
|
`https://api.dingtalk.com/v1.0/robot/emotion/${endpoint}`,
|
|
69
79
|
{
|
|
70
|
-
robotCode:
|
|
71
|
-
openMsgId:
|
|
72
|
-
openConversationId:
|
|
80
|
+
robotCode: request.robotCode,
|
|
81
|
+
openMsgId: request.msgId,
|
|
82
|
+
openConversationId: request.conversationId,
|
|
73
83
|
emotionType: 2,
|
|
74
|
-
emotionName:
|
|
84
|
+
emotionName: request.reactionName,
|
|
75
85
|
textEmotion: {
|
|
76
86
|
emotionId: THINKING_EMOTION_ID,
|
|
77
|
-
emotionName:
|
|
78
|
-
text:
|
|
87
|
+
emotionName: request.reactionName,
|
|
88
|
+
text: request.reactionName,
|
|
79
89
|
backgroundId: THINKING_EMOTION_BACKGROUND_ID,
|
|
80
90
|
},
|
|
81
91
|
},
|
|
@@ -119,6 +129,10 @@ export async function attachNativeAckReaction(
|
|
|
119
129
|
data: AckReactionTarget,
|
|
120
130
|
log?: AckReactionLogger,
|
|
121
131
|
): Promise<boolean> {
|
|
132
|
+
const request = resolveAckReactionRequest(config, data);
|
|
133
|
+
if (!request) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
122
136
|
for (let index = 0; index < THINKING_REACTION_ATTACH_DELAYS_MS.length; index += 1) {
|
|
123
137
|
const delayMs = THINKING_REACTION_ATTACH_DELAYS_MS[index];
|
|
124
138
|
if (delayMs > 0) {
|
|
@@ -128,10 +142,10 @@ export async function attachNativeAckReaction(
|
|
|
128
142
|
const attemptLabel = `${attempt}/${THINKING_REACTION_ATTACH_DELAYS_MS.length}`;
|
|
129
143
|
const result = await callEmotionApi(
|
|
130
144
|
config,
|
|
131
|
-
|
|
145
|
+
request,
|
|
132
146
|
"reply",
|
|
133
|
-
`[DingTalk] Native ack reaction attach succeeded (${formatAckReactionTarget(
|
|
134
|
-
`[DingTalk] Native ack reaction attach failed (${formatAckReactionTarget(
|
|
147
|
+
`[DingTalk] Native ack reaction attach succeeded (${formatAckReactionTarget(request)} attempt=${attemptLabel})`,
|
|
148
|
+
`[DingTalk] Native ack reaction attach failed (${formatAckReactionTarget(request)} attempt=${attemptLabel})`,
|
|
135
149
|
"inbound.ackReactionAttach",
|
|
136
150
|
log,
|
|
137
151
|
);
|
|
@@ -143,7 +157,7 @@ export async function attachNativeAckReaction(
|
|
|
143
157
|
break;
|
|
144
158
|
}
|
|
145
159
|
log?.debug?.(
|
|
146
|
-
`[DingTalk] Retrying native ack reaction attach (${formatAckReactionTarget(
|
|
160
|
+
`[DingTalk] Retrying native ack reaction attach (${formatAckReactionTarget(request)} nextAttempt=${attempt + 1}/${THINKING_REACTION_ATTACH_DELAYS_MS.length})`,
|
|
147
161
|
);
|
|
148
162
|
}
|
|
149
163
|
return false;
|
|
@@ -151,12 +165,12 @@ export async function attachNativeAckReaction(
|
|
|
151
165
|
|
|
152
166
|
async function recallNativeAckReaction(
|
|
153
167
|
config: DingTalkConfig,
|
|
154
|
-
|
|
168
|
+
request: ResolvedAckReactionRequest,
|
|
155
169
|
log?: AckReactionLogger,
|
|
156
170
|
): Promise<boolean> {
|
|
157
171
|
const result = await callEmotionApi(
|
|
158
172
|
config,
|
|
159
|
-
|
|
173
|
+
request,
|
|
160
174
|
"recall",
|
|
161
175
|
"[DingTalk] Native ack reaction recall succeeded",
|
|
162
176
|
"[DingTalk] Native ack reaction recall failed",
|
|
@@ -171,11 +185,15 @@ export async function recallNativeAckReactionWithRetry(
|
|
|
171
185
|
data: AckReactionTarget,
|
|
172
186
|
log?: AckReactionLogger,
|
|
173
187
|
): Promise<void> {
|
|
188
|
+
const request = resolveAckReactionRequest(config, data);
|
|
189
|
+
if (!request) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
174
192
|
for (const delayMs of THINKING_REACTION_RECALL_DELAYS_MS) {
|
|
175
193
|
if (delayMs > 0) {
|
|
176
194
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
177
195
|
}
|
|
178
|
-
if (await recallNativeAckReaction(config,
|
|
196
|
+
if (await recallNativeAckReaction(config, request, log)) {
|
|
179
197
|
return;
|
|
180
198
|
}
|
|
181
199
|
}
|
|
@@ -1,61 +1,224 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Card draft controller for throttled AI Card streaming updates.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The controller keeps a single rendered card timeline made of:
|
|
5
|
+
* - sealed process blocks (`thinking` / `tool`)
|
|
6
|
+
* - an optional live thinking block
|
|
7
|
+
* - accumulated answer turns rendered as plain markdown
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* - DOES enforce single-flight, latest-wins, phase-gated semantics
|
|
11
|
-
* - Does NOT handle tool append, finalize, or markdown fallback —
|
|
12
|
-
* those stay in inbound-handler's deliver callback.
|
|
9
|
+
* It delegates throttling and single-flight transport guarantees to
|
|
10
|
+
* {@link createDraftStreamLoop}.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
|
-
import {
|
|
13
|
+
import { streamAICard } from "./card-service";
|
|
16
14
|
import { createDraftStreamLoop } from "./draft-stream-loop";
|
|
17
15
|
import type { AICardInstance, Logger } from "./types";
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
type TimelineEntryKind = "thinking" | "tool" | "answer";
|
|
18
|
+
|
|
19
|
+
type TimelineEntry = {
|
|
20
|
+
kind: TimelineEntryKind;
|
|
21
|
+
text: string;
|
|
22
|
+
};
|
|
20
23
|
|
|
21
24
|
export interface CardDraftController {
|
|
22
|
-
updateAnswer: (text: string) => void
|
|
23
|
-
updateReasoning: (text: string) => void
|
|
25
|
+
updateAnswer: (text: string) => Promise<void>;
|
|
26
|
+
updateReasoning: (text: string) => Promise<void>;
|
|
27
|
+
updateThinking: (text: string) => Promise<void>;
|
|
28
|
+
updateTool: (text: string) => Promise<void>;
|
|
29
|
+
appendTool: (text: string) => Promise<void>;
|
|
24
30
|
/** Signal that a new assistant turn has started (e.g. after a tool call). */
|
|
25
|
-
notifyNewAssistantTurn: () => void
|
|
31
|
+
notifyNewAssistantTurn: () => Promise<void>;
|
|
32
|
+
startAssistantTurn: () => Promise<void>;
|
|
26
33
|
flush: () => Promise<void>;
|
|
27
34
|
waitForInFlight: () => Promise<void>;
|
|
28
35
|
stop: () => void;
|
|
29
36
|
isFailed: () => boolean;
|
|
30
|
-
/** Last content sent to card
|
|
37
|
+
/** Last content successfully sent to card. */
|
|
31
38
|
getLastContent: () => string;
|
|
32
|
-
/** Last content sent to card
|
|
39
|
+
/** Last answer-only content successfully sent to card. */
|
|
33
40
|
getLastAnswerContent: () => string;
|
|
41
|
+
/** Current answer-only content composed from all completed answer turns. */
|
|
42
|
+
getFinalAnswerContent: () => string;
|
|
43
|
+
/** Current rendered timeline, including process blocks and answer text. */
|
|
44
|
+
getRenderedContent: (options?: {
|
|
45
|
+
fallbackAnswer?: string;
|
|
46
|
+
overrideAnswer?: string;
|
|
47
|
+
compactProcessAnswerSpacing?: boolean;
|
|
48
|
+
}) => string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeProcessText(text: string | undefined): string {
|
|
52
|
+
return typeof text === "string" ? text.trim() : "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeAnswerText(text: string | undefined): string {
|
|
56
|
+
return typeof text === "string" ? text.trimStart() : "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function quoteMarkdown(text: string): string {
|
|
60
|
+
return text
|
|
61
|
+
.split("\n")
|
|
62
|
+
.map((line) => line.trim() ? `> ${line.trim()}` : ">")
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderProcessBlock(_kind: "thinking" | "tool", text: string): string {
|
|
67
|
+
return quoteMarkdown(text);
|
|
34
68
|
}
|
|
35
69
|
|
|
36
70
|
export function createCardDraftController(params: {
|
|
37
71
|
card: AICardInstance;
|
|
38
72
|
throttleMs?: number;
|
|
73
|
+
/** Legacy compatibility: verbose mode previously lowered the throttle. */
|
|
74
|
+
verboseMode?: boolean;
|
|
39
75
|
log?: Logger;
|
|
40
76
|
}): CardDraftController {
|
|
41
|
-
let phase: CardDraftPhase = "idle";
|
|
42
77
|
let failed = false;
|
|
43
78
|
let stopped = false;
|
|
44
79
|
let lastSentContent = "";
|
|
45
80
|
let lastAnswerContent = "";
|
|
46
|
-
|
|
47
|
-
let
|
|
81
|
+
|
|
82
|
+
let timelineEntries: TimelineEntry[] = [];
|
|
83
|
+
let activeThinkingIndex: number | null = null;
|
|
84
|
+
let activeAnswerIndex: number | null = null;
|
|
85
|
+
let pendingBoundaryPromise: Promise<void> | null = null;
|
|
86
|
+
|
|
87
|
+
const effectiveThrottleMs = params.throttleMs ?? (params.verboseMode ? 50 : 300);
|
|
88
|
+
|
|
89
|
+
const getFinalAnswerContent = (): string => {
|
|
90
|
+
return timelineEntries
|
|
91
|
+
.filter((entry) => entry.kind === "answer" && entry.text)
|
|
92
|
+
.map((entry) => entry.text)
|
|
93
|
+
.join("\n\n");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const removeTimelineEntry = (index: number) => {
|
|
97
|
+
timelineEntries.splice(index, 1);
|
|
98
|
+
if (activeThinkingIndex !== null) {
|
|
99
|
+
if (activeThinkingIndex === index) {
|
|
100
|
+
activeThinkingIndex = null;
|
|
101
|
+
} else if (activeThinkingIndex > index) {
|
|
102
|
+
activeThinkingIndex -= 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (activeAnswerIndex !== null) {
|
|
106
|
+
if (activeAnswerIndex === index) {
|
|
107
|
+
activeAnswerIndex = null;
|
|
108
|
+
} else if (activeAnswerIndex > index) {
|
|
109
|
+
activeAnswerIndex -= 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const appendTimelineEntry = (kind: TimelineEntryKind, text: string): number => {
|
|
115
|
+
timelineEntries.push({ kind, text });
|
|
116
|
+
return timelineEntries.length - 1;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const renderTimeline = (options: {
|
|
120
|
+
fallbackAnswer?: string;
|
|
121
|
+
overrideAnswer?: string;
|
|
122
|
+
compactProcessAnswerSpacing?: boolean;
|
|
123
|
+
} = {}): string => {
|
|
124
|
+
const entries = timelineEntries.map((entry) => ({ ...entry }));
|
|
125
|
+
|
|
126
|
+
const overrideAnswer = normalizeAnswerText(options.overrideAnswer);
|
|
127
|
+
if (overrideAnswer) {
|
|
128
|
+
const lastAnswerIndex = [...entries]
|
|
129
|
+
.map((entry, index) => ({ entry, index }))
|
|
130
|
+
.toReversed()
|
|
131
|
+
.find(({ entry }) => entry.kind === "answer")?.index;
|
|
132
|
+
if (lastAnswerIndex !== undefined) {
|
|
133
|
+
entries[lastAnswerIndex] = { kind: "answer", text: overrideAnswer };
|
|
134
|
+
} else {
|
|
135
|
+
entries.push({ kind: "answer", text: overrideAnswer });
|
|
136
|
+
}
|
|
137
|
+
} else if (!entries.some((entry) => entry.kind === "answer" && entry.text)) {
|
|
138
|
+
const fallbackAnswer = normalizeAnswerText(options.fallbackAnswer);
|
|
139
|
+
if (fallbackAnswer) {
|
|
140
|
+
entries.push({ kind: "answer", text: fallbackAnswer });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let rendered = "";
|
|
145
|
+
const compactProcessAnswerSpacing = options.compactProcessAnswerSpacing === true;
|
|
146
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
147
|
+
const entry = entries[index];
|
|
148
|
+
if (!entry?.text) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const part = entry.kind === "answer"
|
|
152
|
+
? entry.text
|
|
153
|
+
: renderProcessBlock(entry.kind, entry.text);
|
|
154
|
+
if (!rendered) {
|
|
155
|
+
rendered = part;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const previousKind = entries[index - 1]?.kind;
|
|
159
|
+
const separator =
|
|
160
|
+
compactProcessAnswerSpacing && previousKind
|
|
161
|
+
? "\n"
|
|
162
|
+
: "\n\n";
|
|
163
|
+
rendered += `${separator}${part}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return rendered;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const sealLiveThinking = () => {
|
|
170
|
+
activeThinkingIndex = null;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const sealCurrentAnswer = () => {
|
|
174
|
+
activeAnswerIndex = null;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const queueRender = () => {
|
|
178
|
+
const rendered = renderTimeline({ compactProcessAnswerSpacing: true });
|
|
179
|
+
if (rendered) {
|
|
180
|
+
loop.update(rendered);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
loop.resetPending();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const flushBoundaryFrame = async () => {
|
|
187
|
+
if (stopped || failed) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await loop.flush();
|
|
191
|
+
await loop.waitForInFlight();
|
|
192
|
+
loop.resetThrottleWindow();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const beginBoundaryFlush = () => {
|
|
196
|
+
if (pendingBoundaryPromise) {
|
|
197
|
+
return pendingBoundaryPromise;
|
|
198
|
+
}
|
|
199
|
+
const current = flushBoundaryFrame().finally(() => {
|
|
200
|
+
if (pendingBoundaryPromise === current) {
|
|
201
|
+
pendingBoundaryPromise = null;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
pendingBoundaryPromise = current;
|
|
205
|
+
return current;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const waitForPendingBoundary = async () => {
|
|
209
|
+
if (pendingBoundaryPromise) {
|
|
210
|
+
await pendingBoundaryPromise;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
48
213
|
|
|
49
214
|
const loop = createDraftStreamLoop({
|
|
50
|
-
throttleMs:
|
|
215
|
+
throttleMs: effectiveThrottleMs,
|
|
51
216
|
isStopped: () => stopped || failed,
|
|
52
217
|
sendOrEditStreamMessage: async (content: string) => {
|
|
53
218
|
try {
|
|
54
219
|
await streamAICard(params.card, content, false, params.log);
|
|
55
220
|
lastSentContent = content;
|
|
56
|
-
|
|
57
|
-
lastAnswerContent = content;
|
|
58
|
-
}
|
|
221
|
+
lastAnswerContent = getFinalAnswerContent();
|
|
59
222
|
} catch (err: unknown) {
|
|
60
223
|
failed = true;
|
|
61
224
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -64,41 +227,94 @@ export function createCardDraftController(params: {
|
|
|
64
227
|
},
|
|
65
228
|
});
|
|
66
229
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
230
|
+
const updateReasoning = async (text: string) => {
|
|
231
|
+
await waitForPendingBoundary();
|
|
232
|
+
if (stopped || failed || activeAnswerIndex !== null) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const normalized = normalizeProcessText(text);
|
|
236
|
+
if (!normalized) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (activeThinkingIndex === null && timelineEntries.length > 0) {
|
|
240
|
+
const lastKind = timelineEntries.at(-1)?.kind;
|
|
241
|
+
if (lastKind && lastKind !== "thinking") {
|
|
242
|
+
await flushBoundaryFrame();
|
|
74
243
|
}
|
|
75
|
-
}
|
|
244
|
+
}
|
|
245
|
+
if (activeThinkingIndex !== null) {
|
|
246
|
+
timelineEntries[activeThinkingIndex] = { kind: "thinking", text: normalized };
|
|
247
|
+
} else {
|
|
248
|
+
activeThinkingIndex = appendTimelineEntry("thinking", normalized);
|
|
249
|
+
}
|
|
250
|
+
queueRender();
|
|
251
|
+
};
|
|
76
252
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
253
|
+
const updateAnswer = async (text: string) => {
|
|
254
|
+
await waitForPendingBoundary();
|
|
255
|
+
if (stopped || failed) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const normalized = normalizeAnswerText(text);
|
|
259
|
+
if (!normalized.trim()) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (activeAnswerIndex === null && timelineEntries.length > 0) {
|
|
263
|
+
const lastKind = timelineEntries.at(-1)?.kind;
|
|
264
|
+
if (lastKind && lastKind !== "answer") {
|
|
265
|
+
await flushBoundaryFrame();
|
|
86
266
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
}
|
|
267
|
+
}
|
|
268
|
+
sealLiveThinking();
|
|
269
|
+
if (activeAnswerIndex !== null) {
|
|
270
|
+
timelineEntries[activeAnswerIndex] = { kind: "answer", text: normalized };
|
|
271
|
+
} else {
|
|
272
|
+
activeAnswerIndex = appendTimelineEntry("answer", normalized);
|
|
273
|
+
}
|
|
274
|
+
queueRender();
|
|
275
|
+
};
|
|
92
276
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
277
|
+
const updateTool = async (text: string) => {
|
|
278
|
+
await waitForPendingBoundary();
|
|
279
|
+
if (stopped || failed) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const normalized = normalizeProcessText(text);
|
|
283
|
+
if (!normalized) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (timelineEntries.length > 0) {
|
|
287
|
+
await flushBoundaryFrame();
|
|
288
|
+
}
|
|
289
|
+
sealLiveThinking();
|
|
290
|
+
sealCurrentAnswer();
|
|
291
|
+
appendTimelineEntry("tool", normalized);
|
|
292
|
+
queueRender();
|
|
293
|
+
};
|
|
101
294
|
|
|
295
|
+
const notifyNewAssistantTurn = async () => {
|
|
296
|
+
if (stopped || failed) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (activeAnswerIndex !== null) {
|
|
300
|
+
sealCurrentAnswer();
|
|
301
|
+
await beginBoundaryFlush();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (activeThinkingIndex !== null) {
|
|
305
|
+
removeTimelineEntry(activeThinkingIndex);
|
|
306
|
+
loop.resetPending();
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
updateAnswer,
|
|
312
|
+
updateReasoning,
|
|
313
|
+
updateThinking: updateReasoning,
|
|
314
|
+
updateTool,
|
|
315
|
+
appendTool: updateTool,
|
|
316
|
+
notifyNewAssistantTurn,
|
|
317
|
+
startAssistantTurn: notifyNewAssistantTurn,
|
|
102
318
|
flush: () => loop.flush(),
|
|
103
319
|
waitForInFlight: () => loop.waitForInFlight(),
|
|
104
320
|
|
|
@@ -110,5 +326,7 @@ export function createCardDraftController(params: {
|
|
|
110
326
|
isFailed: () => failed,
|
|
111
327
|
getLastContent: () => lastSentContent,
|
|
112
328
|
getLastAnswerContent: () => lastAnswerContent,
|
|
329
|
+
getFinalAnswerContent,
|
|
330
|
+
getRenderedContent: renderTimeline,
|
|
113
331
|
};
|
|
114
332
|
}
|
package/src/card-service.ts
CHANGED
|
@@ -3,13 +3,15 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import axios from "axios";
|
|
5
5
|
import { getAccessToken } from "./auth";
|
|
6
|
-
import { stripTargetPrefix } from "./config";
|
|
6
|
+
import { resolveRobotCode, stripTargetPrefix } from "./config";
|
|
7
7
|
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
8
8
|
import {
|
|
9
9
|
createSyntheticOutboundMsgId,
|
|
10
10
|
clearMessageContextCacheForTest,
|
|
11
11
|
DEFAULT_CARD_CONTENT_TTL_MS,
|
|
12
12
|
DEFAULT_CREATED_AT_MATCH_WINDOW_MS,
|
|
13
|
+
DEFAULT_OUTBOUND_SENDER,
|
|
14
|
+
inferConversationChatType,
|
|
13
15
|
resolveByCreatedAtWindow,
|
|
14
16
|
upsertOutboundMessageContext,
|
|
15
17
|
} from "./message-context-store";
|
|
@@ -409,7 +411,7 @@ async function sendTemplateMismatchNotification(
|
|
|
409
411
|
|
|
410
412
|
// Direct markdown fallback notification to user/group, without re-entering sendMessage card flow.
|
|
411
413
|
const payload: Record<string, unknown> = {
|
|
412
|
-
robotCode: config
|
|
414
|
+
robotCode: resolveRobotCode(config),
|
|
413
415
|
msgKey: "sampleMarkdown",
|
|
414
416
|
msgParam: JSON.stringify({ title: "OpenClaw 提醒", text }),
|
|
415
417
|
};
|
|
@@ -596,26 +598,19 @@ export async function createAICard(
|
|
|
596
598
|
userIdType: 1,
|
|
597
599
|
imGroupOpenDeliverModel: isGroup
|
|
598
600
|
? {
|
|
599
|
-
robotCode: config
|
|
601
|
+
robotCode: resolveRobotCode(config),
|
|
600
602
|
extension: DYNAMIC_SUMMARY_EXTENSION,
|
|
601
603
|
}
|
|
602
604
|
: undefined,
|
|
603
605
|
imRobotOpenDeliverModel: !isGroup
|
|
604
606
|
? {
|
|
605
607
|
spaceType: "IM_ROBOT",
|
|
606
|
-
robotCode: config
|
|
608
|
+
robotCode: resolveRobotCode(config),
|
|
607
609
|
extension: DYNAMIC_SUMMARY_EXTENSION,
|
|
608
610
|
}
|
|
609
611
|
: undefined,
|
|
610
612
|
};
|
|
611
613
|
|
|
612
|
-
if (isGroup && !config.robotCode) {
|
|
613
|
-
log?.warn?.(
|
|
614
|
-
"[DingTalk][AICard] robotCode not configured, using clientId as fallback. " +
|
|
615
|
-
"For best compatibility, set robotCode explicitly in config.",
|
|
616
|
-
);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
614
|
log?.debug?.(
|
|
620
615
|
`[DingTalk][AICard] POST /v1.0/card/instances/createAndDeliver body=${JSON.stringify(createAndDeliverBody)}`,
|
|
621
616
|
);
|
|
@@ -895,6 +890,8 @@ function cacheCardContentByProcessQueryKey(
|
|
|
895
890
|
createdAt: Date.now(),
|
|
896
891
|
text: content,
|
|
897
892
|
messageType: "card",
|
|
893
|
+
...DEFAULT_OUTBOUND_SENDER,
|
|
894
|
+
chatType: inferConversationChatType(conversationId),
|
|
898
895
|
ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
|
|
899
896
|
topic: null,
|
|
900
897
|
quotedRef,
|
|
@@ -932,6 +929,8 @@ export function cacheCardContent(
|
|
|
932
929
|
createdAt,
|
|
933
930
|
text: content,
|
|
934
931
|
messageType: "card",
|
|
932
|
+
...DEFAULT_OUTBOUND_SENDER,
|
|
933
|
+
chatType: inferConversationChatType(conversationId),
|
|
935
934
|
ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
|
|
936
935
|
topic: null,
|
|
937
936
|
});
|