@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.
@@ -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
  }
@@ -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 "axios";
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?: { mediaLocalRoots?: string[] },
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
- mediaLocalRoots: options?.mediaLocalRoots,
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 "./types";
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 "axios";
4
- import { getAccessToken } from "./auth";
5
- import { getDingTalkRuntime } from "./runtime";
6
- import type { DingTalkConfig, Logger, MediaFile } from "./types";
7
- import { formatDingTalkErrorPayload, formatDingTalkErrorPayloadLog } from "./utils";
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
- "Create an AI card template in DingTalk Developer Console:",
245
- "https://open-dev.dingtalk.com/fe/card",
246
- "1. Go to 'My Templates' > 'Create Template'",
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 Setup",
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 FILE_ONLY_FALLBACK_ANSWER = "附件已发送,请查收。";
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 ? FILE_ONLY_FALLBACK_ANSWER : undefined);
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: intermediate blocks are unused card updates go through
43
- // onPartialReply (real-time) or deliver(final) -> finishAICard.
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
- await controller.notifyNewAssistantTurn();
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 controller.updateThinking(payload.text);
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
- // ---- block: only handle media (text blocks are unused) ----
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 finalText = getRenderedTimeline() || "✅ Done";
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 controller.getFinalAnswerContent()
215
- || finalTextForFallback
216
- || (sawFinalDelivery ? FILE_ONLY_FALLBACK_ANSWER : undefined);
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
- * 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
122
  await ctx.deliverMedia(payload.mediaUrls);
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
  }
@@ -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 -----------------------------------------------------