@soimy/dingtalk 3.6.2 → 3.6.4
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/dist/index.js +387 -159
- package/dist/index.js.map +3 -3
- package/dist/src/card-draft-controller.d.ts.map +1 -1
- package/dist/src/card-service.d.ts.map +1 -1
- package/dist/src/inbound-handler.d.ts.map +1 -1
- package/dist/src/message-utils.d.ts +10 -0
- package/dist/src/message-utils.d.ts.map +1 -1
- package/dist/src/reply-strategy-card.d.ts.map +1 -1
- package/dist/src/reply-strategy-markdown.d.ts.map +1 -1
- package/dist/src/reply-strategy-types.d.ts +2 -0
- package/dist/src/reply-strategy-types.d.ts.map +1 -1
- package/dist/src/send-service.d.ts.map +1 -1
- package/dist/src/targeting/agent-routing.d.ts +55 -16
- package/dist/src/targeting/agent-routing.d.ts.map +1 -1
- package/dist/src/types.d.ts +7 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/card-callback-service.ts +1 -1
- package/src/card-draft-controller.ts +4 -3
- package/src/card-service.ts +28 -4
- package/src/inbound-handler.ts +82 -33
- package/src/message-utils.ts +103 -7
- package/src/reply-strategy-card.ts +31 -3
- package/src/reply-strategy-markdown.ts +139 -20
- package/src/reply-strategy-types.ts +3 -0
- package/src/send-service.ts +67 -65
- package/src/targeting/agent-routing.ts +151 -85
- package/src/types.ts +7 -0
package/src/message-utils.ts
CHANGED
|
@@ -398,7 +398,10 @@ function isMarkdownTableSeparator(line: string): boolean {
|
|
|
398
398
|
.replace(/\|$/, "")
|
|
399
399
|
.split("|")
|
|
400
400
|
.map((cell) => cell.trim());
|
|
401
|
-
|
|
401
|
+
// DingTalk markdown requires :-: separator format (1 dash minimum).
|
|
402
|
+
// Note: single `-` cells in data rows would also match; this is an accepted trade-off
|
|
403
|
+
// for compatibility with DingTalk's recommended :-: separator format.
|
|
404
|
+
return cells.length > 0 && cells.every((cell) => /^:?-{1,}:?$/.test(cell));
|
|
402
405
|
}
|
|
403
406
|
|
|
404
407
|
function isMarkdownTableRow(line: string): boolean {
|
|
@@ -415,11 +418,96 @@ function parseMarkdownTableRow(line: string): string[] {
|
|
|
415
418
|
.map((cell) => cell.trim());
|
|
416
419
|
}
|
|
417
420
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Parse alignment from a separator cell.
|
|
423
|
+
* Returns 'left', 'center', 'right', or 'center' (default).
|
|
424
|
+
*/
|
|
425
|
+
function parseSeparatorAlignment(cell: string): "left" | "center" | "right" {
|
|
426
|
+
const trimmed = cell.trim();
|
|
427
|
+
const hasLeftColon = trimmed.startsWith(":");
|
|
428
|
+
const hasRightColon = trimmed.endsWith(":");
|
|
429
|
+
if (hasLeftColon && hasRightColon) {
|
|
430
|
+
return "center";
|
|
431
|
+
}
|
|
432
|
+
if (hasRightColon) {
|
|
433
|
+
return "right";
|
|
434
|
+
}
|
|
435
|
+
if (hasLeftColon) {
|
|
436
|
+
return "left";
|
|
437
|
+
}
|
|
438
|
+
// Default to center for bare dashes (standard markdown)
|
|
439
|
+
return "center";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Build separator row from alignment array.
|
|
444
|
+
*/
|
|
445
|
+
function buildSeparatorRow(alignments: ("left" | "center" | "right")[]): string {
|
|
446
|
+
const cells = alignments.map((align) => {
|
|
447
|
+
if (align === "left") {
|
|
448
|
+
return ":---";
|
|
449
|
+
}
|
|
450
|
+
if (align === "right") {
|
|
451
|
+
return "---:";
|
|
452
|
+
}
|
|
453
|
+
return ":---:";
|
|
454
|
+
});
|
|
455
|
+
return "|" + cells.join("|") + "|";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Render markdown table rows preserving original alignment.
|
|
460
|
+
* Extracts alignment from the separator row and applies it to output.
|
|
461
|
+
*/
|
|
462
|
+
function renderMarkdownTable(headerLine: string, separatorLine: string, dataLines: string[]): string {
|
|
463
|
+
const headerCells = parseMarkdownTableRow(headerLine);
|
|
464
|
+
const separatorCells = parseMarkdownTableRow(separatorLine);
|
|
465
|
+
const dataRows = dataLines.map(parseMarkdownTableRow).filter((cells) => cells.length > 0);
|
|
466
|
+
|
|
467
|
+
if (headerCells.length === 0) {
|
|
468
|
+
return "";
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const colCount = Math.max(
|
|
472
|
+
headerCells.length,
|
|
473
|
+
separatorCells.length,
|
|
474
|
+
...dataRows.map((cells) => cells.length),
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// Extract alignments from separator
|
|
478
|
+
const alignments: ("left" | "center" | "right")[] = [];
|
|
479
|
+
for (let i = 0; i < colCount; i++) {
|
|
480
|
+
const sepCell = separatorCells[i] || "";
|
|
481
|
+
alignments.push(parseSeparatorAlignment(sepCell));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const separator = buildSeparatorRow(alignments);
|
|
485
|
+
|
|
486
|
+
const allRows = [headerCells, ...dataRows];
|
|
487
|
+
const rendered = allRows
|
|
488
|
+
.map((cells) => {
|
|
489
|
+
const padded = cells.length < colCount
|
|
490
|
+
? [...cells, ...Array(colCount - cells.length).fill("")]
|
|
491
|
+
: cells;
|
|
492
|
+
return "|" + padded.join("|") + "|";
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Insert separator after header row (works for header-only tables too)
|
|
496
|
+
rendered.splice(1, 0, separator);
|
|
497
|
+
|
|
498
|
+
return rendered.join("\n");
|
|
421
499
|
}
|
|
422
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Convert markdown tables to DingTalk-compatible format.
|
|
503
|
+
* DingTalk's markdown renderer supports left/center/right alignment (:---, :---:, ---:).
|
|
504
|
+
* This function preserves the original alignment from the separator row.
|
|
505
|
+
* Tables inside code fences are preserved unchanged.
|
|
506
|
+
*
|
|
507
|
+
* Note: Despite the function name, this now produces DingTalk-compatible
|
|
508
|
+
* markdown tables rather than plain text. The name is preserved for API
|
|
509
|
+
* compatibility with the `convertMarkdownTables` config option.
|
|
510
|
+
*/
|
|
423
511
|
export function convertMarkdownTablesToPlainText(text: string): string {
|
|
424
512
|
const lines = text.split("\n");
|
|
425
513
|
const output: string[] = [];
|
|
@@ -441,13 +529,21 @@ export function convertMarkdownTablesToPlainText(text: string): string {
|
|
|
441
529
|
isMarkdownTableRow(line) &&
|
|
442
530
|
isMarkdownTableSeparator(lines[index + 1] || "")
|
|
443
531
|
) {
|
|
444
|
-
const
|
|
532
|
+
const headerLine = line;
|
|
533
|
+
const separatorLine = lines[index + 1] || "";
|
|
534
|
+
const dataLines: string[] = [];
|
|
445
535
|
index += 2;
|
|
446
536
|
while (index < lines.length && isMarkdownTableRow(lines[index] || "")) {
|
|
447
|
-
|
|
537
|
+
dataLines.push(lines[index] || "");
|
|
448
538
|
index += 1;
|
|
449
539
|
}
|
|
450
|
-
|
|
540
|
+
const renderedTable = renderMarkdownTable(headerLine, separatorLine, dataLines);
|
|
541
|
+
// Ensure blank line before table for DingTalk markdown rendering
|
|
542
|
+
const lastOutput = output[output.length - 1];
|
|
543
|
+
if (lastOutput !== undefined && lastOutput.trim() !== "") {
|
|
544
|
+
output.push("");
|
|
545
|
+
}
|
|
546
|
+
output.push(renderedTable);
|
|
451
547
|
continue;
|
|
452
548
|
}
|
|
453
549
|
|
|
@@ -102,7 +102,6 @@ export function createCardReplyStrategy(
|
|
|
102
102
|
};
|
|
103
103
|
const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
|
|
104
104
|
const streamAnswerLive = mode === "answer" || mode === "all";
|
|
105
|
-
const renderAnswerBlocksLive = mode === "all";
|
|
106
105
|
const streamThinkingLive = mode === "all";
|
|
107
106
|
let lifecycleState: CardReplyLifecycleState = "open";
|
|
108
107
|
const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
|
|
@@ -134,6 +133,8 @@ export function createCardReplyStrategy(
|
|
|
134
133
|
let latestReasoningSnapshot = "";
|
|
135
134
|
/** Non-image media attachments deferred for out-of-card delivery. */
|
|
136
135
|
let pendingNonImageMedia: DeferredMedia[] = [];
|
|
136
|
+
/** URLs already processed in this card session — prevents duplicate upload/send when the same mediaUrl appears in both a non-final and final deliver. */
|
|
137
|
+
const processedMediaUrls = new Set<string>();
|
|
137
138
|
|
|
138
139
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
139
140
|
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
@@ -261,7 +262,10 @@ export function createCardReplyStrategy(
|
|
|
261
262
|
finalTextForFallback = normalized.answerText;
|
|
262
263
|
return;
|
|
263
264
|
}
|
|
264
|
-
await controller.updateAnswer(normalized.answerText
|
|
265
|
+
await controller.updateAnswer(normalized.answerText, {
|
|
266
|
+
stream: streamAnswerLive,
|
|
267
|
+
renderBlocks: !streamAnswerLive,
|
|
268
|
+
});
|
|
265
269
|
}
|
|
266
270
|
};
|
|
267
271
|
|
|
@@ -304,7 +308,8 @@ export function createCardReplyStrategy(
|
|
|
304
308
|
|
|
305
309
|
await controller.updateAnswer(answerSnapshot, {
|
|
306
310
|
stream: streamAnswerLive,
|
|
307
|
-
|
|
311
|
+
// Active answer previews live in the content field; blockList is committed at boundaries/finalize.
|
|
312
|
+
renderBlocks: false,
|
|
308
313
|
});
|
|
309
314
|
};
|
|
310
315
|
|
|
@@ -342,6 +347,12 @@ export function createCardReplyStrategy(
|
|
|
342
347
|
continue;
|
|
343
348
|
}
|
|
344
349
|
|
|
350
|
+
if (processedMediaUrls.has(candidate.url.trim())) {
|
|
351
|
+
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
352
|
+
nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
345
356
|
let prepared: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
|
|
346
357
|
try {
|
|
347
358
|
prepared = await prepareMediaInput(candidate.url, log, config.mediaUrlAllowlist);
|
|
@@ -358,6 +369,7 @@ export function createCardReplyStrategy(
|
|
|
358
369
|
continue;
|
|
359
370
|
}
|
|
360
371
|
|
|
372
|
+
processedMediaUrls.add(candidate.url.trim());
|
|
361
373
|
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
362
374
|
const blockText = candidate.alt.trim() || placeholder.replace(/^见下图/, "").trim() || "图片";
|
|
363
375
|
successfulReroutes.push({
|
|
@@ -388,6 +400,11 @@ export function createCardReplyStrategy(
|
|
|
388
400
|
// Card mode keeps runtime block streaming disabled, but still consumes
|
|
389
401
|
// reasoning blocks through explicit callbacks and delivery metadata.
|
|
390
402
|
disableBlockStreaming: ctx.disableBlockStreaming ?? true,
|
|
403
|
+
// DingTalk card mode owns the visible reply surface. In group chats,
|
|
404
|
+
// OpenClaw defaults source replies to message-tool-only; override that
|
|
405
|
+
// so final replies are delivered into this card instead of spawning a
|
|
406
|
+
// separate visible message/card via the message tool.
|
|
407
|
+
sourceReplyDeliveryMode: "automatic",
|
|
391
408
|
|
|
392
409
|
onAssistantMessageStart: async () => {
|
|
393
410
|
if (isLifecycleSealed() || isStopRequested?.()) {
|
|
@@ -469,6 +486,10 @@ export function createCardReplyStrategy(
|
|
|
469
486
|
// Inline media upload → image blocks in card; defer non-image attachments
|
|
470
487
|
if (payload.mediaUrls.length > 0) {
|
|
471
488
|
for (const url of payload.mediaUrls) {
|
|
489
|
+
const normalizedUrl = url.trim();
|
|
490
|
+
if (processedMediaUrls.has(normalizedUrl)) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
472
493
|
try {
|
|
473
494
|
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
474
495
|
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
@@ -484,6 +505,7 @@ export function createCardReplyStrategy(
|
|
|
484
505
|
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
485
506
|
await prepared.cleanup?.();
|
|
486
507
|
if (result?.mediaId) {
|
|
508
|
+
processedMediaUrls.add(normalizedUrl);
|
|
487
509
|
await controller.appendImageBlock(result.mediaId);
|
|
488
510
|
}
|
|
489
511
|
} catch (err: unknown) {
|
|
@@ -547,6 +569,10 @@ export function createCardReplyStrategy(
|
|
|
547
569
|
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
548
570
|
if (payload.mediaUrls.length > 0) {
|
|
549
571
|
for (const url of payload.mediaUrls) {
|
|
572
|
+
const normalizedUrl = url.trim();
|
|
573
|
+
if (processedMediaUrls.has(normalizedUrl)) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
550
576
|
try {
|
|
551
577
|
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
552
578
|
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
@@ -559,6 +585,7 @@ export function createCardReplyStrategy(
|
|
|
559
585
|
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
560
586
|
await prepared.cleanup?.();
|
|
561
587
|
if (result?.mediaId) {
|
|
588
|
+
processedMediaUrls.add(normalizedUrl);
|
|
562
589
|
await controller.appendImageBlock(result.mediaId);
|
|
563
590
|
}
|
|
564
591
|
} catch (err: unknown) {
|
|
@@ -671,6 +698,7 @@ export function createCardReplyStrategy(
|
|
|
671
698
|
try {
|
|
672
699
|
await flushPendingReasoning();
|
|
673
700
|
|
|
701
|
+
await controller.clearStreamingContent?.();
|
|
674
702
|
await controller.flush();
|
|
675
703
|
await controller.waitForInFlight();
|
|
676
704
|
|
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
* Reasoning display is intentionally unsupported on DingTalk markdown.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { resolveRelativePath } from "./config";
|
|
11
|
+
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
12
|
+
import type {
|
|
13
|
+
DeliverPayload,
|
|
14
|
+
ReplyOptions,
|
|
15
|
+
ReplyStrategy,
|
|
16
|
+
ReplyStrategyContext,
|
|
17
|
+
} from "./reply-strategy-types";
|
|
10
18
|
import { sendMessage } from "./send-service";
|
|
11
19
|
|
|
12
20
|
const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
|
|
@@ -14,7 +22,7 @@ const EMPTY_FINAL_FALLBACK_TEXT = "✅ Done";
|
|
|
14
22
|
function renderQuotedSegment(text: string): string {
|
|
15
23
|
return text
|
|
16
24
|
.split("\n")
|
|
17
|
-
.map((line) => line.length > 0 ? `> ${line}` : ">")
|
|
25
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"))
|
|
18
26
|
.join("\n");
|
|
19
27
|
}
|
|
20
28
|
|
|
@@ -52,9 +60,12 @@ function computeSharedPrefixTail(previous: string, next: string): string {
|
|
|
52
60
|
return suffix.trim() ? suffix : "";
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
function renderMarkdownImage(mediaPath: string): string {
|
|
64
|
+
const filename = path.basename(mediaPath) || "image";
|
|
65
|
+
return ``;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createMarkdownReplyStrategy(ctx: ReplyStrategyContext): ReplyStrategy {
|
|
58
69
|
let finalText: string | undefined;
|
|
59
70
|
let activeAnswerText = "";
|
|
60
71
|
let lastSentAnswerText = "";
|
|
@@ -79,7 +90,10 @@ export function createMarkdownReplyStrategy(
|
|
|
79
90
|
sentVisibleContent = true;
|
|
80
91
|
};
|
|
81
92
|
|
|
82
|
-
const
|
|
93
|
+
const prepareAnswerSuffix = (text: string | undefined): {
|
|
94
|
+
text: string;
|
|
95
|
+
markSent: () => void;
|
|
96
|
+
} | null => {
|
|
83
97
|
const current = typeof text === "string" ? text : "";
|
|
84
98
|
if (current.length > 0) {
|
|
85
99
|
activeAnswerText = current;
|
|
@@ -88,42 +102,146 @@ export function createMarkdownReplyStrategy(
|
|
|
88
102
|
|
|
89
103
|
const suffix = computeIncrementalSuffix(lastSentAnswerText, current);
|
|
90
104
|
if (suffix) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
return {
|
|
106
|
+
text: suffix,
|
|
107
|
+
markSent: () => {
|
|
108
|
+
lastSentAnswerText = current;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
if (current.trim() && lastSentAnswerText && !current.startsWith(lastSentAnswerText)) {
|
|
97
114
|
const suffix = computeSharedPrefixTail(lastSentAnswerText, current);
|
|
98
115
|
ctx.log?.warn?.(
|
|
99
116
|
`[DingTalk][Markdown] answer prefix drift detected; falling back to shared-prefix tail ` +
|
|
100
|
-
|
|
117
|
+
`prevLen=${lastSentAnswerText.length} currentLen=${current.length}`,
|
|
101
118
|
);
|
|
102
|
-
lastSentAnswerText = "";
|
|
103
119
|
if (suffix) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
return {
|
|
121
|
+
text: suffix,
|
|
122
|
+
markSent: () => {
|
|
123
|
+
lastSentAnswerText = current;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
107
126
|
}
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
return {
|
|
128
|
+
text: current,
|
|
129
|
+
markSent: () => {
|
|
130
|
+
lastSentAnswerText = current;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const emitAnswerSuffix = async (text: string | undefined): Promise<void> => {
|
|
139
|
+
const suffix = prepareAnswerSuffix(text);
|
|
140
|
+
if (suffix) {
|
|
141
|
+
await sendMarkdownSegment(suffix.text);
|
|
142
|
+
suffix.markSent();
|
|
110
143
|
}
|
|
111
144
|
};
|
|
112
145
|
|
|
146
|
+
const prepareMarkdownImageAttachments = async (
|
|
147
|
+
mediaUrls: string[],
|
|
148
|
+
): Promise<{
|
|
149
|
+
imageMarkdown: string[];
|
|
150
|
+
passthroughMediaUrls: string[];
|
|
151
|
+
cleanups: Array<() => Promise<void>>;
|
|
152
|
+
}> => {
|
|
153
|
+
const imageMarkdown: string[] = [];
|
|
154
|
+
const passthroughMediaUrls: string[] = [];
|
|
155
|
+
const cleanups: Array<() => Promise<void>> = [];
|
|
156
|
+
|
|
157
|
+
for (const rawMediaUrl of mediaUrls) {
|
|
158
|
+
const preparedMedia = await prepareMediaInput(
|
|
159
|
+
rawMediaUrl,
|
|
160
|
+
ctx.log,
|
|
161
|
+
ctx.config.mediaUrlAllowlist,
|
|
162
|
+
);
|
|
163
|
+
const actualMediaPath = preparedMedia.cleanup
|
|
164
|
+
? preparedMedia.path
|
|
165
|
+
: resolveRelativePath(preparedMedia.path);
|
|
166
|
+
const mediaType = resolveOutboundMediaType({
|
|
167
|
+
mediaPath: actualMediaPath,
|
|
168
|
+
asVoice: false,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (mediaType === "image") {
|
|
172
|
+
imageMarkdown.push(renderMarkdownImage(actualMediaPath));
|
|
173
|
+
if (preparedMedia.cleanup) {
|
|
174
|
+
cleanups.push(preparedMedia.cleanup);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
await preparedMedia.cleanup?.();
|
|
178
|
+
passthroughMediaUrls.push(rawMediaUrl);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { imageMarkdown, passthroughMediaUrls, cleanups };
|
|
183
|
+
};
|
|
184
|
+
|
|
113
185
|
return {
|
|
114
186
|
getReplyOptions(): ReplyOptions {
|
|
115
187
|
return {
|
|
116
188
|
disableBlockStreaming: ctx.disableBlockStreaming === true,
|
|
189
|
+
// DingTalk markdown/sessionWebhook mode owns the visible reply surface.
|
|
190
|
+
// Keep runtime final replies on this strategy even when group chats
|
|
191
|
+
// default source replies to message-tool-only.
|
|
192
|
+
sourceReplyDeliveryMode: "automatic",
|
|
117
193
|
};
|
|
118
194
|
},
|
|
119
195
|
|
|
120
196
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
197
|
+
let answerTextSentWithImages = false;
|
|
198
|
+
let toolTextSentWithImages = false;
|
|
199
|
+
|
|
121
200
|
if (payload.mediaUrls.length > 0) {
|
|
122
|
-
|
|
123
|
-
|
|
201
|
+
const prepared =
|
|
202
|
+
payload.audioAsVoice === true
|
|
203
|
+
? {
|
|
204
|
+
imageMarkdown: [],
|
|
205
|
+
passthroughMediaUrls: payload.mediaUrls,
|
|
206
|
+
cleanups: [],
|
|
207
|
+
}
|
|
208
|
+
: await prepareMarkdownImageAttachments(payload.mediaUrls);
|
|
209
|
+
try {
|
|
210
|
+
if (prepared.passthroughMediaUrls.length > 0) {
|
|
211
|
+
await ctx.deliverMedia(prepared.passthroughMediaUrls, {
|
|
212
|
+
audioAsVoice: payload.audioAsVoice,
|
|
213
|
+
});
|
|
214
|
+
sentVisibleContent = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (prepared.imageMarkdown.length > 0) {
|
|
218
|
+
const answerSuffix =
|
|
219
|
+
payload.kind === "block" || payload.kind === "final"
|
|
220
|
+
? prepareAnswerSuffix(payload.text)
|
|
221
|
+
: typeof payload.text === "string"
|
|
222
|
+
? { text: renderQuotedSegment(payload.text), markSent: () => {} }
|
|
223
|
+
: null;
|
|
224
|
+
const markdownParts = [answerSuffix?.text || "", ...prepared.imageMarkdown].filter(
|
|
225
|
+
(part) => part.trim().length > 0,
|
|
226
|
+
);
|
|
227
|
+
if (markdownParts.length > 0) {
|
|
228
|
+
await sendMarkdownSegment(markdownParts.join("\n\n"));
|
|
229
|
+
answerSuffix?.markSent();
|
|
230
|
+
}
|
|
231
|
+
answerTextSentWithImages = payload.kind === "block" || payload.kind === "final";
|
|
232
|
+
toolTextSentWithImages = payload.kind === "tool";
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
for (const cleanup of prepared.cleanups) {
|
|
236
|
+
await cleanup();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
124
239
|
}
|
|
125
240
|
|
|
126
241
|
if (payload.kind === "tool") {
|
|
242
|
+
if (toolTextSentWithImages) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
127
245
|
const text = typeof payload.text === "string" ? payload.text : "";
|
|
128
246
|
if (!text.trim()) {
|
|
129
247
|
return;
|
|
@@ -133,8 +251,9 @@ export function createMarkdownReplyStrategy(
|
|
|
133
251
|
}
|
|
134
252
|
|
|
135
253
|
if (
|
|
136
|
-
(payload.kind === "block" || payload.kind === "final")
|
|
137
|
-
|
|
254
|
+
(payload.kind === "block" || payload.kind === "final") &&
|
|
255
|
+
typeof payload.text === "string" &&
|
|
256
|
+
!answerTextSentWithImages
|
|
138
257
|
) {
|
|
139
258
|
await emitAnswerSuffix(payload.text);
|
|
140
259
|
}
|
|
@@ -16,6 +16,8 @@ export type InternalReplyStrategyConfig = DingTalkConfig & {
|
|
|
16
16
|
cardStreamReasoning?: boolean;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
export type SourceReplyDeliveryMode = "automatic" | "message_tool_only";
|
|
20
|
+
|
|
19
21
|
// ---- Public interfaces ----
|
|
20
22
|
|
|
21
23
|
export interface DeliverPayload {
|
|
@@ -33,6 +35,7 @@ export interface DeliverPayload {
|
|
|
33
35
|
|
|
34
36
|
export interface ReplyOptions {
|
|
35
37
|
disableBlockStreaming: boolean;
|
|
38
|
+
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
|
36
39
|
onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
|
|
37
40
|
onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
|
|
38
41
|
onAssistantMessageStart?: () => void | Promise<void>;
|
package/src/send-service.ts
CHANGED
|
@@ -182,6 +182,10 @@ function buildPersistedOutboundText(text: string, options: SendMessageOptions):
|
|
|
182
182
|
return text;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
function shouldRouteSessionMediaViaProactive(mediaType?: string | null): mediaType is "voice" | "video" | "file" {
|
|
186
|
+
return mediaType === "voice" || mediaType === "video" || mediaType === "file";
|
|
187
|
+
}
|
|
188
|
+
|
|
185
189
|
const DINGTALK_TEXT_CHUNK_LIMIT = 3800;
|
|
186
190
|
const CARD_MEDIA_CONTROLLER_ATTACH_WAIT_MS = 150;
|
|
187
191
|
const CARD_MEDIA_CONTROLLER_ATTACH_POLL_MS = 25;
|
|
@@ -726,55 +730,30 @@ export async function sendBySession(
|
|
|
726
730
|
const token = await getAccessToken(config, options.log);
|
|
727
731
|
const log = options.log || getLogger();
|
|
728
732
|
|
|
729
|
-
//
|
|
733
|
+
// Keep session webhooks on text/markdown. Images can render through markdown
|
|
734
|
+
// media references; other media types are routed by sendMessage via OpenAPI.
|
|
730
735
|
if (options.mediaPath && options.mediaType) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (options.mediaType === "image") {
|
|
739
|
-
body = { msgtype: "image", image: { media_id: mediaId } };
|
|
740
|
-
} else if (options.mediaType === "voice") {
|
|
741
|
-
const durationMs = uploadedDurationMs
|
|
742
|
-
?? await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
|
|
743
|
-
body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
|
|
744
|
-
log?.debug?.(
|
|
745
|
-
`[DingTalk] Sending session voice message mediaId=${mediaId} durationMs=${durationMs}`,
|
|
746
|
-
);
|
|
747
|
-
} else if (options.mediaType === "video") {
|
|
748
|
-
body = { msgtype: "video", video: { media_id: mediaId } };
|
|
749
|
-
} else if (options.mediaType === "file") {
|
|
750
|
-
body = { msgtype: "file", file: { media_id: mediaId } };
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
if (body) {
|
|
754
|
-
const result = await axios({
|
|
755
|
-
url: sessionWebhook,
|
|
756
|
-
method: "POST",
|
|
757
|
-
data: body,
|
|
758
|
-
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
759
|
-
...getProxyBypassOption(config),
|
|
760
|
-
});
|
|
736
|
+
if (options.mediaType === "image") {
|
|
737
|
+
const uploadResult = await uploadMedia(config, options.mediaPath, options.mediaType, log, {
|
|
738
|
+
mediaLocalRoots: options.mediaLocalRoots,
|
|
739
|
+
});
|
|
740
|
+
if (uploadResult) {
|
|
741
|
+
const imageMarkdown = ``;
|
|
742
|
+
text = text ? `${text}\n\n${imageMarkdown}` : imageMarkdown;
|
|
761
743
|
log?.debug?.(
|
|
762
|
-
`[DingTalk] Session webhook
|
|
744
|
+
`[DingTalk] Session webhook image will be delivered as markdown media reference mediaId=${uploadResult.mediaId}`,
|
|
763
745
|
);
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
`[DingTalk] Session webhook ${body.msgtype} response missing delivery metadata; ` +
|
|
769
|
-
summarizeSessionWebhookResponse(result.data),
|
|
770
|
-
);
|
|
771
|
-
}
|
|
772
|
-
return result.data;
|
|
746
|
+
} else {
|
|
747
|
+
const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(媒体发送失败)";
|
|
748
|
+
text = `${text}\n\n📎 媒体发送失败,兜底链接/路径:${mediaHint}`.trim();
|
|
749
|
+
log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
|
|
773
750
|
}
|
|
774
751
|
} else {
|
|
775
752
|
const mediaHint = options.mediaUrl || options.mediaPath || options.filePath || "(媒体发送失败)";
|
|
776
|
-
text = `${text}\n\n📎
|
|
777
|
-
log?.warn?.(
|
|
753
|
+
text = `${text}\n\n📎 当前会话无法直接发送 ${options.mediaType},兜底链接/路径:${mediaHint}`.trim();
|
|
754
|
+
log?.warn?.(
|
|
755
|
+
`[DingTalk] Session webhook does not support native ${options.mediaType} replies; falling back to text description`,
|
|
756
|
+
);
|
|
778
757
|
}
|
|
779
758
|
}
|
|
780
759
|
|
|
@@ -835,6 +814,51 @@ export async function sendMessage(
|
|
|
835
814
|
const messageType = config.messageType || "markdown";
|
|
836
815
|
const log = options.log || getLogger();
|
|
837
816
|
|
|
817
|
+
if (options.sessionWebhook && options.mediaPath && shouldRouteSessionMediaViaProactive(options.mediaType)) {
|
|
818
|
+
log?.debug?.(
|
|
819
|
+
`[DingTalk] Session webhook does not support ${options.mediaType} replies reliably; ` +
|
|
820
|
+
"using proactive media API instead",
|
|
821
|
+
);
|
|
822
|
+
const proactiveMediaResult = await sendProactiveMedia(
|
|
823
|
+
config,
|
|
824
|
+
conversationId,
|
|
825
|
+
options.mediaPath,
|
|
826
|
+
options.mediaType,
|
|
827
|
+
options,
|
|
828
|
+
);
|
|
829
|
+
if (!proactiveMediaResult.ok) {
|
|
830
|
+
log?.warn?.(
|
|
831
|
+
`[DingTalk] Proactive ${options.mediaType} reply failed; falling back to session markdown: ` +
|
|
832
|
+
(proactiveMediaResult.error || "unknown"),
|
|
833
|
+
);
|
|
834
|
+
const data = await sendBySession(config, options.sessionWebhook, text, options);
|
|
835
|
+
const delivery = extractOutboundDeliveryMetadata(data);
|
|
836
|
+
const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
|
|
837
|
+
const persistedText = buildPersistedOutboundText(text, options);
|
|
838
|
+
persistOutboundMessageContext({
|
|
839
|
+
storePath: options.storePath,
|
|
840
|
+
accountId: options.accountId,
|
|
841
|
+
conversationId: options.conversationId || conversationId,
|
|
842
|
+
text: persistedText,
|
|
843
|
+
messageType: "outbound-media",
|
|
844
|
+
quotedRef: options.quotedRef,
|
|
845
|
+
log,
|
|
846
|
+
...DEFAULT_OUTBOUND_SENDER,
|
|
847
|
+
chatType: inferConversationChatType(options.conversationId || conversationId),
|
|
848
|
+
delivery: {
|
|
849
|
+
...delivery,
|
|
850
|
+
kind: "session",
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
return { ok: true, data, messageId };
|
|
854
|
+
}
|
|
855
|
+
return {
|
|
856
|
+
ok: true,
|
|
857
|
+
data: proactiveMediaResult.data,
|
|
858
|
+
messageId: proactiveMediaResult.messageId,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
838
862
|
if (messageType === "card" && options.card && !options.forceMarkdown) {
|
|
839
863
|
const card = options.card;
|
|
840
864
|
if (isCardInTerminalState(card.state)) {
|
|
@@ -858,28 +882,6 @@ export async function sendMessage(
|
|
|
858
882
|
}
|
|
859
883
|
}
|
|
860
884
|
|
|
861
|
-
if (options.sessionWebhook && options.mediaPath && options.mediaType === "voice") {
|
|
862
|
-
log?.debug?.(
|
|
863
|
-
"[DingTalk] Session webhook does not support voice replies reliably; " +
|
|
864
|
-
"using proactive media API for this voice response",
|
|
865
|
-
);
|
|
866
|
-
const proactiveVoiceResult = await sendProactiveMedia(
|
|
867
|
-
config,
|
|
868
|
-
conversationId,
|
|
869
|
-
options.mediaPath,
|
|
870
|
-
options.mediaType,
|
|
871
|
-
options,
|
|
872
|
-
);
|
|
873
|
-
if (!proactiveVoiceResult.ok) {
|
|
874
|
-
return { ok: false, error: proactiveVoiceResult.error || "Voice reply send failed" };
|
|
875
|
-
}
|
|
876
|
-
return {
|
|
877
|
-
ok: true,
|
|
878
|
-
data: proactiveVoiceResult.data,
|
|
879
|
-
messageId: proactiveVoiceResult.messageId,
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
|
|
883
885
|
if (options.sessionWebhook) {
|
|
884
886
|
const data = await sendBySession(config, options.sessionWebhook, text, options);
|
|
885
887
|
const delivery = extractOutboundDeliveryMetadata(data);
|