@soimy/dingtalk 3.5.1 → 3.5.3
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 +13 -24
- package/openclaw.plugin.json +695 -0
- package/package.json +12 -7
- package/src/ack-reaction-service.ts +1 -1
- package/src/auth.ts +1 -1
- package/src/card/card-action-handler.ts +1 -1
- package/src/card/card-stop-handler.ts +1 -1
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/reasoning-block-assembler.ts +157 -0
- package/src/card-callback-service.ts +1 -1
- package/src/card-draft-controller.ts +117 -6
- package/src/card-service.ts +112 -1
- package/src/channel.ts +131 -96
- package/src/command/card-stop-command.ts +4 -22
- package/src/command/inbound-command-dispatch-service.ts +464 -0
- package/src/config-schema.ts +62 -38
- package/src/config.ts +25 -3
- package/src/docs-service.ts +5 -5
- package/src/http-client.ts +20 -0
- package/src/inbound-handler.ts +475 -501
- package/src/logger-context.ts +16 -2
- package/src/media-utils.ts +166 -10
- package/src/message-utils.ts +33 -5
- package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
- package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +14 -9
- package/src/onboarding.ts +29 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +294 -28
- package/src/reply-strategy-markdown.ts +124 -19
- package/src/reply-strategy.ts +22 -2
- package/src/send-service.ts +178 -7
- package/src/targeting/agent-routing.ts +55 -32
- package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
- package/src/types.ts +60 -4
- package/src/utils.ts +190 -0
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
import {
|
|
10
10
|
finishAICard,
|
|
11
11
|
isCardInTerminalState,
|
|
12
|
+
recallAICardMessage,
|
|
12
13
|
} from "./card-service";
|
|
14
|
+
import { splitCardReasoningAnswerText } from "./card/reasoning-answer-split";
|
|
15
|
+
import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
|
|
16
|
+
import {
|
|
17
|
+
resolveCardStreamingMode,
|
|
18
|
+
shouldWarnDeprecatedCardRealTimeStreamOnce,
|
|
19
|
+
} from "./card/card-streaming-mode";
|
|
13
20
|
import { createCardDraftController } from "./card-draft-controller";
|
|
14
21
|
import { attachCardRunController } from "./card/card-run-registry";
|
|
15
22
|
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
@@ -18,59 +25,250 @@ import type { AICardInstance } from "./types";
|
|
|
18
25
|
import { AICardStatus } from "./types";
|
|
19
26
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
20
27
|
|
|
21
|
-
const
|
|
28
|
+
const EMPTY_FINAL_REPLY = "✅ Done";
|
|
29
|
+
type CardReplyLifecycleState = "open" | "final_seen" | "sealed";
|
|
22
30
|
|
|
23
31
|
export function createCardReplyStrategy(
|
|
24
32
|
ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
|
|
25
33
|
): ReplyStrategy {
|
|
26
34
|
const { card, config, log, isStopRequested } = ctx;
|
|
35
|
+
const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
|
|
36
|
+
const streamAnswerLive = mode === "answer" || mode === "all";
|
|
37
|
+
const streamThinkingLive = mode === "all";
|
|
38
|
+
let lifecycleState: CardReplyLifecycleState = "open";
|
|
39
|
+
const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
|
|
40
|
+
const isLifecycleSealed = () => lifecycleState === "sealed";
|
|
41
|
+
|
|
42
|
+
if (usedDeprecatedCardRealTimeStream) {
|
|
43
|
+
const warningKey = `dingtalk-card-streaming:${ctx.accountId || config.clientId || "default"}`;
|
|
44
|
+
if (shouldWarnDeprecatedCardRealTimeStreamOnce(warningKey)) {
|
|
45
|
+
log?.warn?.(
|
|
46
|
+
"[DingTalk][Config] `cardRealTimeStream` is deprecated. Use `cardStreamingMode` with `off` | `answer` | `all`.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
27
50
|
|
|
28
|
-
const controller = createCardDraftController({
|
|
51
|
+
const controller = createCardDraftController({
|
|
52
|
+
card,
|
|
53
|
+
log,
|
|
54
|
+
throttleMs: config.cardStreamInterval ?? 1000,
|
|
55
|
+
});
|
|
56
|
+
const reasoningAssembler = createReasoningBlockAssembler();
|
|
29
57
|
if (card.outTrackId) {
|
|
30
58
|
attachCardRunController(card.outTrackId, controller);
|
|
31
59
|
}
|
|
32
60
|
let finalTextForFallback: string | undefined;
|
|
33
61
|
let sawFinalDelivery = false;
|
|
62
|
+
let successfulMediaDeliveries = 0;
|
|
63
|
+
let failedMediaDeliveries = 0;
|
|
64
|
+
/** Tracks the latest reasoning snapshot text for non-streaming boundary flush. */
|
|
65
|
+
let latestReasoningSnapshot = "";
|
|
34
66
|
|
|
35
67
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
36
|
-
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ?
|
|
68
|
+
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
37
69
|
return controller.getRenderedContent({
|
|
38
70
|
fallbackAnswer,
|
|
39
71
|
overrideAnswer: options.preferFinalAnswer ? finalTextForFallback : undefined,
|
|
40
72
|
});
|
|
41
73
|
};
|
|
42
74
|
|
|
75
|
+
const getRawRenderedTimeline = (): string =>
|
|
76
|
+
controller.getRenderedContent({
|
|
77
|
+
fallbackAnswer: undefined,
|
|
78
|
+
overrideAnswer: undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const appendAssembledThinkingBlocks = async (blocks: string[]): Promise<void> => {
|
|
82
|
+
for (const block of blocks) {
|
|
83
|
+
if (!block.trim() || isStopRequested?.()) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
await controller.appendThinkingBlock(block);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const applyModeAwareDeliveredReasoning = async (text: string | undefined): Promise<void> => {
|
|
91
|
+
if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (streamThinkingLive) {
|
|
95
|
+
await controller.appendThinkingBlock(text);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await applyModeAwareReasoningSnapshot(text);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const applyModeAwareReasoningSnapshot = async (text: string | undefined): Promise<void> => {
|
|
102
|
+
if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (streamThinkingLive) {
|
|
106
|
+
latestReasoningSnapshot = text;
|
|
107
|
+
await controller.updateReasoning(text);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const blocks = reasoningAssembler.ingestSnapshot(text);
|
|
111
|
+
const trimmed = text.trimStart();
|
|
112
|
+
if (
|
|
113
|
+
blocks.length === 0
|
|
114
|
+
&& !trimmed.startsWith("Reasoning:")
|
|
115
|
+
) {
|
|
116
|
+
if (trimmed.startsWith("Reason:")) {
|
|
117
|
+
latestReasoningSnapshot = "";
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
latestReasoningSnapshot = text.trim();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
latestReasoningSnapshot = "";
|
|
124
|
+
await appendAssembledThinkingBlocks(blocks);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const flushPendingReasoning = async (): Promise<void> => {
|
|
128
|
+
if (streamThinkingLive) {
|
|
129
|
+
await controller.sealActiveThinking();
|
|
130
|
+
latestReasoningSnapshot = "";
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const blocks = reasoningAssembler.flushPendingAtBoundary();
|
|
134
|
+
if (latestReasoningSnapshot) {
|
|
135
|
+
blocks.push(latestReasoningSnapshot);
|
|
136
|
+
latestReasoningSnapshot = "";
|
|
137
|
+
}
|
|
138
|
+
await appendAssembledThinkingBlocks(blocks);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleAssistantBoundary = async (): Promise<void> => {
|
|
142
|
+
if (streamThinkingLive) {
|
|
143
|
+
await controller.sealActiveThinking();
|
|
144
|
+
latestReasoningSnapshot = "";
|
|
145
|
+
reasoningAssembler.reset();
|
|
146
|
+
await controller.notifyNewAssistantTurn();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const pendingReasoningBlocks = reasoningAssembler.flushPendingAtBoundary();
|
|
150
|
+
if (latestReasoningSnapshot) {
|
|
151
|
+
pendingReasoningBlocks.push(latestReasoningSnapshot);
|
|
152
|
+
latestReasoningSnapshot = "";
|
|
153
|
+
}
|
|
154
|
+
reasoningAssembler.reset();
|
|
155
|
+
const turnBoundary = controller.notifyNewAssistantTurn();
|
|
156
|
+
if (pendingReasoningBlocks.length > 0) {
|
|
157
|
+
await turnBoundary;
|
|
158
|
+
await appendAssembledThinkingBlocks(pendingReasoningBlocks);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await turnBoundary;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const normalizeDeliveredText = (
|
|
165
|
+
text: string,
|
|
166
|
+
options: { isReasoning: boolean },
|
|
167
|
+
): { reasoningText?: string; answerText?: string } => {
|
|
168
|
+
if (options.isReasoning) {
|
|
169
|
+
const split = splitCardReasoningAnswerText(text);
|
|
170
|
+
return { reasoningText: split.reasoningText || text };
|
|
171
|
+
}
|
|
172
|
+
const split = splitCardReasoningAnswerText(text);
|
|
173
|
+
return {
|
|
174
|
+
reasoningText: split.reasoningText,
|
|
175
|
+
answerText: split.answerText,
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const applyDeliveredContent = async (
|
|
180
|
+
normalized: { reasoningText?: string; answerText?: string },
|
|
181
|
+
options: {
|
|
182
|
+
routeReasoningThroughModePolicy: boolean;
|
|
183
|
+
answerHandling?: "update" | "capture" | "ignore";
|
|
184
|
+
},
|
|
185
|
+
): Promise<void> => {
|
|
186
|
+
if (normalized.reasoningText) {
|
|
187
|
+
if (options.routeReasoningThroughModePolicy) {
|
|
188
|
+
await applyModeAwareDeliveredReasoning(normalized.reasoningText);
|
|
189
|
+
} else {
|
|
190
|
+
// Conservative local split fallback: keep existing behavior for mixed payloads.
|
|
191
|
+
await controller.appendThinkingBlock(normalized.reasoningText);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (normalized.answerText && options.answerHandling !== "ignore") {
|
|
195
|
+
if (options.answerHandling === "capture") {
|
|
196
|
+
finalTextForFallback = normalized.answerText;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
await controller.updateAnswer(normalized.answerText);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const handleAnswerSnapshot = async (text: string | undefined): Promise<void> => {
|
|
204
|
+
if (!shouldAcceptAnswerSnapshot() || isStopRequested?.()) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!text) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
await controller.updateAnswer(text, { stream: streamAnswerLive });
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const applySplitTextToTimeline = async (
|
|
214
|
+
text: string,
|
|
215
|
+
options: { answerHandling?: "update" | "capture" | "ignore" } = {},
|
|
216
|
+
) => {
|
|
217
|
+
const normalized = normalizeDeliveredText(text, { isReasoning: false });
|
|
218
|
+
await applyDeliveredContent(normalized, {
|
|
219
|
+
routeReasoningThroughModePolicy: true,
|
|
220
|
+
answerHandling: options.answerHandling ?? "update",
|
|
221
|
+
});
|
|
222
|
+
return normalized;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const deliverMediaWithTracking = async (
|
|
226
|
+
mediaUrls: string[],
|
|
227
|
+
options: { audioAsVoice?: boolean },
|
|
228
|
+
): Promise<void> => {
|
|
229
|
+
if (mediaUrls.length === 0) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await ctx.deliverMedia(mediaUrls, options);
|
|
234
|
+
successfulMediaDeliveries += mediaUrls.length;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
failedMediaDeliveries += mediaUrls.length;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
43
241
|
return {
|
|
44
242
|
getReplyOptions(): ReplyOptions {
|
|
45
243
|
return {
|
|
46
|
-
// Card mode
|
|
47
|
-
//
|
|
48
|
-
disableBlockStreaming: true,
|
|
244
|
+
// Card mode keeps runtime block streaming disabled, but still consumes
|
|
245
|
+
// reasoning blocks through explicit callbacks and delivery metadata.
|
|
246
|
+
disableBlockStreaming: ctx.disableBlockStreaming ?? true,
|
|
49
247
|
|
|
50
248
|
onAssistantMessageStart: async () => {
|
|
51
|
-
if (isStopRequested?.()) {
|
|
249
|
+
if (isLifecycleSealed() || isStopRequested?.()) {
|
|
52
250
|
return;
|
|
53
251
|
}
|
|
54
|
-
await
|
|
252
|
+
await handleAssistantBoundary();
|
|
55
253
|
},
|
|
56
254
|
|
|
57
|
-
onPartialReply:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
await controller.updateAnswer(payload.text);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
: undefined,
|
|
255
|
+
onPartialReply: async (payload) => {
|
|
256
|
+
await handleAnswerSnapshot(payload.text);
|
|
257
|
+
},
|
|
64
258
|
|
|
65
259
|
onReasoningStream: async (payload) => {
|
|
66
|
-
if (
|
|
67
|
-
|
|
260
|
+
if (isLifecycleSealed() || isStopRequested?.()) {
|
|
261
|
+
return;
|
|
68
262
|
}
|
|
263
|
+
await applyModeAwareReasoningSnapshot(payload.text);
|
|
69
264
|
},
|
|
70
265
|
};
|
|
71
266
|
},
|
|
72
267
|
|
|
73
268
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
269
|
+
if (isLifecycleSealed()) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
74
272
|
const textToSend = payload.text;
|
|
75
273
|
|
|
76
274
|
// Empty-payload guard — card final is an exception (e.g. file-only response).
|
|
@@ -82,7 +280,12 @@ export function createCardReplyStrategy(
|
|
|
82
280
|
|
|
83
281
|
// ---- final: defer to finalize, just save text ----
|
|
84
282
|
if (payload.kind === "final") {
|
|
85
|
-
|
|
283
|
+
const isFirstFinalDelivery = !sawFinalDelivery;
|
|
284
|
+
lifecycleState = "final_seen";
|
|
285
|
+
await flushPendingReasoning();
|
|
286
|
+
if (isFirstFinalDelivery) {
|
|
287
|
+
sawFinalDelivery = true;
|
|
288
|
+
}
|
|
86
289
|
log?.info?.(
|
|
87
290
|
`[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
|
|
88
291
|
`textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
|
|
@@ -91,11 +294,22 @@ export function createCardReplyStrategy(
|
|
|
91
294
|
`lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
|
|
92
295
|
);
|
|
93
296
|
if (payload.mediaUrls.length > 0) {
|
|
94
|
-
await
|
|
297
|
+
await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
|
|
95
298
|
}
|
|
96
299
|
const rawFinalText = typeof textToSend === "string" ? textToSend : "";
|
|
97
300
|
if (rawFinalText) {
|
|
98
|
-
|
|
301
|
+
if (payload.isReasoning === true) {
|
|
302
|
+
await applyModeAwareReasoningSnapshot(rawFinalText);
|
|
303
|
+
await flushPendingReasoning();
|
|
304
|
+
} else {
|
|
305
|
+
const normalizedFinal = await applySplitTextToTimeline(rawFinalText, {
|
|
306
|
+
answerHandling: "capture",
|
|
307
|
+
});
|
|
308
|
+
if (isFirstFinalDelivery && !normalizedFinal.answerText && !normalizedFinal.reasoningText) {
|
|
309
|
+
finalTextForFallback = rawFinalText;
|
|
310
|
+
}
|
|
311
|
+
await flushPendingReasoning();
|
|
312
|
+
}
|
|
99
313
|
}
|
|
100
314
|
return;
|
|
101
315
|
}
|
|
@@ -106,16 +320,36 @@ export function createCardReplyStrategy(
|
|
|
106
320
|
log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
|
|
107
321
|
return;
|
|
108
322
|
}
|
|
323
|
+
await flushPendingReasoning();
|
|
109
324
|
log?.info?.(
|
|
110
325
|
`[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
|
|
111
326
|
);
|
|
112
|
-
|
|
327
|
+
if (lifecycleState === "final_seen") {
|
|
328
|
+
await controller.appendToolBeforeCurrentAnswer(textToSend ?? "");
|
|
329
|
+
} else {
|
|
330
|
+
await controller.appendTool(textToSend ?? "");
|
|
331
|
+
}
|
|
113
332
|
return;
|
|
114
333
|
}
|
|
115
334
|
|
|
116
|
-
|
|
335
|
+
const isReasoningBlock = payload.isReasoning === true;
|
|
336
|
+
if (typeof textToSend === "string" && textToSend.trim()) {
|
|
337
|
+
if (isReasoningBlock) {
|
|
338
|
+
const normalized = normalizeDeliveredText(textToSend, { isReasoning: true });
|
|
339
|
+
await applyDeliveredContent(normalized, {
|
|
340
|
+
routeReasoningThroughModePolicy: true,
|
|
341
|
+
answerHandling: "ignore",
|
|
342
|
+
});
|
|
343
|
+
} else {
|
|
344
|
+
await applySplitTextToTimeline(textToSend, {
|
|
345
|
+
answerHandling: lifecycleState === "open" ? "update" : "capture",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
117
351
|
if (payload.mediaUrls.length > 0) {
|
|
118
|
-
await
|
|
352
|
+
await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
|
|
119
353
|
}
|
|
120
354
|
},
|
|
121
355
|
|
|
@@ -131,16 +365,19 @@ export function createCardReplyStrategy(
|
|
|
131
365
|
|
|
132
366
|
if (isStopRequested?.()) {
|
|
133
367
|
log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
|
|
368
|
+
lifecycleState = "sealed";
|
|
134
369
|
return;
|
|
135
370
|
}
|
|
136
371
|
|
|
137
372
|
if (card.state === AICardStatus.FINISHED) {
|
|
138
373
|
log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
|
|
374
|
+
lifecycleState = "sealed";
|
|
139
375
|
return;
|
|
140
376
|
}
|
|
141
377
|
|
|
142
378
|
if (card.state === AICardStatus.STOPPED) {
|
|
143
379
|
log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
|
|
380
|
+
lifecycleState = "sealed";
|
|
144
381
|
return;
|
|
145
382
|
}
|
|
146
383
|
|
|
@@ -168,23 +405,49 @@ export function createCardReplyStrategy(
|
|
|
168
405
|
} else {
|
|
169
406
|
log?.debug?.("[DingTalk] Card failed but no content to fallback with");
|
|
170
407
|
}
|
|
408
|
+
lifecycleState = "sealed";
|
|
171
409
|
return;
|
|
172
410
|
}
|
|
173
411
|
|
|
174
412
|
// Normal finalize.
|
|
175
413
|
try {
|
|
414
|
+
await flushPendingReasoning();
|
|
176
415
|
await controller.flush();
|
|
177
416
|
await controller.waitForInFlight();
|
|
178
|
-
const
|
|
417
|
+
const renderedTimeline = getRenderedTimeline({ preferFinalAnswer: true });
|
|
418
|
+
const rawRenderedTimeline = getRawRenderedTimeline();
|
|
419
|
+
const hasRenderedCardContent = Boolean(rawRenderedTimeline.trim());
|
|
420
|
+
const hasMeaningfulCardContent = hasRenderedCardContent
|
|
421
|
+
|| Boolean((finalTextForFallback || "").trim())
|
|
422
|
+
|| Boolean((controller.getFinalAnswerContent() || "").trim())
|
|
423
|
+
|| Boolean((controller.getLastAnswerContent() || "").trim())
|
|
424
|
+
|| Boolean((controller.getLastContent() || "").trim());
|
|
425
|
+
const shouldRecallEmptyCard =
|
|
426
|
+
successfulMediaDeliveries > 0
|
|
427
|
+
&& failedMediaDeliveries === 0
|
|
428
|
+
&& !hasMeaningfulCardContent;
|
|
429
|
+
if (shouldRecallEmptyCard) {
|
|
430
|
+
controller.stop();
|
|
431
|
+
log?.info?.(
|
|
432
|
+
`[DingTalk][Finalize] Attempting to recall empty card after successful media delivery ` +
|
|
433
|
+
`mediaCount=${successfulMediaDeliveries} conversationId=${card.conversationId}`,
|
|
434
|
+
);
|
|
435
|
+
if (await recallAICardMessage(card, log)) {
|
|
436
|
+
lifecycleState = "sealed";
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const finalText = renderedTimeline || EMPTY_FINAL_REPLY;
|
|
179
441
|
controller.stop();
|
|
180
442
|
log?.info?.(
|
|
181
443
|
`[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
|
|
182
|
-
`source=${controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
444
|
+
`source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
183
445
|
`preview="${finalText.slice(0, 120)}"`,
|
|
184
446
|
);
|
|
185
447
|
await finishAICard(card, finalText, log, {
|
|
186
448
|
quotedRef: ctx.replyQuotedRef,
|
|
187
449
|
});
|
|
450
|
+
lifecycleState = "sealed";
|
|
188
451
|
|
|
189
452
|
// In group chats, send a lightweight @mention via session webhook
|
|
190
453
|
// so the sender gets a notification — card API doesn't support @mention.
|
|
@@ -210,10 +473,13 @@ export function createCardReplyStrategy(
|
|
|
210
473
|
card.state = AICardStatus.FAILED;
|
|
211
474
|
card.lastUpdated = Date.now();
|
|
212
475
|
}
|
|
476
|
+
} finally {
|
|
477
|
+
lifecycleState = "sealed";
|
|
213
478
|
}
|
|
214
479
|
},
|
|
215
480
|
|
|
216
481
|
async abort(_error: Error): Promise<void> {
|
|
482
|
+
lifecycleState = "sealed";
|
|
217
483
|
if (!isCardInTerminalState(card.state)) {
|
|
218
484
|
controller.stop();
|
|
219
485
|
await controller.waitForInFlight();
|
|
@@ -228,9 +494,9 @@ export function createCardReplyStrategy(
|
|
|
228
494
|
},
|
|
229
495
|
|
|
230
496
|
getFinalText(): string | undefined {
|
|
231
|
-
return
|
|
232
|
-
||
|
|
233
|
-
|| (sawFinalDelivery ?
|
|
497
|
+
return finalTextForFallback
|
|
498
|
+
|| controller.getFinalAnswerContent()
|
|
499
|
+
|| (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
234
500
|
},
|
|
235
501
|
};
|
|
236
502
|
}
|
|
@@ -1,47 +1,152 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown / text reply strategy.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* DingTalk cannot edit prior messages in place, so markdown mode emits
|
|
5
|
+
* incremental answer tails from dispatcher-delivered block/final payloads.
|
|
6
|
+
* Reasoning display is intentionally unsupported on DingTalk markdown.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
9
10
|
import { sendMessage } from "./send-service";
|
|
10
11
|
|
|
12
|
+
const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
|
|
13
|
+
|
|
14
|
+
function renderQuotedSegment(text: string): string {
|
|
15
|
+
return text
|
|
16
|
+
.split("\n")
|
|
17
|
+
.map((line) => line.length > 0 ? `> ${line}` : ">")
|
|
18
|
+
.join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function computeIncrementalSuffix(previous: string, next: string): string {
|
|
22
|
+
const prev = previous || "";
|
|
23
|
+
const current = next || "";
|
|
24
|
+
if (!current.trim()) {
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
if (!prev) {
|
|
28
|
+
return current;
|
|
29
|
+
}
|
|
30
|
+
if (!current.startsWith(prev)) {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
const suffix = current.slice(prev.length);
|
|
34
|
+
return suffix.trim() ? suffix : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function computeSharedPrefixTail(previous: string, next: string): string {
|
|
38
|
+
const prev = previous || "";
|
|
39
|
+
const current = next || "";
|
|
40
|
+
if (!prev || !current.trim()) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const limit = Math.min(prev.length, current.length);
|
|
44
|
+
let sharedPrefixLength = 0;
|
|
45
|
+
while (sharedPrefixLength < limit && prev[sharedPrefixLength] === current[sharedPrefixLength]) {
|
|
46
|
+
sharedPrefixLength += 1;
|
|
47
|
+
}
|
|
48
|
+
if (sharedPrefixLength === 0) {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
const suffix = current.slice(sharedPrefixLength);
|
|
52
|
+
return suffix.trim() ? suffix : "";
|
|
53
|
+
}
|
|
54
|
+
|
|
11
55
|
export function createMarkdownReplyStrategy(
|
|
12
56
|
ctx: ReplyStrategyContext,
|
|
13
57
|
): ReplyStrategy {
|
|
14
58
|
let finalText: string | undefined;
|
|
59
|
+
let activeAnswerText = "";
|
|
60
|
+
let lastSentAnswerText = "";
|
|
61
|
+
let sentVisibleContent = false;
|
|
62
|
+
|
|
63
|
+
const sendMarkdownSegment = async (text: string): Promise<void> => {
|
|
64
|
+
if (!text.trim()) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const sendResult = await sendMessage(ctx.config, ctx.to, text, {
|
|
68
|
+
sessionWebhook: ctx.sessionWebhook,
|
|
69
|
+
atUserId: !ctx.isDirect ? ctx.senderId : null,
|
|
70
|
+
log: ctx.log,
|
|
71
|
+
accountId: ctx.accountId,
|
|
72
|
+
storePath: ctx.storePath,
|
|
73
|
+
conversationId: ctx.groupId,
|
|
74
|
+
quotedRef: ctx.replyQuotedRef,
|
|
75
|
+
});
|
|
76
|
+
if (!sendResult.ok) {
|
|
77
|
+
throw new Error(sendResult.error || "Reply send failed");
|
|
78
|
+
}
|
|
79
|
+
sentVisibleContent = true;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const emitAnswerSuffix = async (text: string | undefined): Promise<void> => {
|
|
83
|
+
const current = typeof text === "string" ? text : "";
|
|
84
|
+
if (current.length > 0) {
|
|
85
|
+
activeAnswerText = current;
|
|
86
|
+
finalText = current;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const suffix = computeIncrementalSuffix(lastSentAnswerText, current);
|
|
90
|
+
if (suffix) {
|
|
91
|
+
await sendMarkdownSegment(suffix);
|
|
92
|
+
lastSentAnswerText = current;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (current.trim() && lastSentAnswerText && !current.startsWith(lastSentAnswerText)) {
|
|
97
|
+
const suffix = computeSharedPrefixTail(lastSentAnswerText, current);
|
|
98
|
+
ctx.log?.warn?.(
|
|
99
|
+
`[DingTalk][Markdown] answer prefix drift detected; falling back to shared-prefix tail ` +
|
|
100
|
+
`prevLen=${lastSentAnswerText.length} currentLen=${current.length}`,
|
|
101
|
+
);
|
|
102
|
+
lastSentAnswerText = "";
|
|
103
|
+
if (suffix) {
|
|
104
|
+
await sendMarkdownSegment(suffix);
|
|
105
|
+
lastSentAnswerText = current;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await sendMarkdownSegment(current);
|
|
109
|
+
lastSentAnswerText = current;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
15
112
|
|
|
16
113
|
return {
|
|
17
114
|
getReplyOptions(): ReplyOptions {
|
|
18
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
disableBlockStreaming: ctx.disableBlockStreaming === true,
|
|
117
|
+
};
|
|
19
118
|
},
|
|
20
119
|
|
|
21
120
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
22
121
|
if (payload.mediaUrls.length > 0) {
|
|
23
|
-
await ctx.deliverMedia(payload.mediaUrls);
|
|
122
|
+
await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
|
|
123
|
+
sentVisibleContent = true;
|
|
24
124
|
}
|
|
25
125
|
|
|
26
|
-
if (payload.kind === "
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
atUserId: !ctx.isDirect ? ctx.senderId : null,
|
|
31
|
-
log: ctx.log,
|
|
32
|
-
accountId: ctx.accountId,
|
|
33
|
-
storePath: ctx.storePath,
|
|
34
|
-
conversationId: ctx.groupId,
|
|
35
|
-
quotedRef: ctx.replyQuotedRef,
|
|
36
|
-
});
|
|
37
|
-
if (!sendResult.ok) {
|
|
38
|
-
throw new Error(sendResult.error || "Reply send failed");
|
|
126
|
+
if (payload.kind === "tool") {
|
|
127
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
128
|
+
if (!text.trim()) {
|
|
129
|
+
return;
|
|
39
130
|
}
|
|
131
|
+
await sendMarkdownSegment(renderQuotedSegment(text));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
(payload.kind === "block" || payload.kind === "final")
|
|
137
|
+
&& typeof payload.text === "string"
|
|
138
|
+
) {
|
|
139
|
+
await emitAnswerSuffix(payload.text);
|
|
40
140
|
}
|
|
41
141
|
},
|
|
42
142
|
|
|
43
143
|
async finalize(): Promise<void> {
|
|
44
|
-
|
|
144
|
+
if (sentVisibleContent) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
finalText = EMPTY_FINAL_FALLBACK_TEXT;
|
|
148
|
+
activeAnswerText = EMPTY_FINAL_FALLBACK_TEXT;
|
|
149
|
+
await sendMarkdownSegment(EMPTY_FINAL_FALLBACK_TEXT);
|
|
45
150
|
},
|
|
46
151
|
|
|
47
152
|
async abort(): Promise<void> {
|
|
@@ -49,7 +154,7 @@ export function createMarkdownReplyStrategy(
|
|
|
49
154
|
},
|
|
50
155
|
|
|
51
156
|
getFinalText(): string | undefined {
|
|
52
|
-
return finalText;
|
|
157
|
+
return finalText || activeAnswerText || undefined;
|
|
53
158
|
},
|
|
54
159
|
};
|
|
55
160
|
}
|
package/src/reply-strategy.ts
CHANGED
|
@@ -12,10 +12,22 @@ import { createMarkdownReplyStrategy } from "./reply-strategy-markdown";
|
|
|
12
12
|
|
|
13
13
|
// ---- Public types ------------------------------------------------
|
|
14
14
|
|
|
15
|
+
type InternalReplyStrategyConfig = DingTalkConfig & {
|
|
16
|
+
/** @deprecated Internal compatibility only. Removed from public config surface. */
|
|
17
|
+
cardStreamReasoning?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
15
20
|
export interface DeliverPayload {
|
|
16
21
|
text?: string;
|
|
17
22
|
mediaUrls: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Shared reply-runtime voice hint. Strategies forward this unchanged into the
|
|
25
|
+
* channel media delivery helper; inbound-handler is responsible for bridging
|
|
26
|
+
* legacy aliases (for example `asVoice`) into this single field.
|
|
27
|
+
*/
|
|
28
|
+
audioAsVoice?: boolean;
|
|
18
29
|
kind: "block" | "final" | "tool";
|
|
30
|
+
isReasoning?: boolean;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
export interface ReplyOptions {
|
|
@@ -44,17 +56,25 @@ export interface ReplyStrategy {
|
|
|
44
56
|
|
|
45
57
|
/** Shared context passed to every strategy implementation. */
|
|
46
58
|
export interface ReplyStrategyContext {
|
|
47
|
-
config:
|
|
59
|
+
config: InternalReplyStrategyConfig;
|
|
48
60
|
to: string;
|
|
49
61
|
sessionWebhook: string;
|
|
50
62
|
senderId: string;
|
|
51
63
|
isDirect: boolean;
|
|
52
64
|
accountId: string;
|
|
53
65
|
storePath: string;
|
|
66
|
+
disableBlockStreaming?: boolean;
|
|
67
|
+
sessionKey?: string;
|
|
68
|
+
sessionAgentId?: string;
|
|
54
69
|
groupId?: string;
|
|
55
70
|
log?: Logger;
|
|
56
71
|
replyQuotedRef?: QuotedRef;
|
|
57
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Channel-level media delivery hook. The `audioAsVoice` option is the same
|
|
74
|
+
* shared voice semantic carried on DeliverPayload, not a second independent
|
|
75
|
+
* config knob.
|
|
76
|
+
*/
|
|
77
|
+
deliverMedia: (urls: string[], options?: { audioAsVoice?: boolean }) => Promise<void>;
|
|
58
78
|
isStopRequested?: () => boolean;
|
|
59
79
|
}
|
|
60
80
|
|