@soimy/dingtalk 3.5.0 → 3.5.2
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 +7 -1
- package/package.json +14 -3
- package/src/ack-reaction-service.ts +1 -1
- package/src/auth.ts +1 -1
- package/src/card/card-action-handler.ts +62 -0
- package/src/card/card-run-registry.ts +118 -0
- package/src/card/card-stop-handler.ts +94 -0
- package/src/card/card-template.ts +20 -0
- package/src/card/reasoning-block-assembler.ts +157 -0
- package/src/card-callback-service.ts +90 -8
- package/src/card-draft-controller.ts +32 -0
- package/src/card-service.ts +189 -128
- package/src/channel.ts +82 -53
- package/src/command/card-stop-command.ts +78 -0
- package/src/command/inbound-command-dispatch-service.ts +464 -0
- package/src/docs-service.ts +5 -5
- package/src/http-client.ts +20 -0
- package/src/inbound-handler.ts +162 -465
- package/src/logger-context.ts +16 -2
- package/src/media-utils.ts +3 -3
- package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
- package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +5 -5
- package/src/onboarding.ts +4 -32
- package/src/reply-strategy-card.ts +85 -17
- package/src/reply-strategy-markdown.ts +123 -18
- package/src/reply-strategy.ts +5 -0
- package/src/send-service.ts +80 -17
- package/src/targeting/agent-routing.ts +11 -4
- package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
- package/src/types.ts +6 -1
- package/src/utils.ts +165 -0
package/src/logger-context.ts
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import type { Logger } from "./types";
|
|
2
2
|
|
|
3
3
|
let currentLogger: Logger | undefined;
|
|
4
|
+
const loggerByAccountId = new Map<string, Logger>();
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Persist current request logger for shared services invoked outside handler scope.
|
|
7
8
|
*/
|
|
8
|
-
export function setCurrentLogger(log?: Logger): void {
|
|
9
|
+
export function setCurrentLogger(log?: Logger, accountId?: string | null): void {
|
|
9
10
|
currentLogger = log;
|
|
11
|
+
const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
|
|
12
|
+
if (!normalizedAccountId) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (log) {
|
|
16
|
+
loggerByAccountId.set(normalizedAccountId, log);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
loggerByAccountId.delete(normalizedAccountId);
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* Read current logger bound by inbound handler.
|
|
14
24
|
*/
|
|
15
|
-
export function getLogger(): Logger | undefined {
|
|
25
|
+
export function getLogger(accountId?: string | null): Logger | undefined {
|
|
26
|
+
const normalizedAccountId = typeof accountId === "string" ? accountId.trim() : "";
|
|
27
|
+
if (normalizedAccountId) {
|
|
28
|
+
return loggerByAccountId.get(normalizedAccountId);
|
|
29
|
+
}
|
|
16
30
|
return currentLogger;
|
|
17
31
|
}
|
package/src/media-utils.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as path from "node:path";
|
|
|
11
11
|
import { promises as fsPromises } from "node:fs";
|
|
12
12
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
13
13
|
import { BlockList, isIP } from "node:net";
|
|
14
|
-
import axios from "
|
|
14
|
+
import axios from "./http-client";
|
|
15
15
|
import FormData from "form-data";
|
|
16
16
|
import type { DingTalkConfig, Logger } from "./types";
|
|
17
17
|
import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
|
|
@@ -26,7 +26,7 @@ interface PluginRuntimeWithMedia {
|
|
|
26
26
|
media?: {
|
|
27
27
|
loadWebMedia(
|
|
28
28
|
mediaPath: string,
|
|
29
|
-
options?: {
|
|
29
|
+
options?: { localRoots?: readonly string[] | "any" },
|
|
30
30
|
): Promise<{ buffer: Buffer | ArrayBuffer; fileName?: string; contentType?: string } | null>;
|
|
31
31
|
};
|
|
32
32
|
[key: string]: unknown;
|
|
@@ -667,7 +667,7 @@ async function readMediaBuffer(
|
|
|
667
667
|
}
|
|
668
668
|
|
|
669
669
|
const media = await rt.media.loadWebMedia(mediaPath, {
|
|
670
|
-
|
|
670
|
+
localRoots: options?.mediaLocalRoots,
|
|
671
671
|
});
|
|
672
672
|
|
|
673
673
|
if (!media || !media.buffer) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import type { AttachmentTextSource } from "
|
|
3
|
+
import type { AttachmentTextSource } from "../types";
|
|
4
4
|
|
|
5
5
|
const MAX_EXTRACTED_TEXT_CHARS = 6000;
|
|
6
6
|
const MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
|
-
import axios from "
|
|
4
|
-
import { getAccessToken } from "
|
|
5
|
-
import { getDingTalkRuntime } from "
|
|
6
|
-
import type { DingTalkConfig, Logger, MediaFile } from "
|
|
7
|
-
import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "
|
|
3
|
+
import axios from "../http-client";
|
|
4
|
+
import { getAccessToken } from "../auth";
|
|
5
|
+
import { getDingTalkRuntime } from "../runtime";
|
|
6
|
+
import type { DingTalkConfig, Logger, MediaFile } from "../types";
|
|
7
|
+
import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "../utils";
|
|
8
8
|
|
|
9
9
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
10
10
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
package/src/onboarding.ts
CHANGED
|
@@ -126,8 +126,6 @@ function applyAccountConfig(params: {
|
|
|
126
126
|
? { mediaUrlAllowlist: input.mediaUrlAllowlist }
|
|
127
127
|
: {}),
|
|
128
128
|
...(input.messageType ? { messageType: input.messageType } : {}),
|
|
129
|
-
...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
|
|
130
|
-
...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
|
|
131
129
|
...(typeof input.maxReconnectCycles === "number"
|
|
132
130
|
? { maxReconnectCycles: input.maxReconnectCycles }
|
|
133
131
|
: {}),
|
|
@@ -234,41 +232,17 @@ async function configureDingTalkAccount(params: {
|
|
|
234
232
|
initialValue: resolved.messageType === "card",
|
|
235
233
|
});
|
|
236
234
|
|
|
237
|
-
let cardTemplateId: string | undefined;
|
|
238
|
-
let cardTemplateKey: string | undefined;
|
|
239
235
|
let messageType: "markdown" | "card" = "markdown";
|
|
240
236
|
|
|
241
237
|
if (wantsCardMode) {
|
|
242
238
|
await prompter.note(
|
|
243
239
|
[
|
|
244
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"2. Select 'AI Card' scenario",
|
|
248
|
-
"3. Design your card and publish",
|
|
249
|
-
"4. Copy the Template ID (e.g., xxx.schema)",
|
|
240
|
+
"AI interactive card mode now uses the built-in DingTalk template contract.",
|
|
241
|
+
"No manual Template ID or content field configuration is required.",
|
|
242
|
+
"Legacy cardTemplateId/cardTemplateKey config is deprecated and ignored.",
|
|
250
243
|
].join("\n"),
|
|
251
|
-
"Card Template
|
|
244
|
+
"Built-in AI Card Template",
|
|
252
245
|
);
|
|
253
|
-
|
|
254
|
-
cardTemplateId =
|
|
255
|
-
String(
|
|
256
|
-
await prompter.text({
|
|
257
|
-
message: "Card Template ID",
|
|
258
|
-
placeholder: "xxxxx-xxxxx-xxxxx.schema",
|
|
259
|
-
initialValue: resolved.cardTemplateId ?? undefined,
|
|
260
|
-
}),
|
|
261
|
-
).trim() || undefined;
|
|
262
|
-
|
|
263
|
-
cardTemplateKey =
|
|
264
|
-
String(
|
|
265
|
-
await prompter.text({
|
|
266
|
-
message: "Card Template Key (content field name)",
|
|
267
|
-
placeholder: "content",
|
|
268
|
-
initialValue: resolved.cardTemplateKey ?? "content",
|
|
269
|
-
}),
|
|
270
|
-
).trim() || "content";
|
|
271
|
-
|
|
272
246
|
messageType = "card";
|
|
273
247
|
}
|
|
274
248
|
|
|
@@ -436,8 +410,6 @@ async function configureDingTalkAccount(params: {
|
|
|
436
410
|
displayNameResolution: displayNameResolutionValue as "disabled" | "all",
|
|
437
411
|
mediaUrlAllowlist,
|
|
438
412
|
messageType,
|
|
439
|
-
cardTemplateId,
|
|
440
|
-
cardTemplateKey,
|
|
441
413
|
maxReconnectCycles,
|
|
442
414
|
mediaMaxMb,
|
|
443
415
|
journalTTLDays,
|
|
@@ -10,54 +10,99 @@ import {
|
|
|
10
10
|
finishAICard,
|
|
11
11
|
isCardInTerminalState,
|
|
12
12
|
} from "./card-service";
|
|
13
|
+
import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
|
|
13
14
|
import { createCardDraftController } from "./card-draft-controller";
|
|
15
|
+
import { attachCardRunController } from "./card/card-run-registry";
|
|
14
16
|
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
15
17
|
import { sendBySession, sendMessage } from "./send-service";
|
|
16
18
|
import type { AICardInstance } from "./types";
|
|
17
19
|
import { AICardStatus } from "./types";
|
|
18
20
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
19
21
|
|
|
20
|
-
const
|
|
22
|
+
const EMPTY_FINAL_REPLY = "✅ Done";
|
|
21
23
|
|
|
22
24
|
export function createCardReplyStrategy(
|
|
23
|
-
ctx: ReplyStrategyContext & { card: AICardInstance },
|
|
25
|
+
ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
|
|
24
26
|
): ReplyStrategy {
|
|
25
|
-
const { card, config, log } = ctx;
|
|
27
|
+
const { card, config, log, isStopRequested } = ctx;
|
|
26
28
|
|
|
27
29
|
const controller = createCardDraftController({ card, log });
|
|
30
|
+
const reasoningAssembler = createReasoningBlockAssembler();
|
|
31
|
+
if (card.outTrackId) {
|
|
32
|
+
attachCardRunController(card.outTrackId, controller);
|
|
33
|
+
}
|
|
28
34
|
let finalTextForFallback: string | undefined;
|
|
29
35
|
let sawFinalDelivery = false;
|
|
30
36
|
|
|
31
37
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
32
|
-
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ?
|
|
38
|
+
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
33
39
|
return controller.getRenderedContent({
|
|
34
40
|
fallbackAnswer,
|
|
35
41
|
overrideAnswer: options.preferFinalAnswer ? finalTextForFallback : undefined,
|
|
36
42
|
});
|
|
37
43
|
};
|
|
38
44
|
|
|
45
|
+
const appendAssembledThinkingBlocks = async (blocks: string[]): Promise<void> => {
|
|
46
|
+
for (const block of blocks) {
|
|
47
|
+
if (!block.trim() || isStopRequested?.()) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
await controller.appendThinkingBlock(block);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ingestReasoningSnapshot = async (text: string | undefined): Promise<void> => {
|
|
55
|
+
const blocks = reasoningAssembler.ingestSnapshot(text);
|
|
56
|
+
if (
|
|
57
|
+
blocks.length === 0
|
|
58
|
+
&& typeof text === "string"
|
|
59
|
+
&& text.trim()
|
|
60
|
+
&& !text.trimStart().startsWith("Reasoning:")
|
|
61
|
+
) {
|
|
62
|
+
await appendAssembledThinkingBlocks([text.trim()]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
await appendAssembledThinkingBlocks(blocks);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const flushPendingReasoning = async (): Promise<void> => {
|
|
69
|
+
const blocks = reasoningAssembler.flushPendingAtBoundary();
|
|
70
|
+
await appendAssembledThinkingBlocks(blocks);
|
|
71
|
+
};
|
|
72
|
+
|
|
39
73
|
return {
|
|
40
74
|
getReplyOptions(): ReplyOptions {
|
|
41
75
|
return {
|
|
42
|
-
// Card mode
|
|
43
|
-
//
|
|
44
|
-
disableBlockStreaming: true,
|
|
76
|
+
// Card mode keeps runtime block streaming disabled, but still consumes
|
|
77
|
+
// reasoning blocks through explicit callbacks and delivery metadata.
|
|
78
|
+
disableBlockStreaming: ctx.disableBlockStreaming ?? true,
|
|
45
79
|
|
|
46
80
|
onAssistantMessageStart: async () => {
|
|
47
|
-
|
|
81
|
+
if (isStopRequested?.()) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const pendingReasoningBlocks = reasoningAssembler.flushPendingAtBoundary();
|
|
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;
|
|
48
93
|
},
|
|
49
94
|
|
|
50
95
|
onPartialReply: config.cardRealTimeStream
|
|
51
96
|
? async (payload) => {
|
|
52
|
-
if (payload.text) {
|
|
97
|
+
if (payload.text && !isStopRequested?.()) {
|
|
53
98
|
await controller.updateAnswer(payload.text);
|
|
54
99
|
}
|
|
55
100
|
}
|
|
56
101
|
: undefined,
|
|
57
102
|
|
|
58
103
|
onReasoningStream: async (payload) => {
|
|
59
|
-
if (payload.text) {
|
|
60
|
-
await
|
|
104
|
+
if (payload.text && !isStopRequested?.()) {
|
|
105
|
+
await ingestReasoningSnapshot(payload.text);
|
|
61
106
|
}
|
|
62
107
|
},
|
|
63
108
|
};
|
|
@@ -75,6 +120,7 @@ export function createCardReplyStrategy(
|
|
|
75
120
|
|
|
76
121
|
// ---- final: defer to finalize, just save text ----
|
|
77
122
|
if (payload.kind === "final") {
|
|
123
|
+
await flushPendingReasoning();
|
|
78
124
|
sawFinalDelivery = true;
|
|
79
125
|
log?.info?.(
|
|
80
126
|
`[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
|
|
@@ -99,6 +145,7 @@ export function createCardReplyStrategy(
|
|
|
99
145
|
log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
|
|
100
146
|
return;
|
|
101
147
|
}
|
|
148
|
+
await flushPendingReasoning();
|
|
102
149
|
log?.info?.(
|
|
103
150
|
`[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
|
|
104
151
|
);
|
|
@@ -106,7 +153,16 @@ export function createCardReplyStrategy(
|
|
|
106
153
|
return;
|
|
107
154
|
}
|
|
108
155
|
|
|
109
|
-
|
|
156
|
+
const isReasoningBlock = payload.isReasoning === true;
|
|
157
|
+
if (typeof textToSend === "string" && textToSend.trim()) {
|
|
158
|
+
if (isReasoningBlock) {
|
|
159
|
+
await ingestReasoningSnapshot(textToSend);
|
|
160
|
+
} else {
|
|
161
|
+
await controller.updateAnswer(textToSend);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
110
166
|
if (payload.mediaUrls.length > 0) {
|
|
111
167
|
await ctx.deliverMedia(payload.mediaUrls);
|
|
112
168
|
}
|
|
@@ -122,11 +178,21 @@ export function createCardReplyStrategy(
|
|
|
122
178
|
`lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
|
|
123
179
|
);
|
|
124
180
|
|
|
181
|
+
if (isStopRequested?.()) {
|
|
182
|
+
log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
125
186
|
if (card.state === AICardStatus.FINISHED) {
|
|
126
187
|
log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
|
|
127
188
|
return;
|
|
128
189
|
}
|
|
129
190
|
|
|
191
|
+
if (card.state === AICardStatus.STOPPED) {
|
|
192
|
+
log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
130
196
|
// Card failed -> markdown fallback (bypass sendMessage to avoid duplicate card).
|
|
131
197
|
if (card.state === AICardStatus.FAILED || controller.isFailed()) {
|
|
132
198
|
const fallbackText = getRenderedTimeline({ preferFinalAnswer: true })
|
|
@@ -156,13 +222,15 @@ export function createCardReplyStrategy(
|
|
|
156
222
|
|
|
157
223
|
// Normal finalize.
|
|
158
224
|
try {
|
|
225
|
+
await flushPendingReasoning();
|
|
159
226
|
await controller.flush();
|
|
160
227
|
await controller.waitForInFlight();
|
|
161
|
-
const
|
|
228
|
+
const renderedTimeline = getRenderedTimeline({ preferFinalAnswer: true });
|
|
229
|
+
const finalText = renderedTimeline || EMPTY_FINAL_REPLY;
|
|
162
230
|
controller.stop();
|
|
163
231
|
log?.info?.(
|
|
164
232
|
`[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
|
|
165
|
-
`source=${controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
233
|
+
`source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
166
234
|
`preview="${finalText.slice(0, 120)}"`,
|
|
167
235
|
);
|
|
168
236
|
await finishAICard(card, finalText, log, {
|
|
@@ -211,9 +279,9 @@ export function createCardReplyStrategy(
|
|
|
211
279
|
},
|
|
212
280
|
|
|
213
281
|
getFinalText(): string | undefined {
|
|
214
|
-
return
|
|
215
|
-
||
|
|
216
|
-
|| (sawFinalDelivery ?
|
|
282
|
+
return finalTextForFallback
|
|
283
|
+
|| controller.getFinalAnswerContent()
|
|
284
|
+
|| (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
217
285
|
},
|
|
218
286
|
};
|
|
219
287
|
}
|
|
@@ -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
122
|
await ctx.deliverMedia(payload.mediaUrls);
|
|
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
|
@@ -16,6 +16,7 @@ export interface DeliverPayload {
|
|
|
16
16
|
text?: string;
|
|
17
17
|
mediaUrls: string[];
|
|
18
18
|
kind: "block" | "final" | "tool";
|
|
19
|
+
isReasoning?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface ReplyOptions {
|
|
@@ -51,10 +52,14 @@ export interface ReplyStrategyContext {
|
|
|
51
52
|
isDirect: boolean;
|
|
52
53
|
accountId: string;
|
|
53
54
|
storePath: string;
|
|
55
|
+
disableBlockStreaming?: boolean;
|
|
56
|
+
sessionKey?: string;
|
|
57
|
+
sessionAgentId?: string;
|
|
54
58
|
groupId?: string;
|
|
55
59
|
log?: Logger;
|
|
56
60
|
replyQuotedRef?: QuotedRef;
|
|
57
61
|
deliverMedia: (urls: string[]) => Promise<void>;
|
|
62
|
+
isStopRequested?: () => boolean;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
// ---- Factory -----------------------------------------------------
|