@soimy/dingtalk 3.5.2 → 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 +6 -23
- package/openclaw.plugin.json +695 -0
- package/package.json +5 -5
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card-draft-controller.ts +85 -6
- package/src/card-service.ts +111 -0
- package/src/channel.ts +60 -41
- package/src/config-schema.ts +62 -38
- package/src/config.ts +25 -3
- package/src/inbound-handler.ts +355 -38
- package/src/media-utils.ts +163 -7
- package/src/message-utils.ts +33 -5
- package/src/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +29 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +247 -32
- package/src/reply-strategy-markdown.ts +1 -1
- package/src/reply-strategy.ts +18 -2
- package/src/send-service.ts +110 -4
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +60 -4
- package/src/utils.ts +25 -0
|
@@ -9,8 +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";
|
|
13
15
|
import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
|
|
16
|
+
import {
|
|
17
|
+
resolveCardStreamingMode,
|
|
18
|
+
shouldWarnDeprecatedCardRealTimeStreamOnce,
|
|
19
|
+
} from "./card/card-streaming-mode";
|
|
14
20
|
import { createCardDraftController } from "./card-draft-controller";
|
|
15
21
|
import { attachCardRunController } from "./card/card-run-registry";
|
|
16
22
|
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
@@ -20,19 +26,43 @@ import { AICardStatus } from "./types";
|
|
|
20
26
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
21
27
|
|
|
22
28
|
const EMPTY_FINAL_REPLY = "✅ Done";
|
|
29
|
+
type CardReplyLifecycleState = "open" | "final_seen" | "sealed";
|
|
23
30
|
|
|
24
31
|
export function createCardReplyStrategy(
|
|
25
32
|
ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
|
|
26
33
|
): ReplyStrategy {
|
|
27
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
|
+
}
|
|
28
50
|
|
|
29
|
-
const controller = createCardDraftController({
|
|
51
|
+
const controller = createCardDraftController({
|
|
52
|
+
card,
|
|
53
|
+
log,
|
|
54
|
+
throttleMs: config.cardStreamInterval ?? 1000,
|
|
55
|
+
});
|
|
30
56
|
const reasoningAssembler = createReasoningBlockAssembler();
|
|
31
57
|
if (card.outTrackId) {
|
|
32
58
|
attachCardRunController(card.outTrackId, controller);
|
|
33
59
|
}
|
|
34
60
|
let finalTextForFallback: string | undefined;
|
|
35
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 = "";
|
|
36
66
|
|
|
37
67
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
38
68
|
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
@@ -42,6 +72,12 @@ export function createCardReplyStrategy(
|
|
|
42
72
|
});
|
|
43
73
|
};
|
|
44
74
|
|
|
75
|
+
const getRawRenderedTimeline = (): string =>
|
|
76
|
+
controller.getRenderedContent({
|
|
77
|
+
fallbackAnswer: undefined,
|
|
78
|
+
overrideAnswer: undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
45
81
|
const appendAssembledThinkingBlocks = async (blocks: string[]): Promise<void> => {
|
|
46
82
|
for (const block of blocks) {
|
|
47
83
|
if (!block.trim() || isStopRequested?.()) {
|
|
@@ -51,25 +87,157 @@ export function createCardReplyStrategy(
|
|
|
51
87
|
}
|
|
52
88
|
};
|
|
53
89
|
|
|
54
|
-
const
|
|
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
|
+
}
|
|
55
110
|
const blocks = reasoningAssembler.ingestSnapshot(text);
|
|
111
|
+
const trimmed = text.trimStart();
|
|
56
112
|
if (
|
|
57
113
|
blocks.length === 0
|
|
58
|
-
&&
|
|
59
|
-
&& text.trim()
|
|
60
|
-
&& !text.trimStart().startsWith("Reasoning:")
|
|
114
|
+
&& !trimmed.startsWith("Reasoning:")
|
|
61
115
|
) {
|
|
62
|
-
|
|
116
|
+
if (trimmed.startsWith("Reason:")) {
|
|
117
|
+
latestReasoningSnapshot = "";
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
latestReasoningSnapshot = text.trim();
|
|
63
121
|
return;
|
|
64
122
|
}
|
|
123
|
+
latestReasoningSnapshot = "";
|
|
65
124
|
await appendAssembledThinkingBlocks(blocks);
|
|
66
125
|
};
|
|
67
126
|
|
|
68
127
|
const flushPendingReasoning = async (): Promise<void> => {
|
|
128
|
+
if (streamThinkingLive) {
|
|
129
|
+
await controller.sealActiveThinking();
|
|
130
|
+
latestReasoningSnapshot = "";
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
69
133
|
const blocks = reasoningAssembler.flushPendingAtBoundary();
|
|
134
|
+
if (latestReasoningSnapshot) {
|
|
135
|
+
blocks.push(latestReasoningSnapshot);
|
|
136
|
+
latestReasoningSnapshot = "";
|
|
137
|
+
}
|
|
70
138
|
await appendAssembledThinkingBlocks(blocks);
|
|
71
139
|
};
|
|
72
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
|
+
|
|
73
241
|
return {
|
|
74
242
|
getReplyOptions(): ReplyOptions {
|
|
75
243
|
return {
|
|
@@ -78,37 +246,29 @@ export function createCardReplyStrategy(
|
|
|
78
246
|
disableBlockStreaming: ctx.disableBlockStreaming ?? true,
|
|
79
247
|
|
|
80
248
|
onAssistantMessageStart: async () => {
|
|
81
|
-
if (isStopRequested?.()) {
|
|
249
|
+
if (isLifecycleSealed() || isStopRequested?.()) {
|
|
82
250
|
return;
|
|
83
251
|
}
|
|
84
|
-
|
|
85
|
-
reasoningAssembler.reset();
|
|
86
|
-
const turnBoundary = controller.notifyNewAssistantTurn();
|
|
87
|
-
if (pendingReasoningBlocks.length > 0) {
|
|
88
|
-
await turnBoundary;
|
|
89
|
-
await appendAssembledThinkingBlocks(pendingReasoningBlocks);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
await turnBoundary;
|
|
252
|
+
await handleAssistantBoundary();
|
|
93
253
|
},
|
|
94
254
|
|
|
95
|
-
onPartialReply:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await controller.updateAnswer(payload.text);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
: undefined,
|
|
255
|
+
onPartialReply: async (payload) => {
|
|
256
|
+
await handleAnswerSnapshot(payload.text);
|
|
257
|
+
},
|
|
102
258
|
|
|
103
259
|
onReasoningStream: async (payload) => {
|
|
104
|
-
if (
|
|
105
|
-
|
|
260
|
+
if (isLifecycleSealed() || isStopRequested?.()) {
|
|
261
|
+
return;
|
|
106
262
|
}
|
|
263
|
+
await applyModeAwareReasoningSnapshot(payload.text);
|
|
107
264
|
},
|
|
108
265
|
};
|
|
109
266
|
},
|
|
110
267
|
|
|
111
268
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
269
|
+
if (isLifecycleSealed()) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
112
272
|
const textToSend = payload.text;
|
|
113
273
|
|
|
114
274
|
// Empty-payload guard — card final is an exception (e.g. file-only response).
|
|
@@ -120,8 +280,12 @@ export function createCardReplyStrategy(
|
|
|
120
280
|
|
|
121
281
|
// ---- final: defer to finalize, just save text ----
|
|
122
282
|
if (payload.kind === "final") {
|
|
283
|
+
const isFirstFinalDelivery = !sawFinalDelivery;
|
|
284
|
+
lifecycleState = "final_seen";
|
|
123
285
|
await flushPendingReasoning();
|
|
124
|
-
|
|
286
|
+
if (isFirstFinalDelivery) {
|
|
287
|
+
sawFinalDelivery = true;
|
|
288
|
+
}
|
|
125
289
|
log?.info?.(
|
|
126
290
|
`[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
|
|
127
291
|
`textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
|
|
@@ -130,11 +294,22 @@ export function createCardReplyStrategy(
|
|
|
130
294
|
`lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
|
|
131
295
|
);
|
|
132
296
|
if (payload.mediaUrls.length > 0) {
|
|
133
|
-
await
|
|
297
|
+
await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
|
|
134
298
|
}
|
|
135
299
|
const rawFinalText = typeof textToSend === "string" ? textToSend : "";
|
|
136
300
|
if (rawFinalText) {
|
|
137
|
-
|
|
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
|
+
}
|
|
138
313
|
}
|
|
139
314
|
return;
|
|
140
315
|
}
|
|
@@ -149,22 +324,32 @@ export function createCardReplyStrategy(
|
|
|
149
324
|
log?.info?.(
|
|
150
325
|
`[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
|
|
151
326
|
);
|
|
152
|
-
|
|
327
|
+
if (lifecycleState === "final_seen") {
|
|
328
|
+
await controller.appendToolBeforeCurrentAnswer(textToSend ?? "");
|
|
329
|
+
} else {
|
|
330
|
+
await controller.appendTool(textToSend ?? "");
|
|
331
|
+
}
|
|
153
332
|
return;
|
|
154
333
|
}
|
|
155
334
|
|
|
156
335
|
const isReasoningBlock = payload.isReasoning === true;
|
|
157
336
|
if (typeof textToSend === "string" && textToSend.trim()) {
|
|
158
337
|
if (isReasoningBlock) {
|
|
159
|
-
|
|
338
|
+
const normalized = normalizeDeliveredText(textToSend, { isReasoning: true });
|
|
339
|
+
await applyDeliveredContent(normalized, {
|
|
340
|
+
routeReasoningThroughModePolicy: true,
|
|
341
|
+
answerHandling: "ignore",
|
|
342
|
+
});
|
|
160
343
|
} else {
|
|
161
|
-
await
|
|
344
|
+
await applySplitTextToTimeline(textToSend, {
|
|
345
|
+
answerHandling: lifecycleState === "open" ? "update" : "capture",
|
|
346
|
+
});
|
|
162
347
|
}
|
|
163
348
|
}
|
|
164
349
|
|
|
165
350
|
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
166
351
|
if (payload.mediaUrls.length > 0) {
|
|
167
|
-
await
|
|
352
|
+
await deliverMediaWithTracking(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
|
|
168
353
|
}
|
|
169
354
|
},
|
|
170
355
|
|
|
@@ -180,16 +365,19 @@ export function createCardReplyStrategy(
|
|
|
180
365
|
|
|
181
366
|
if (isStopRequested?.()) {
|
|
182
367
|
log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
|
|
368
|
+
lifecycleState = "sealed";
|
|
183
369
|
return;
|
|
184
370
|
}
|
|
185
371
|
|
|
186
372
|
if (card.state === AICardStatus.FINISHED) {
|
|
187
373
|
log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
|
|
374
|
+
lifecycleState = "sealed";
|
|
188
375
|
return;
|
|
189
376
|
}
|
|
190
377
|
|
|
191
378
|
if (card.state === AICardStatus.STOPPED) {
|
|
192
379
|
log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
|
|
380
|
+
lifecycleState = "sealed";
|
|
193
381
|
return;
|
|
194
382
|
}
|
|
195
383
|
|
|
@@ -217,6 +405,7 @@ export function createCardReplyStrategy(
|
|
|
217
405
|
} else {
|
|
218
406
|
log?.debug?.("[DingTalk] Card failed but no content to fallback with");
|
|
219
407
|
}
|
|
408
|
+
lifecycleState = "sealed";
|
|
220
409
|
return;
|
|
221
410
|
}
|
|
222
411
|
|
|
@@ -226,6 +415,28 @@ export function createCardReplyStrategy(
|
|
|
226
415
|
await controller.flush();
|
|
227
416
|
await controller.waitForInFlight();
|
|
228
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
|
+
}
|
|
229
440
|
const finalText = renderedTimeline || EMPTY_FINAL_REPLY;
|
|
230
441
|
controller.stop();
|
|
231
442
|
log?.info?.(
|
|
@@ -236,6 +447,7 @@ export function createCardReplyStrategy(
|
|
|
236
447
|
await finishAICard(card, finalText, log, {
|
|
237
448
|
quotedRef: ctx.replyQuotedRef,
|
|
238
449
|
});
|
|
450
|
+
lifecycleState = "sealed";
|
|
239
451
|
|
|
240
452
|
// In group chats, send a lightweight @mention via session webhook
|
|
241
453
|
// so the sender gets a notification — card API doesn't support @mention.
|
|
@@ -261,10 +473,13 @@ export function createCardReplyStrategy(
|
|
|
261
473
|
card.state = AICardStatus.FAILED;
|
|
262
474
|
card.lastUpdated = Date.now();
|
|
263
475
|
}
|
|
476
|
+
} finally {
|
|
477
|
+
lifecycleState = "sealed";
|
|
264
478
|
}
|
|
265
479
|
},
|
|
266
480
|
|
|
267
481
|
async abort(_error: Error): Promise<void> {
|
|
482
|
+
lifecycleState = "sealed";
|
|
268
483
|
if (!isCardInTerminalState(card.state)) {
|
|
269
484
|
controller.stop();
|
|
270
485
|
await controller.waitForInFlight();
|
|
@@ -119,7 +119,7 @@ export function createMarkdownReplyStrategy(
|
|
|
119
119
|
|
|
120
120
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
121
121
|
if (payload.mediaUrls.length > 0) {
|
|
122
|
-
await ctx.deliverMedia(payload.mediaUrls);
|
|
122
|
+
await ctx.deliverMedia(payload.mediaUrls, { audioAsVoice: payload.audioAsVoice });
|
|
123
123
|
sentVisibleContent = true;
|
|
124
124
|
}
|
|
125
125
|
|
package/src/reply-strategy.ts
CHANGED
|
@@ -12,9 +12,20 @@ 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";
|
|
19
30
|
isReasoning?: boolean;
|
|
20
31
|
}
|
|
@@ -45,7 +56,7 @@ export interface ReplyStrategy {
|
|
|
45
56
|
|
|
46
57
|
/** Shared context passed to every strategy implementation. */
|
|
47
58
|
export interface ReplyStrategyContext {
|
|
48
|
-
config:
|
|
59
|
+
config: InternalReplyStrategyConfig;
|
|
49
60
|
to: string;
|
|
50
61
|
sessionWebhook: string;
|
|
51
62
|
senderId: string;
|
|
@@ -58,7 +69,12 @@ export interface ReplyStrategyContext {
|
|
|
58
69
|
groupId?: string;
|
|
59
70
|
log?: Logger;
|
|
60
71
|
replyQuotedRef?: QuotedRef;
|
|
61
|
-
|
|
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>;
|
|
62
78
|
isStopRequested?: () => boolean;
|
|
63
79
|
}
|
|
64
80
|
|
package/src/send-service.ts
CHANGED
|
@@ -212,6 +212,14 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
const payload = data as Record<string, unknown>;
|
|
215
|
+
const errcode = payload.errcode;
|
|
216
|
+
if (typeof errcode === "number" && Number.isFinite(errcode)) {
|
|
217
|
+
return String(errcode);
|
|
218
|
+
}
|
|
219
|
+
if (typeof errcode === "string" && errcode.trim()) {
|
|
220
|
+
return errcode.trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
215
223
|
const code = payload.code;
|
|
216
224
|
if (typeof code === "string" && code.trim()) {
|
|
217
225
|
return code.trim();
|
|
@@ -225,6 +233,62 @@ function extractErrorCodeFromResponseData(data: unknown): string | null {
|
|
|
225
233
|
return null;
|
|
226
234
|
}
|
|
227
235
|
|
|
236
|
+
function summarizeSessionWebhookResponse(data: unknown): string {
|
|
237
|
+
if (!data || typeof data !== "object") {
|
|
238
|
+
return `type=${typeof data}`;
|
|
239
|
+
}
|
|
240
|
+
const payload = data as Record<string, unknown>;
|
|
241
|
+
const code = extractErrorCodeFromResponseData(payload) || "(none)";
|
|
242
|
+
const message = firstTrimmedString(
|
|
243
|
+
payload.message,
|
|
244
|
+
payload.errmsg,
|
|
245
|
+
payload.msg,
|
|
246
|
+
payload.errorMessage,
|
|
247
|
+
) || "(none)";
|
|
248
|
+
const success =
|
|
249
|
+
typeof payload.success === "boolean"
|
|
250
|
+
? String(payload.success)
|
|
251
|
+
: typeof payload.result === "boolean"
|
|
252
|
+
? String(payload.result)
|
|
253
|
+
: "(none)";
|
|
254
|
+
const delivery = extractOutboundDeliveryMetadata(payload);
|
|
255
|
+
return (
|
|
256
|
+
`success=${success} code=${code} message=${message} ` +
|
|
257
|
+
`messageId=${delivery.messageId || "(none)"} ` +
|
|
258
|
+
`processQueryKey=${delivery.processQueryKey || "(none)"} ` +
|
|
259
|
+
`outTrackId=${delivery.outTrackId || "(none)"}`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ensureSessionWebhookBusinessSuccess(
|
|
264
|
+
data: unknown,
|
|
265
|
+
context: { msgtype: string },
|
|
266
|
+
): void {
|
|
267
|
+
if (!data || typeof data !== "object") {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const payload = data as Record<string, unknown>;
|
|
271
|
+
const code = extractErrorCodeFromResponseData(payload);
|
|
272
|
+
const message = firstTrimmedString(
|
|
273
|
+
payload.message,
|
|
274
|
+
payload.errmsg,
|
|
275
|
+
payload.msg,
|
|
276
|
+
payload.errorMessage,
|
|
277
|
+
) || "unknown error";
|
|
278
|
+
|
|
279
|
+
const hasFailureSuccessFlag = payload.success === false || payload.result === false;
|
|
280
|
+
const hasFailureCode = typeof code === "string" && code !== "" && code !== "0";
|
|
281
|
+
if (!hasFailureSuccessFlag && !hasFailureCode) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const reason = [
|
|
286
|
+
code && code !== "0" ? `code=${code}` : "",
|
|
287
|
+
message !== "unknown error" ? `message=${message}` : "",
|
|
288
|
+
].filter(Boolean).join(" ");
|
|
289
|
+
throw new Error(`Session webhook ${context.msgtype} send failed${reason ? `: ${reason}` : ""}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
228
292
|
function isProactivePermissionOrScopeError(code: string | null): boolean {
|
|
229
293
|
if (!code) {
|
|
230
294
|
return false;
|
|
@@ -400,7 +464,7 @@ export async function sendProactiveMedia(
|
|
|
400
464
|
if (!uploadResult) {
|
|
401
465
|
return { ok: false, error: "Failed to upload media" };
|
|
402
466
|
}
|
|
403
|
-
const { mediaId, buffer } = uploadResult;
|
|
467
|
+
const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
|
|
404
468
|
|
|
405
469
|
const token = await getAccessToken(config, log);
|
|
406
470
|
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
@@ -421,7 +485,8 @@ export async function sendProactiveMedia(
|
|
|
421
485
|
msgParam = JSON.stringify({ photoURL: mediaId });
|
|
422
486
|
} else if (mediaType === "voice") {
|
|
423
487
|
msgKey = "sampleAudio";
|
|
424
|
-
const durationMs =
|
|
488
|
+
const durationMs = uploadedDurationMs
|
|
489
|
+
?? await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
|
|
425
490
|
msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
|
|
426
491
|
} else {
|
|
427
492
|
// sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
|
|
@@ -555,14 +620,18 @@ export async function sendBySession(
|
|
|
555
620
|
mediaLocalRoots: options.mediaLocalRoots,
|
|
556
621
|
});
|
|
557
622
|
if (uploadResult) {
|
|
558
|
-
const { mediaId, buffer } = uploadResult;
|
|
623
|
+
const { mediaId, buffer, durationMs: uploadedDurationMs } = uploadResult;
|
|
559
624
|
let body: any;
|
|
560
625
|
|
|
561
626
|
if (options.mediaType === "image") {
|
|
562
627
|
body = { msgtype: "image", image: { media_id: mediaId } };
|
|
563
628
|
} else if (options.mediaType === "voice") {
|
|
564
|
-
const durationMs =
|
|
629
|
+
const durationMs = uploadedDurationMs
|
|
630
|
+
?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
|
|
565
631
|
body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
|
|
632
|
+
log?.debug?.(
|
|
633
|
+
`[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`,
|
|
634
|
+
);
|
|
566
635
|
} else if (options.mediaType === "video") {
|
|
567
636
|
body = { msgtype: "video", video: { media_id: mediaId } };
|
|
568
637
|
} else if (options.mediaType === "file") {
|
|
@@ -577,6 +646,17 @@ export async function sendBySession(
|
|
|
577
646
|
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
578
647
|
...getProxyBypassOption(config),
|
|
579
648
|
});
|
|
649
|
+
log?.debug?.(
|
|
650
|
+
`[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
|
|
651
|
+
);
|
|
652
|
+
ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
|
|
653
|
+
const delivery = extractOutboundDeliveryMetadata(result.data);
|
|
654
|
+
if (!delivery.messageId && !delivery.processQueryKey && !delivery.outTrackId) {
|
|
655
|
+
log?.warn?.(
|
|
656
|
+
`[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` +
|
|
657
|
+
summarizeSessionWebhookResponse(result.data),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
580
660
|
return result.data;
|
|
581
661
|
}
|
|
582
662
|
} else {
|
|
@@ -624,6 +704,10 @@ export async function sendBySession(
|
|
|
624
704
|
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
625
705
|
...getProxyBypassOption(config),
|
|
626
706
|
});
|
|
707
|
+
log?.debug?.(
|
|
708
|
+
`[DingTalk] Session webhook response msgtype=${body.msgtype} ${summarizeSessionWebhookResponse(result.data)}`,
|
|
709
|
+
);
|
|
710
|
+
ensureSessionWebhookBusinessSuccess(result.data, { msgtype: body.msgtype });
|
|
627
711
|
lastResult = result.data;
|
|
628
712
|
}
|
|
629
713
|
return lastResult;
|
|
@@ -662,6 +746,28 @@ export async function sendMessage(
|
|
|
662
746
|
}
|
|
663
747
|
}
|
|
664
748
|
|
|
749
|
+
if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
|
|
750
|
+
log?.debug?.(
|
|
751
|
+
"[DingTalk] Session webhook does not support voice replies reliably; " +
|
|
752
|
+
"using proactive media API for this voice response",
|
|
753
|
+
);
|
|
754
|
+
const proactiveVoiceResult = await sendProactiveMedia(
|
|
755
|
+
config,
|
|
756
|
+
conversationId,
|
|
757
|
+
options.mediaPath,
|
|
758
|
+
options.mediaType,
|
|
759
|
+
options,
|
|
760
|
+
);
|
|
761
|
+
if (!proactiveVoiceResult.ok) {
|
|
762
|
+
return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
ok: true,
|
|
766
|
+
data: proactiveVoiceResult.data,
|
|
767
|
+
messageId: proactiveVoiceResult.messageId,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
665
771
|
if (options.sessionWebhook) {
|
|
666
772
|
const data = await sendBySession(config, options.sessionWebhook, text, options);
|
|
667
773
|
const delivery = extractOutboundDeliveryMetadata(data);
|