@soimy/dingtalk 3.5.1 → 3.5.3

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