@soimy/dingtalk 3.5.3 → 3.6.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/index.ts +7 -0
- package/openclaw.plugin.json +104 -0
- package/package.json +1 -1
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-template.ts +14 -3
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +245 -52
- package/src/card-service.ts +368 -8
- package/src/channel.ts +19 -1081
- package/src/config-schema.ts +19 -0
- package/src/config.ts +117 -1
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +147 -24
- package/src/media-utils.ts +6 -0
- package/src/message-utils.ts +124 -16
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/onboarding.ts +321 -232
- package/src/platform/channel-status.ts +81 -0
- package/src/reply-strategy-card.ts +373 -64
- package/src/reply-strategy-markdown.ts +1 -1
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -72
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +115 -3
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/types.ts +23 -147
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
commitAICardBlocks,
|
|
11
11
|
isCardInTerminalState,
|
|
12
|
-
|
|
12
|
+
updateAICardStatusLine,
|
|
13
13
|
} from "./card-service";
|
|
14
14
|
import { splitCardReasoningAnswerText } from "./card/reasoning-answer-split";
|
|
15
15
|
import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
|
|
@@ -17,23 +17,92 @@ import {
|
|
|
17
17
|
resolveCardStreamingMode,
|
|
18
18
|
shouldWarnDeprecatedCardRealTimeStreamOnce,
|
|
19
19
|
} from "./card/card-streaming-mode";
|
|
20
|
+
import {
|
|
21
|
+
buildImagePlaceholderText,
|
|
22
|
+
extractMarkdownImageCandidates,
|
|
23
|
+
} from "./card/card-markdown-image-reroute";
|
|
20
24
|
import { createCardDraftController } from "./card-draft-controller";
|
|
21
25
|
import { attachCardRunController } from "./card/card-run-registry";
|
|
22
|
-
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
23
|
-
import {
|
|
26
|
+
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
|
|
27
|
+
import { resolveRelativePath } from "./config";
|
|
28
|
+
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
29
|
+
import { getTaskTimeSeconds, updateSessionState } from "./session-state";
|
|
30
|
+
import { renderStatusLine } from "./card/statusline-renderer";
|
|
31
|
+
import type { StatusLineData } from "./card/statusline-renderer";
|
|
32
|
+
import { recordRunStart, getAggregatedUsage, clearRuns } from "./run-usage-store";
|
|
33
|
+
import { sendBySession, sendMessage, sendProactiveMedia, uploadMedia } from "./send-service";
|
|
24
34
|
import type { AICardInstance } from "./types";
|
|
25
35
|
import { AICardStatus } from "./types";
|
|
26
36
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
27
37
|
|
|
28
38
|
const EMPTY_FINAL_REPLY = "✅ Done";
|
|
39
|
+
const DEFAULT_CARD_FAILED_MESSAGE = "回复生成失败,请重试";
|
|
29
40
|
type CardReplyLifecycleState = "open" | "final_seen" | "sealed";
|
|
30
41
|
|
|
42
|
+
/** Deferred media attachment for out-of-card delivery */
|
|
43
|
+
interface DeferredMedia {
|
|
44
|
+
url: string;
|
|
45
|
+
type: "voice" | "video" | "file";
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
export function createCardReplyStrategy(
|
|
32
49
|
ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
|
|
33
50
|
): ReplyStrategy {
|
|
34
51
|
const { card, config, log, isStopRequested } = ctx;
|
|
52
|
+
|
|
53
|
+
const buildStatusLine = (): string | undefined => {
|
|
54
|
+
if (!ctx.taskMeta) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const resolvedUsage =
|
|
58
|
+
typeof card.dapiUsage === "number"
|
|
59
|
+
? card.dapiUsage
|
|
60
|
+
: typeof ctx.taskMeta.usage === "number"
|
|
61
|
+
? ctx.taskMeta.usage
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
64
|
+
const sessionTaskTimeSeconds = card.accountId && card.conversationId
|
|
65
|
+
? getTaskTimeSeconds(card.accountId, card.contextConversationId || card.conversationId)
|
|
66
|
+
: undefined;
|
|
67
|
+
const cardElapsedMs = Math.max(0, Date.now() - card.createdAt);
|
|
68
|
+
const sessionElapsedMs = typeof sessionTaskTimeSeconds === "number"
|
|
69
|
+
? sessionTaskTimeSeconds * 1000
|
|
70
|
+
: undefined;
|
|
71
|
+
const metaElapsedMs = typeof ctx.taskMeta.elapsedMs === "number" && ctx.taskMeta.elapsedMs > 0
|
|
72
|
+
? ctx.taskMeta.elapsedMs
|
|
73
|
+
: undefined;
|
|
74
|
+
ctx.taskMeta.elapsedMs = Math.max(
|
|
75
|
+
cardElapsedMs,
|
|
76
|
+
sessionElapsedMs ?? 0,
|
|
77
|
+
metaElapsedMs ?? 0,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
let inputTokens: number | undefined;
|
|
81
|
+
let outputTokens: number | undefined;
|
|
82
|
+
let cacheRead: number | undefined;
|
|
83
|
+
if (ctx.taskMeta.runIds && ctx.taskMeta.runIds.size > 0) {
|
|
84
|
+
const tokenUsage = getAggregatedUsage(ctx.taskMeta.runIds);
|
|
85
|
+
if (typeof tokenUsage.input === "number") { inputTokens = tokenUsage.input; }
|
|
86
|
+
if (typeof tokenUsage.output === "number") { outputTokens = tokenUsage.output; }
|
|
87
|
+
if (typeof tokenUsage.cacheRead === "number") { cacheRead = tokenUsage.cacheRead; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const statusLineData: StatusLineData = {
|
|
91
|
+
model: ctx.taskMeta.model,
|
|
92
|
+
effort: ctx.taskMeta.effort,
|
|
93
|
+
agent: ctx.taskMeta.agent,
|
|
94
|
+
taskTime: typeof ctx.taskMeta.elapsedMs === "number" ? Math.round(ctx.taskMeta.elapsedMs / 1000) : undefined,
|
|
95
|
+
inputTokens,
|
|
96
|
+
outputTokens,
|
|
97
|
+
cacheRead,
|
|
98
|
+
dapi_usage: resolvedUsage,
|
|
99
|
+
};
|
|
100
|
+
const statusLine = renderStatusLine(statusLineData, config);
|
|
101
|
+
return statusLine || undefined;
|
|
102
|
+
};
|
|
35
103
|
const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
|
|
36
104
|
const streamAnswerLive = mode === "answer" || mode === "all";
|
|
105
|
+
const renderAnswerBlocksLive = mode === "all";
|
|
37
106
|
const streamThinkingLive = mode === "all";
|
|
38
107
|
let lifecycleState: CardReplyLifecycleState = "open";
|
|
39
108
|
const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
|
|
@@ -51,7 +120,9 @@ export function createCardReplyStrategy(
|
|
|
51
120
|
const controller = createCardDraftController({
|
|
52
121
|
card,
|
|
53
122
|
log,
|
|
123
|
+
realTimeStreamEnabled: streamAnswerLive,
|
|
54
124
|
throttleMs: config.cardStreamInterval ?? 1000,
|
|
125
|
+
getStatusLine: buildStatusLine,
|
|
55
126
|
});
|
|
56
127
|
const reasoningAssembler = createReasoningBlockAssembler();
|
|
57
128
|
if (card.outTrackId) {
|
|
@@ -59,10 +130,10 @@ export function createCardReplyStrategy(
|
|
|
59
130
|
}
|
|
60
131
|
let finalTextForFallback: string | undefined;
|
|
61
132
|
let sawFinalDelivery = false;
|
|
62
|
-
let successfulMediaDeliveries = 0;
|
|
63
|
-
let failedMediaDeliveries = 0;
|
|
64
133
|
/** Tracks the latest reasoning snapshot text for non-streaming boundary flush. */
|
|
65
134
|
let latestReasoningSnapshot = "";
|
|
135
|
+
/** Non-image media attachments deferred for out-of-card delivery. */
|
|
136
|
+
let pendingNonImageMedia: DeferredMedia[] = [];
|
|
66
137
|
|
|
67
138
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
68
139
|
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
@@ -72,12 +143,6 @@ export function createCardReplyStrategy(
|
|
|
72
143
|
});
|
|
73
144
|
};
|
|
74
145
|
|
|
75
|
-
const getRawRenderedTimeline = (): string =>
|
|
76
|
-
controller.getRenderedContent({
|
|
77
|
-
fallbackAnswer: undefined,
|
|
78
|
-
overrideAnswer: undefined,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
146
|
const appendAssembledThinkingBlocks = async (blocks: string[]): Promise<void> => {
|
|
82
147
|
for (const block of blocks) {
|
|
83
148
|
if (!block.trim() || isStopRequested?.()) {
|
|
@@ -200,6 +265,23 @@ export function createCardReplyStrategy(
|
|
|
200
265
|
}
|
|
201
266
|
};
|
|
202
267
|
|
|
268
|
+
const rewriteLocalMarkdownImagesToPlaceholders = (text: string): string => {
|
|
269
|
+
const candidates = extractMarkdownImageCandidates(text);
|
|
270
|
+
if (candidates.length === 0) {
|
|
271
|
+
return text;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let nextText = text;
|
|
275
|
+
for (const candidate of candidates.toReversed()) {
|
|
276
|
+
if (candidate.classification !== "local") {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
280
|
+
nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
|
|
281
|
+
}
|
|
282
|
+
return nextText;
|
|
283
|
+
};
|
|
284
|
+
|
|
203
285
|
const handleAnswerSnapshot = async (text: string | undefined): Promise<void> => {
|
|
204
286
|
if (!shouldAcceptAnswerSnapshot() || isStopRequested?.()) {
|
|
205
287
|
return;
|
|
@@ -207,7 +289,23 @@ export function createCardReplyStrategy(
|
|
|
207
289
|
if (!text) {
|
|
208
290
|
return;
|
|
209
291
|
}
|
|
210
|
-
|
|
292
|
+
const rewrittenSnapshot = rewriteLocalMarkdownImagesToPlaceholders(text);
|
|
293
|
+
const normalizedSnapshot = normalizeDeliveredText(rewrittenSnapshot, { isReasoning: false });
|
|
294
|
+
|
|
295
|
+
if (normalizedSnapshot.reasoningText) {
|
|
296
|
+
await applyModeAwareReasoningSnapshot(normalizedSnapshot.reasoningText);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const answerSnapshot = normalizedSnapshot.answerText
|
|
300
|
+
?? (!normalizedSnapshot.reasoningText ? rewrittenSnapshot : undefined);
|
|
301
|
+
if (!answerSnapshot) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await controller.updateAnswer(answerSnapshot, {
|
|
306
|
+
stream: streamAnswerLive,
|
|
307
|
+
renderBlocks: renderAnswerBlocksLive,
|
|
308
|
+
});
|
|
211
309
|
};
|
|
212
310
|
|
|
213
311
|
const applySplitTextToTimeline = async (
|
|
@@ -222,20 +320,66 @@ export function createCardReplyStrategy(
|
|
|
222
320
|
return normalized;
|
|
223
321
|
};
|
|
224
322
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (mediaUrls.length === 0) {
|
|
230
|
-
return;
|
|
323
|
+
const rerouteMarkdownImagesFromAnswer = async (text: string): Promise<string> => {
|
|
324
|
+
const candidates = extractMarkdownImageCandidates(text);
|
|
325
|
+
if (candidates.length === 0) {
|
|
326
|
+
return text;
|
|
231
327
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
328
|
+
|
|
329
|
+
type SuccessfulReroute = {
|
|
330
|
+
start: number;
|
|
331
|
+
end: number;
|
|
332
|
+
placeholder: string;
|
|
333
|
+
mediaId: string;
|
|
334
|
+
blockText: string;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
let nextText = text;
|
|
338
|
+
const successfulReroutes: SuccessfulReroute[] = [];
|
|
339
|
+
|
|
340
|
+
for (const candidate of candidates.toReversed()) {
|
|
341
|
+
if (candidate.classification !== "local") {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let prepared: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
|
|
346
|
+
try {
|
|
347
|
+
prepared = await prepareMediaInput(candidate.url, log, config.mediaUrlAllowlist);
|
|
348
|
+
const mediaPath = prepared.cleanup
|
|
349
|
+
? prepared.path
|
|
350
|
+
: resolveRelativePath(prepared.path);
|
|
351
|
+
const mediaType = resolveOutboundMediaType({ mediaPath, asVoice: false });
|
|
352
|
+
if (mediaType !== "image") {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const result = await uploadMedia(config, mediaPath, "image", log);
|
|
357
|
+
if (!result?.mediaId) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
362
|
+
const blockText = candidate.alt.trim() || placeholder.replace(/^见下图/, "").trim() || "图片";
|
|
363
|
+
successfulReroutes.push({
|
|
364
|
+
start: candidate.start,
|
|
365
|
+
end: candidate.end,
|
|
366
|
+
placeholder,
|
|
367
|
+
mediaId: result.mediaId,
|
|
368
|
+
blockText,
|
|
369
|
+
});
|
|
370
|
+
nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
|
|
371
|
+
} catch {
|
|
372
|
+
// Failure fallback: keep the original markdown unchanged.
|
|
373
|
+
} finally {
|
|
374
|
+
await prepared?.cleanup?.();
|
|
375
|
+
}
|
|
238
376
|
}
|
|
377
|
+
|
|
378
|
+
for (const reroute of successfulReroutes.toSorted((left, right) => left.start - right.start)) {
|
|
379
|
+
await controller.appendImageBlock(reroute.mediaId, reroute.blockText);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return nextText;
|
|
239
383
|
};
|
|
240
384
|
|
|
241
385
|
return {
|
|
@@ -252,6 +396,17 @@ export function createCardReplyStrategy(
|
|
|
252
396
|
await handleAssistantBoundary();
|
|
253
397
|
},
|
|
254
398
|
|
|
399
|
+
onAgentRunStart: (runId: string) => {
|
|
400
|
+
if (isLifecycleSealed()) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
recordRunStart(runId);
|
|
404
|
+
if (ctx.taskMeta) {
|
|
405
|
+
if (!ctx.taskMeta.runIds) { ctx.taskMeta.runIds = new Set(); }
|
|
406
|
+
ctx.taskMeta.runIds.add(runId);
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
|
|
255
410
|
onPartialReply: async (payload) => {
|
|
256
411
|
await handleAnswerSnapshot(payload.text);
|
|
257
412
|
},
|
|
@@ -262,6 +417,24 @@ export function createCardReplyStrategy(
|
|
|
262
417
|
}
|
|
263
418
|
await applyModeAwareReasoningSnapshot(payload.text);
|
|
264
419
|
},
|
|
420
|
+
|
|
421
|
+
onModelSelected: (selected) => {
|
|
422
|
+
if (!card.accountId || !card.conversationId) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
updateSessionState(card.accountId, card.contextConversationId || card.conversationId, {
|
|
426
|
+
model: selected.model,
|
|
427
|
+
effort: selected.thinkLevel,
|
|
428
|
+
});
|
|
429
|
+
if (ctx.taskMeta) {
|
|
430
|
+
ctx.taskMeta.model = selected.model;
|
|
431
|
+
ctx.taskMeta.effort = selected.thinkLevel;
|
|
432
|
+
}
|
|
433
|
+
const statusLine = buildStatusLine();
|
|
434
|
+
if (statusLine) {
|
|
435
|
+
void updateAICardStatusLine(card, statusLine, log);
|
|
436
|
+
}
|
|
437
|
+
},
|
|
265
438
|
};
|
|
266
439
|
},
|
|
267
440
|
|
|
@@ -293,8 +466,31 @@ export function createCardReplyStrategy(
|
|
|
293
466
|
`lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
|
|
294
467
|
`lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
|
|
295
468
|
);
|
|
469
|
+
// Inline media upload → image blocks in card; defer non-image attachments
|
|
296
470
|
if (payload.mediaUrls.length > 0) {
|
|
297
|
-
|
|
471
|
+
for (const url of payload.mediaUrls) {
|
|
472
|
+
try {
|
|
473
|
+
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
474
|
+
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
475
|
+
if (mediaType !== "image") {
|
|
476
|
+
log?.debug?.(`[DingTalk][Card] Deferring non-image media (${mediaType}) for out-of-card delivery: ${url}`);
|
|
477
|
+
// Collect non-image attachments for later delivery
|
|
478
|
+
if (mediaType === "voice" || mediaType === "video" || mediaType === "file") {
|
|
479
|
+
pendingNonImageMedia.push({ url, type: mediaType });
|
|
480
|
+
}
|
|
481
|
+
await prepared.cleanup?.();
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
485
|
+
await prepared.cleanup?.();
|
|
486
|
+
if (result?.mediaId) {
|
|
487
|
+
await controller.appendImageBlock(result.mediaId);
|
|
488
|
+
}
|
|
489
|
+
} catch (err: unknown) {
|
|
490
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
491
|
+
log?.debug?.(`[DingTalk][Card] Failed to upload media as image block: ${msg}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
298
494
|
}
|
|
299
495
|
const rawFinalText = typeof textToSend === "string" ? textToSend : "";
|
|
300
496
|
if (rawFinalText) {
|
|
@@ -302,11 +498,12 @@ export function createCardReplyStrategy(
|
|
|
302
498
|
await applyModeAwareReasoningSnapshot(rawFinalText);
|
|
303
499
|
await flushPendingReasoning();
|
|
304
500
|
} else {
|
|
305
|
-
const
|
|
501
|
+
const rewrittenFinalText = await rerouteMarkdownImagesFromAnswer(rawFinalText);
|
|
502
|
+
const normalizedFinal = await applySplitTextToTimeline(rewrittenFinalText, {
|
|
306
503
|
answerHandling: "capture",
|
|
307
504
|
});
|
|
308
505
|
if (isFirstFinalDelivery && !normalizedFinal.answerText && !normalizedFinal.reasoningText) {
|
|
309
|
-
finalTextForFallback =
|
|
506
|
+
finalTextForFallback = rewrittenFinalText;
|
|
310
507
|
}
|
|
311
508
|
await flushPendingReasoning();
|
|
312
509
|
}
|
|
@@ -337,11 +534,11 @@ export function createCardReplyStrategy(
|
|
|
337
534
|
if (isReasoningBlock) {
|
|
338
535
|
const normalized = normalizeDeliveredText(textToSend, { isReasoning: true });
|
|
339
536
|
await applyDeliveredContent(normalized, {
|
|
340
|
-
routeReasoningThroughModePolicy:
|
|
537
|
+
routeReasoningThroughModePolicy: false,
|
|
341
538
|
answerHandling: "ignore",
|
|
342
539
|
});
|
|
343
540
|
} else {
|
|
344
|
-
await applySplitTextToTimeline(textToSend, {
|
|
541
|
+
await applySplitTextToTimeline(rewriteLocalMarkdownImagesToPlaceholders(textToSend), {
|
|
345
542
|
answerHandling: lifecycleState === "open" ? "update" : "capture",
|
|
346
543
|
});
|
|
347
544
|
}
|
|
@@ -349,7 +546,26 @@ export function createCardReplyStrategy(
|
|
|
349
546
|
|
|
350
547
|
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
351
548
|
if (payload.mediaUrls.length > 0) {
|
|
352
|
-
|
|
549
|
+
for (const url of payload.mediaUrls) {
|
|
550
|
+
try {
|
|
551
|
+
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
552
|
+
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
553
|
+
if (mediaType !== "image") {
|
|
554
|
+
log?.debug?.(`[DingTalk][Card] Deferring non-image media (${mediaType}) for out-of-card delivery: ${url}`);
|
|
555
|
+
pendingNonImageMedia.push({ url, type: mediaType });
|
|
556
|
+
await prepared.cleanup?.();
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
560
|
+
await prepared.cleanup?.();
|
|
561
|
+
if (result?.mediaId) {
|
|
562
|
+
await controller.appendImageBlock(result.mediaId);
|
|
563
|
+
}
|
|
564
|
+
} catch (err: unknown) {
|
|
565
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
566
|
+
log?.debug?.(`[DingTalk][Card] Failed to upload media as image block: ${msg}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
353
569
|
}
|
|
354
570
|
},
|
|
355
571
|
|
|
@@ -366,18 +582,58 @@ export function createCardReplyStrategy(
|
|
|
366
582
|
if (isStopRequested?.()) {
|
|
367
583
|
log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
|
|
368
584
|
lifecycleState = "sealed";
|
|
585
|
+
if (card.accountId && card.conversationId) {
|
|
586
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
587
|
+
}
|
|
369
588
|
return;
|
|
370
589
|
}
|
|
371
590
|
|
|
372
591
|
if (card.state === AICardStatus.FINISHED) {
|
|
373
|
-
|
|
592
|
+
// Card was already finalized (e.g. first embedded run timed out).
|
|
593
|
+
// If session-recovery triggered a second run that produced new content,
|
|
594
|
+
// deliver it as a markdown fallback so the user sees the final result.
|
|
595
|
+
// The user may see partial overlap with the frozen card's content, but
|
|
596
|
+
// delivering the full answer is preferred over silence.
|
|
597
|
+
const recoveryText = getRenderedTimeline({ preferFinalAnswer: true })
|
|
598
|
+
|| finalTextForFallback
|
|
599
|
+
|| controller.getLastAnswerContent()
|
|
600
|
+
|| controller.getLastContent();
|
|
601
|
+
if (recoveryText) {
|
|
602
|
+
log?.info?.(
|
|
603
|
+
`[DingTalk][Finalize] Card already FINISHED — sending markdown fallback for session-recovery content ` +
|
|
604
|
+
`len=${recoveryText.length} preview="${recoveryText.slice(0, 80)}"`,
|
|
605
|
+
);
|
|
606
|
+
const sendResult = await sendMessage(ctx.config, ctx.to, recoveryText, {
|
|
607
|
+
sessionWebhook: ctx.sessionWebhook,
|
|
608
|
+
atUserId: !ctx.isDirect ? ctx.senderId : null,
|
|
609
|
+
log,
|
|
610
|
+
accountId: ctx.accountId,
|
|
611
|
+
storePath: ctx.storePath,
|
|
612
|
+
conversationId: ctx.groupId,
|
|
613
|
+
quotedRef: ctx.replyQuotedRef,
|
|
614
|
+
forceMarkdown: true,
|
|
615
|
+
});
|
|
616
|
+
if (!sendResult.ok) {
|
|
617
|
+
log?.warn?.(
|
|
618
|
+
`[DingTalk][Finalize] Markdown fallback after FINISHED card failed: ${sendResult.error}`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED and no new content");
|
|
623
|
+
}
|
|
374
624
|
lifecycleState = "sealed";
|
|
625
|
+
if (card.accountId && card.conversationId) {
|
|
626
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
627
|
+
}
|
|
375
628
|
return;
|
|
376
629
|
}
|
|
377
630
|
|
|
378
631
|
if (card.state === AICardStatus.STOPPED) {
|
|
379
632
|
log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
|
|
380
633
|
lifecycleState = "sealed";
|
|
634
|
+
if (card.accountId && card.conversationId) {
|
|
635
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
636
|
+
}
|
|
381
637
|
return;
|
|
382
638
|
}
|
|
383
639
|
|
|
@@ -385,8 +641,7 @@ export function createCardReplyStrategy(
|
|
|
385
641
|
if (card.state === AICardStatus.FAILED || controller.isFailed()) {
|
|
386
642
|
const fallbackText = getRenderedTimeline({ preferFinalAnswer: true })
|
|
387
643
|
|| controller.getLastAnswerContent()
|
|
388
|
-
||
|
|
389
|
-
|| card.lastStreamedContent;
|
|
644
|
+
|| DEFAULT_CARD_FAILED_MESSAGE;
|
|
390
645
|
if (fallbackText) {
|
|
391
646
|
log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
|
|
392
647
|
const sendResult = await sendMessage(ctx.config, ctx.to, fallbackText, {
|
|
@@ -406,47 +661,90 @@ export function createCardReplyStrategy(
|
|
|
406
661
|
log?.debug?.("[DingTalk] Card failed but no content to fallback with");
|
|
407
662
|
}
|
|
408
663
|
lifecycleState = "sealed";
|
|
664
|
+
if (card.accountId && card.conversationId) {
|
|
665
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
666
|
+
}
|
|
409
667
|
return;
|
|
410
668
|
}
|
|
411
669
|
|
|
412
|
-
// Normal finalize.
|
|
670
|
+
// Normal finalize (V2 template path: single instances API call).
|
|
413
671
|
try {
|
|
414
672
|
await flushPendingReasoning();
|
|
673
|
+
|
|
415
674
|
await controller.flush();
|
|
416
675
|
await controller.waitForInFlight();
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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;
|
|
676
|
+
|
|
677
|
+
// Prepare finalize options for single instances API call
|
|
678
|
+
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
679
|
+
const blockListJson = controller.getRenderedBlocks({
|
|
680
|
+
fallbackAnswer,
|
|
681
|
+
overrideAnswer: finalTextForFallback,
|
|
682
|
+
});
|
|
683
|
+
const content = controller.getRenderedContent({
|
|
684
|
+
fallbackAnswer,
|
|
685
|
+
overrideAnswer: finalTextForFallback,
|
|
686
|
+
}) || fallbackAnswer || EMPTY_FINAL_REPLY;
|
|
687
|
+
|
|
441
688
|
controller.stop();
|
|
442
689
|
log?.info?.(
|
|
443
|
-
`[DingTalk][Finalize] Calling
|
|
690
|
+
`[DingTalk][Finalize] Calling commitAICardBlocks — ` +
|
|
691
|
+
`blockListLen=${blockListJson.length} contentLen=${content.length} ` +
|
|
444
692
|
`source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
445
|
-
`preview="${
|
|
693
|
+
`preview="${content.slice(0, 120)}"`,
|
|
446
694
|
);
|
|
447
|
-
|
|
695
|
+
|
|
696
|
+
// Build statusLine for card template
|
|
697
|
+
const statusLine = buildStatusLine();
|
|
698
|
+
|
|
699
|
+
await commitAICardBlocks(card, {
|
|
700
|
+
blockListJson,
|
|
701
|
+
content,
|
|
702
|
+
statusLine,
|
|
448
703
|
quotedRef: ctx.replyQuotedRef,
|
|
449
|
-
});
|
|
704
|
+
}, log);
|
|
705
|
+
|
|
706
|
+
// Send deferred non-image attachments after card finalize
|
|
707
|
+
// Use sessionWebhook for reply-session semantics; fallback to proactive if unavailable.
|
|
708
|
+
if (pendingNonImageMedia.length > 0) {
|
|
709
|
+
log?.debug?.(`[DingTalk][Card] Sending ${pendingNonImageMedia.length} deferred non-image attachments`);
|
|
710
|
+
for (const { url, type } of pendingNonImageMedia) {
|
|
711
|
+
try {
|
|
712
|
+
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
713
|
+
const actualMediaPath = prepared.path;
|
|
714
|
+
|
|
715
|
+
// Prefer sessionWebhook for reply-session permission semantics
|
|
716
|
+
if (ctx.sessionWebhook) {
|
|
717
|
+
const sendResult = await sendMessage(config, ctx.to, "", {
|
|
718
|
+
sessionWebhook: ctx.sessionWebhook,
|
|
719
|
+
mediaPath: actualMediaPath,
|
|
720
|
+
mediaType: type,
|
|
721
|
+
log,
|
|
722
|
+
accountId: ctx.accountId,
|
|
723
|
+
storePath: ctx.storePath,
|
|
724
|
+
});
|
|
725
|
+
if (!sendResult.ok) {
|
|
726
|
+
log?.warn?.(`[DingTalk][Card] Deferred media session send failed: ${sendResult.error || "unknown"}`);
|
|
727
|
+
}
|
|
728
|
+
} else {
|
|
729
|
+
// Fallback: proactive send when no reply session available
|
|
730
|
+
const result = await sendProactiveMedia(config, ctx.to, actualMediaPath, type, {
|
|
731
|
+
log,
|
|
732
|
+
accountId: ctx.accountId,
|
|
733
|
+
});
|
|
734
|
+
if (!result.ok) {
|
|
735
|
+
log?.warn?.(`[DingTalk][Card] Deferred media proactive send failed: ${result.error || "unknown"}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
await prepared.cleanup?.();
|
|
740
|
+
} catch (err: unknown) {
|
|
741
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
742
|
+
log?.warn?.(`[DingTalk][Card] Failed to send deferred media: ${msg}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
pendingNonImageMedia = []; // Clear after sending
|
|
746
|
+
}
|
|
747
|
+
|
|
450
748
|
lifecycleState = "sealed";
|
|
451
749
|
|
|
452
750
|
// In group chats, send a lightweight @mention via session webhook
|
|
@@ -475,16 +773,27 @@ export function createCardReplyStrategy(
|
|
|
475
773
|
}
|
|
476
774
|
} finally {
|
|
477
775
|
lifecycleState = "sealed";
|
|
776
|
+
if (card.accountId && card.conversationId) {
|
|
777
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
778
|
+
}
|
|
478
779
|
}
|
|
479
780
|
},
|
|
480
781
|
|
|
481
782
|
async abort(_error: Error): Promise<void> {
|
|
482
783
|
lifecycleState = "sealed";
|
|
784
|
+
if (card.accountId && card.conversationId) {
|
|
785
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
786
|
+
}
|
|
483
787
|
if (!isCardInTerminalState(card.state)) {
|
|
484
788
|
controller.stop();
|
|
485
789
|
await controller.waitForInFlight();
|
|
486
790
|
try {
|
|
487
|
-
|
|
791
|
+
// For V2 template, finalize via instances API
|
|
792
|
+
const errorBlockListJson = JSON.stringify([{ type: 0, markdown: "❌ 处理失败" }]);
|
|
793
|
+
await commitAICardBlocks(card, {
|
|
794
|
+
blockListJson: errorBlockListJson,
|
|
795
|
+
content: "❌ 处理失败",
|
|
796
|
+
}, log);
|
|
488
797
|
} catch (cardCloseErr: unknown) {
|
|
489
798
|
log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${(cardCloseErr as Error).message}`);
|
|
490
799
|
card.state = AICardStatus.FAILED;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Reasoning display is intentionally unsupported on DingTalk markdown.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
9
|
+
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
|
|
10
10
|
import { sendMessage } from "./send-service";
|
|
11
11
|
|
|
12
12
|
const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
|