@soimy/dingtalk 3.4.2 → 3.5.1

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/src/onboarding.ts CHANGED
@@ -115,9 +115,6 @@ function applyAccountConfig(params: {
115
115
  const payload: Partial<DingTalkConfig> = {
116
116
  ...(input.clientId ? { clientId: input.clientId } : {}),
117
117
  ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}),
118
- ...(input.robotCode ? { robotCode: input.robotCode } : {}),
119
- ...(input.corpId ? { corpId: input.corpId } : {}),
120
- ...(input.agentId ? { agentId: input.agentId } : {}),
121
118
  ...(input.dmPolicy ? { dmPolicy: input.dmPolicy } : {}),
122
119
  ...(input.groupPolicy ? { groupPolicy: input.groupPolicy } : {}),
123
120
  ...(input.allowFrom && input.allowFrom.length > 0 ? { allowFrom: input.allowFrom } : {}),
@@ -129,8 +126,6 @@ function applyAccountConfig(params: {
129
126
  ? { mediaUrlAllowlist: input.mediaUrlAllowlist }
130
127
  : {}),
131
128
  ...(input.messageType ? { messageType: input.messageType } : {}),
132
- ...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
133
- ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
134
129
  ...(typeof input.maxReconnectCycles === "number"
135
130
  ? { maxReconnectCycles: input.maxReconnectCycles }
136
131
  : {}),
@@ -192,7 +187,6 @@ function applyGenericSetupInput(params: {
192
187
  clientId: typeof params.input.token === "string" ? params.input.token.trim() : undefined,
193
188
  clientSecret:
194
189
  typeof params.input.password === "string" ? params.input.password.trim() : undefined,
195
- robotCode: typeof params.input.code === "string" ? params.input.code.trim() : undefined,
196
190
  },
197
191
  });
198
192
  }
@@ -233,84 +227,22 @@ async function configureDingTalkAccount(params: {
233
227
  validate: (value: string) => (String(value ?? "").trim() ? undefined : "Required"),
234
228
  });
235
229
 
236
- const wantsFullConfig = await prompter.confirm({
237
- message: "Configure robot code, corp ID, and agent ID? (recommended for full features)",
238
- initialValue: false,
239
- });
240
-
241
- let robotCode: string | undefined;
242
- let corpId: string | undefined;
243
- let agentId: string | undefined;
244
-
245
- if (wantsFullConfig) {
246
- robotCode =
247
- String(
248
- await prompter.text({
249
- message: "Robot Code",
250
- placeholder: "dingxxxxxxxx",
251
- initialValue: resolved.robotCode ?? undefined,
252
- }),
253
- ).trim() || undefined;
254
-
255
- corpId =
256
- String(
257
- await prompter.text({
258
- message: "Corp ID",
259
- placeholder: "dingxxxxxxxx",
260
- initialValue: resolved.corpId ?? undefined,
261
- }),
262
- ).trim() || undefined;
263
-
264
- agentId =
265
- String(
266
- await prompter.text({
267
- message: "Agent ID",
268
- placeholder: "123456789",
269
- initialValue: resolved.agentId ? String(resolved.agentId) : undefined,
270
- }),
271
- ).trim() || undefined;
272
- }
273
-
274
230
  const wantsCardMode = await prompter.confirm({
275
231
  message: "Enable AI interactive card mode? (for streaming AI responses)",
276
232
  initialValue: resolved.messageType === "card",
277
233
  });
278
234
 
279
- let cardTemplateId: string | undefined;
280
- let cardTemplateKey: string | undefined;
281
235
  let messageType: "markdown" | "card" = "markdown";
282
236
 
283
237
  if (wantsCardMode) {
284
238
  await prompter.note(
285
239
  [
286
- "Create an AI card template in DingTalk Developer Console:",
287
- "https://open-dev.dingtalk.com/fe/card",
288
- "1. Go to 'My Templates' > 'Create Template'",
289
- "2. Select 'AI Card' scenario",
290
- "3. Design your card and publish",
291
- "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.",
292
243
  ].join("\n"),
293
- "Card Template Setup",
244
+ "Built-in AI Card Template",
294
245
  );
295
-
296
- cardTemplateId =
297
- String(
298
- await prompter.text({
299
- message: "Card Template ID",
300
- placeholder: "xxxxx-xxxxx-xxxxx.schema",
301
- initialValue: resolved.cardTemplateId ?? undefined,
302
- }),
303
- ).trim() || undefined;
304
-
305
- cardTemplateKey =
306
- String(
307
- await prompter.text({
308
- message: "Card Template Key (content field name)",
309
- placeholder: "content",
310
- initialValue: resolved.cardTemplateKey ?? "content",
311
- }),
312
- ).trim() || "content";
313
-
314
246
  messageType = "card";
315
247
  }
316
248
 
@@ -471,9 +403,6 @@ async function configureDingTalkAccount(params: {
471
403
  input: {
472
404
  clientId: String(clientId).trim(),
473
405
  clientSecret: String(clientSecret).trim(),
474
- robotCode,
475
- corpId,
476
- agentId,
477
406
  dmPolicy: dmPolicyValue as "open" | "allowlist",
478
407
  groupPolicy: groupPolicyValue as "open" | "allowlist" | "disabled",
479
408
  allowFrom,
@@ -481,8 +410,6 @@ async function configureDingTalkAccount(params: {
481
410
  displayNameResolution: displayNameResolutionValue as "disabled" | "all",
482
411
  mediaUrlAllowlist,
483
412
  messageType,
484
- cardTemplateId,
485
- cardTemplateKey,
486
413
  maxReconnectCycles,
487
414
  mediaMaxMb,
488
415
  journalTTLDays,
@@ -8,23 +8,37 @@
8
8
 
9
9
  import {
10
10
  finishAICard,
11
- formatContentForCard,
12
11
  isCardInTerminalState,
13
12
  } from "./card-service";
14
13
  import { createCardDraftController } from "./card-draft-controller";
14
+ import { attachCardRunController } from "./card/card-run-registry";
15
15
  import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
16
16
  import { sendBySession, sendMessage } from "./send-service";
17
17
  import type { AICardInstance } from "./types";
18
18
  import { AICardStatus } from "./types";
19
19
  import { formatDingTalkErrorPayloadLog } from "./utils";
20
20
 
21
+ const FILE_ONLY_FALLBACK_ANSWER = "附件已发送,请查收。";
22
+
21
23
  export function createCardReplyStrategy(
22
- ctx: ReplyStrategyContext & { card: AICardInstance },
24
+ ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
23
25
  ): ReplyStrategy {
24
- const { card, config, log } = ctx;
26
+ const { card, config, log, isStopRequested } = ctx;
25
27
 
26
28
  const controller = createCardDraftController({ card, log });
29
+ if (card.outTrackId) {
30
+ attachCardRunController(card.outTrackId, controller);
31
+ }
27
32
  let finalTextForFallback: string | undefined;
33
+ let sawFinalDelivery = false;
34
+
35
+ const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
36
+ const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? FILE_ONLY_FALLBACK_ANSWER : undefined);
37
+ return controller.getRenderedContent({
38
+ fallbackAnswer,
39
+ overrideAnswer: options.preferFinalAnswer ? finalTextForFallback : undefined,
40
+ });
41
+ };
28
42
 
29
43
  return {
30
44
  getReplyOptions(): ReplyOptions {
@@ -33,21 +47,24 @@ export function createCardReplyStrategy(
33
47
  // onPartialReply (real-time) or deliver(final) -> finishAICard.
34
48
  disableBlockStreaming: true,
35
49
 
36
- onAssistantMessageStart: () => {
37
- controller.notifyNewAssistantTurn();
50
+ onAssistantMessageStart: async () => {
51
+ if (isStopRequested?.()) {
52
+ return;
53
+ }
54
+ await controller.notifyNewAssistantTurn();
38
55
  },
39
56
 
40
57
  onPartialReply: config.cardRealTimeStream
41
- ? (payload) => {
42
- if (payload.text) {
43
- controller.updateAnswer(payload.text);
58
+ ? async (payload) => {
59
+ if (payload.text && !isStopRequested?.()) {
60
+ await controller.updateAnswer(payload.text);
44
61
  }
45
62
  }
46
63
  : undefined,
47
64
 
48
- onReasoningStream: (payload) => {
49
- if (payload.text) {
50
- controller.updateReasoning(payload.text);
65
+ onReasoningStream: async (payload) => {
66
+ if (payload.text && !isStopRequested?.()) {
67
+ await controller.updateThinking(payload.text);
51
68
  }
52
69
  },
53
70
  };
@@ -65,6 +82,7 @@ export function createCardReplyStrategy(
65
82
 
66
83
  // ---- final: defer to finalize, just save text ----
67
84
  if (payload.kind === "final") {
85
+ sawFinalDelivery = true;
68
86
  log?.info?.(
69
87
  `[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
70
88
  `textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
@@ -88,27 +106,10 @@ export function createCardReplyStrategy(
88
106
  log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
89
107
  return;
90
108
  }
91
- await controller.flush();
92
- await controller.waitForInFlight();
93
109
  log?.info?.(
94
110
  `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
95
111
  );
96
- const toolText = typeof textToSend === "string" ? formatContentForCard(textToSend, "tool") : "";
97
- if (toolText) {
98
- const sendResult = await sendMessage(ctx.config, ctx.to, toolText, {
99
- sessionWebhook: ctx.sessionWebhook,
100
- atUserId: !ctx.isDirect ? ctx.senderId : null,
101
- log,
102
- card,
103
- accountId: ctx.accountId,
104
- storePath: ctx.storePath,
105
- conversationId: ctx.groupId,
106
- cardUpdateMode: "append",
107
- });
108
- if (!sendResult.ok) {
109
- throw new Error(sendResult.error || "Tool stream send failed");
110
- }
111
- }
112
+ await controller.appendTool(textToSend ?? "");
112
113
  return;
113
114
  }
114
115
 
@@ -128,14 +129,24 @@ export function createCardReplyStrategy(
128
129
  `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
129
130
  );
130
131
 
132
+ if (isStopRequested?.()) {
133
+ log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
134
+ return;
135
+ }
136
+
131
137
  if (card.state === AICardStatus.FINISHED) {
132
138
  log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
133
139
  return;
134
140
  }
135
141
 
142
+ if (card.state === AICardStatus.STOPPED) {
143
+ log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
144
+ return;
145
+ }
146
+
136
147
  // Card failed -> markdown fallback (bypass sendMessage to avoid duplicate card).
137
148
  if (card.state === AICardStatus.FAILED || controller.isFailed()) {
138
- const fallbackText = finalTextForFallback
149
+ const fallbackText = getRenderedTimeline({ preferFinalAnswer: true })
139
150
  || controller.getLastAnswerContent()
140
151
  || controller.getLastContent()
141
152
  || card.lastStreamedContent;
@@ -164,13 +175,11 @@ export function createCardReplyStrategy(
164
175
  try {
165
176
  await controller.flush();
166
177
  await controller.waitForInFlight();
178
+ const finalText = getRenderedTimeline() || "✅ Done";
167
179
  controller.stop();
168
- const finalText = controller.getLastAnswerContent()
169
- || finalTextForFallback
170
- || "✅ Done";
171
180
  log?.info?.(
172
181
  `[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
173
- `source=${controller.getLastAnswerContent() ? "lastAnswerContent" : finalTextForFallback ? "finalTextForFallback" : "fallbackDone"} ` +
182
+ `source=${controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
174
183
  `preview="${finalText.slice(0, 120)}"`,
175
184
  );
176
185
  await finishAICard(card, finalText, log, {
@@ -219,7 +228,9 @@ export function createCardReplyStrategy(
219
228
  },
220
229
 
221
230
  getFinalText(): string | undefined {
222
- return controller.getLastAnswerContent() || finalTextForFallback;
231
+ return controller.getFinalAnswerContent()
232
+ || finalTextForFallback
233
+ || (sawFinalDelivery ? FILE_ONLY_FALLBACK_ANSWER : undefined);
223
234
  },
224
235
  };
225
236
  }
@@ -20,9 +20,9 @@ export interface DeliverPayload {
20
20
 
21
21
  export interface ReplyOptions {
22
22
  disableBlockStreaming: boolean;
23
- onPartialReply?: (payload: { text?: string }) => void;
24
- onReasoningStream?: (payload: { text?: string }) => void;
25
- onAssistantMessageStart?: () => void;
23
+ onPartialReply?: (payload: { text?: string }) => void | Promise<void>;
24
+ onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
25
+ onAssistantMessageStart?: () => void | Promise<void>;
26
26
  }
27
27
 
28
28
  export interface ReplyStrategy {
@@ -55,6 +55,7 @@ export interface ReplyStrategyContext {
55
55
  log?: Logger;
56
56
  replyQuotedRef?: QuotedRef;
57
57
  deliverMedia: (urls: string[]) => Promise<void>;
58
+ isStopRequested?: () => boolean;
58
59
  }
59
60
 
60
61
  // ---- Factory -----------------------------------------------------
@@ -4,13 +4,17 @@ import { getAccessToken } from "./auth";
4
4
  import {
5
5
  isCardInTerminalState,
6
6
  sendProactiveCardText,
7
- streamAICard,
8
7
  } from "./card-service";
9
- import { stripTargetPrefix } from "./config";
8
+ import { resolveRobotCode, stripTargetPrefix } from "./config";
10
9
  import { getLogger } from "./logger-context";
11
- import { getVoiceDurationMs, uploadMedia as uploadMediaUtil, type UploadMediaResult } from "./media-utils";
10
+ import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
12
11
  import { convertMarkdownTablesToPlainText, detectMarkdownAndExtractTitle } from "./message-utils";
13
- import { DEFAULT_MESSAGE_CONTEXT_TTL_DAYS, upsertOutboundMessageContext } from "./message-context-store";
12
+ import {
13
+ DEFAULT_MESSAGE_CONTEXT_TTL_DAYS,
14
+ DEFAULT_OUTBOUND_SENDER,
15
+ inferConversationChatType,
16
+ upsertOutboundMessageContext,
17
+ } from "./message-context-store";
14
18
  import { resolveOriginalPeerId } from "./peer-id-registry";
15
19
  import {
16
20
  deleteProactiveRiskObservation,
@@ -18,6 +22,7 @@ import {
18
22
  recordProactiveRiskObservation,
19
23
  } from "./proactive-risk-registry";
20
24
  import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
25
+ import type { UploadMediaResult } from "./media-utils";
21
26
  import type {
22
27
  AICardInstance,
23
28
  AxiosResponse,
@@ -29,7 +34,6 @@ import type {
29
34
  SendMessageOptions,
30
35
  SessionWebhookResponse,
31
36
  } from "./types";
32
- import { AICardStatus } from "./types";
33
37
 
34
38
  export { detectMediaTypeFromExtension } from "./media-utils";
35
39
 
@@ -78,6 +82,9 @@ function persistOutboundMessageContext(params: {
78
82
  createdAt?: number;
79
83
  quotedRef?: QuotedRef;
80
84
  log?: Logger;
85
+ senderId?: string;
86
+ senderName?: string;
87
+ chatType?: "direct" | "group";
81
88
  delivery: {
82
89
  messageId?: string;
83
90
  processQueryKey?: string;
@@ -101,6 +108,9 @@ function persistOutboundMessageContext(params: {
101
108
  createdAt: params.createdAt ?? Date.now(),
102
109
  text: params.text,
103
110
  messageType: params.messageType,
111
+ senderId: params.senderId,
112
+ senderName: params.senderName,
113
+ chatType: params.chatType,
104
114
  ttlMs: DEFAULT_MESSAGE_CONTEXT_TTL_DAYS * 24 * 60 * 60 * 1000,
105
115
  topic: null,
106
116
  quotedRef: params.quotedRef,
@@ -118,26 +128,6 @@ function buildPersistedOutboundText(text: string, options: SendMessageOptions):
118
128
  return text;
119
129
  }
120
130
 
121
- function composeCardContentForAppend(previous: string | undefined, incoming: string): string {
122
- const prev = previous ?? "";
123
- if (!prev) {
124
- return incoming;
125
- }
126
- if (!incoming) {
127
- return prev;
128
- }
129
- if (incoming.startsWith(prev)) {
130
- return incoming;
131
- }
132
- if (prev.endsWith(incoming)) {
133
- return prev;
134
- }
135
- if (prev.endsWith("\n") || incoming.startsWith("\n")) {
136
- return `${prev}${incoming}`;
137
- }
138
- return `${prev}${incoming}`;
139
- }
140
-
141
131
  const DINGTALK_TEXT_CHUNK_LIMIT = 3800;
142
132
 
143
133
  function splitMarkdownChunks(text: string, limit = DINGTALK_TEXT_CHUNK_LIMIT): string[] {
@@ -203,7 +193,6 @@ function isProactivePermissionOrScopeError(code: string | null): boolean {
203
193
 
204
194
  /**
205
195
  * Wrapper to upload media with shared getAccessToken binding.
206
- * Supports sandbox/container paths via mediaLocalRoots option.
207
196
  */
208
197
  export async function uploadMedia(
209
198
  config: DingTalkConfig,
@@ -236,7 +225,7 @@ export async function sendProactiveTextOrMarkdown(
236
225
 
237
226
  // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
238
227
  const messageType = config.messageType || "markdown";
239
- if (messageType === "card" && config.cardTemplateId && !options.forceMarkdown) {
228
+ if (messageType === "card" && !options.forceMarkdown) {
240
229
  log?.debug?.(
241
230
  `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
242
231
  );
@@ -277,7 +266,7 @@ export async function sendProactiveTextOrMarkdown(
277
266
  : JSON.stringify({ content: normalizedText });
278
267
 
279
268
  const payload: ProactiveMessagePayload = {
280
- robotCode: config.robotCode || config.clientId,
269
+ robotCode: resolveRobotCode(config),
281
270
  msgKey,
282
271
  msgParam,
283
272
  };
@@ -343,7 +332,7 @@ export async function sendProactiveMedia(
343
332
  target: string,
344
333
  mediaPath: string,
345
334
  mediaType: "image" | "voice" | "video" | "file",
346
- options: SendMessageOptions & { accountId?: string; mediaLocalRoots?: string[] } = {},
335
+ options: SendMessageOptions & { accountId?: string } = {},
347
336
  ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
348
337
  const log = options.log || getLogger();
349
338
 
@@ -355,7 +344,7 @@ export async function sendProactiveMedia(
355
344
  if (!uploadResult) {
356
345
  return { ok: false, error: "Failed to upload media" };
357
346
  }
358
- const { mediaId, buffer: uploadedBuffer } = uploadResult;
347
+ const { mediaId, buffer } = uploadResult;
359
348
 
360
349
  const token = await getAccessToken(config, log);
361
350
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
@@ -376,10 +365,7 @@ export async function sendProactiveMedia(
376
365
  msgParam = JSON.stringify({ photoURL: mediaId });
377
366
  } else if (mediaType === "voice") {
378
367
  msgKey = "sampleAudio";
379
- // Reuse buffer from upload to avoid reading the file twice
380
- const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log, {
381
- preReadBuffer: uploadedBuffer,
382
- });
368
+ const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log, { preReadBuffer: buffer });
383
369
  msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
384
370
  } else {
385
371
  // sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
@@ -391,7 +377,7 @@ export async function sendProactiveMedia(
391
377
  }
392
378
 
393
379
  const payload: ProactiveMessagePayload = {
394
- robotCode: config.robotCode || config.clientId,
380
+ robotCode: resolveRobotCode(config),
395
381
  msgKey,
396
382
  msgParam,
397
383
  };
@@ -427,6 +413,8 @@ export async function sendProactiveMedia(
427
413
  messageType: "outbound-proactive-media",
428
414
  quotedRef: options.quotedRef,
429
415
  log,
416
+ ...DEFAULT_OUTBOUND_SENDER,
417
+ chatType: inferConversationChatType(options.conversationId || resolvedTarget),
430
418
  delivery: {
431
419
  ...delivery,
432
420
  kind: "proactive-media",
@@ -485,6 +473,8 @@ export async function sendProactiveMedia(
485
473
  messageType: "outbound-proactive-fallback",
486
474
  quotedRef: options.quotedRef,
487
475
  log,
476
+ ...DEFAULT_OUTBOUND_SENDER,
477
+ chatType: inferConversationChatType(options.conversationId || normalizedTarget),
488
478
  delivery: {
489
479
  ...fallbackDelivery,
490
480
  kind: isTrackingResult(fallback as ProactiveTextSendResult) ? "proactive-card" : "proactive-text",
@@ -509,16 +499,13 @@ export async function sendBySession(
509
499
  mediaLocalRoots: options.mediaLocalRoots,
510
500
  });
511
501
  if (uploadResult) {
512
- const { mediaId, buffer: uploadedBuffer } = uploadResult;
502
+ const { mediaId, buffer } = uploadResult;
513
503
  let body: any;
514
504
 
515
505
  if (options.mediaType === "image") {
516
506
  body = { msgtype: "image", image: { media_id: mediaId } };
517
507
  } else if (options.mediaType === "voice") {
518
- // Reuse buffer from upload to avoid reading the file twice
519
- const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log, {
520
- preReadBuffer: uploadedBuffer,
521
- });
508
+ const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log, { preReadBuffer: buffer });
522
509
  body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
523
510
  } else if (options.mediaType === "video") {
524
511
  body = { msgtype: "video", video: { media_id: mediaId } };
@@ -595,31 +582,18 @@ export async function sendMessage(
595
582
  return { ok: true };
596
583
  }
597
584
 
598
- if (config.cardTemplateId) {
599
- const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
600
- if (!proactiveResult.ok) {
601
- return { ok: false, error: proactiveResult.error || "Card send failed" };
602
- }
603
- return {
604
- ok: true,
605
- tracking: {
606
- processQueryKey: proactiveResult.processQueryKey,
607
- outTrackId: proactiveResult.outTrackId,
608
- cardInstanceId: proactiveResult.cardInstanceId,
609
- },
610
- };
611
- }
612
- } else if (options.cardUpdateMode === "append") {
613
- try {
614
- const nextContent = composeCardContentForAppend(card.lastStreamedContent, text);
615
- await streamAICard(card, nextContent, false, log);
616
- return { ok: true };
617
- } catch (err: any) {
618
- log?.warn?.(`[DingTalk] AI Card streaming failed: ${err.message}`);
619
- card.state = AICardStatus.FAILED;
620
- card.lastUpdated = Date.now();
621
- return { ok: false, error: err.message };
585
+ const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
586
+ if (!proactiveResult.ok) {
587
+ return { ok: false, error: proactiveResult.error || "Card send failed" };
622
588
  }
589
+ return {
590
+ ok: true,
591
+ tracking: {
592
+ processQueryKey: proactiveResult.processQueryKey,
593
+ outTrackId: proactiveResult.outTrackId,
594
+ cardInstanceId: proactiveResult.cardInstanceId,
595
+ },
596
+ };
623
597
  }
624
598
  }
625
599
 
@@ -636,6 +610,8 @@ export async function sendMessage(
636
610
  messageType: options.mediaPath && options.mediaType ? "outbound-media" : "outbound",
637
611
  quotedRef: options.quotedRef,
638
612
  log,
613
+ ...DEFAULT_OUTBOUND_SENDER,
614
+ chatType: inferConversationChatType(options.conversationId || conversationId),
639
615
  delivery: {
640
616
  ...delivery,
641
617
  kind: "session",
@@ -655,6 +631,8 @@ export async function sendMessage(
655
631
  messageType: "outbound-proactive",
656
632
  quotedRef: options.quotedRef,
657
633
  log,
634
+ ...DEFAULT_OUTBOUND_SENDER,
635
+ chatType: inferConversationChatType(options.conversationId || conversationId),
658
636
  delivery: {
659
637
  ...delivery,
660
638
  kind: isTrackingResult(result) ? "proactive-card" : "proactive-text",
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
11
11
  import { resolveAtAgents } from "./agent-name-matcher";
12
+ import { resolveRobotCode } from "../config";
12
13
  import { parseLearnCommand } from "../learning-command-service";
13
14
  import { getDingTalkRuntime } from "../runtime";
14
15
  import { sendBySession } from "../send-service";
@@ -65,7 +66,12 @@ function sanitizeAgentName(name: string): string {
65
66
  }
66
67
 
67
68
  /**
68
- * Resolve @mention-based sub-agent routing for a group message.
69
+ * Resolve @mention-based sub-agent routing for a group or direct message.
70
+ *
71
+ * In group chats, @mentions are populated by the DingTalk SDK (atMentions field).
72
+ * In direct messages (DM), the SDK also populates atMentions for text-type messages
73
+ * via extractMessageContent in message-utils.ts, so the same field is reused here.
74
+ * The !isGroup guard is removed to enable sub-agent routing in DM as well.
69
75
  *
70
76
  * Returns matched agents if any @mentions resolve to configured agents,
71
77
  * or null if the message should be handled by the default agent.
@@ -85,14 +91,14 @@ export async function resolveSubAgentRoute(params: {
85
91
  const { extractedContent, cfg, isGroup, dingtalkConfig, sessionWebhook, senderId, log } = params;
86
92
 
87
93
  const atMentions = extractedContent.atMentions || [];
88
- const atUserDingtalkIds = extractedContent.atUserDingtalkIds;
94
+ // DM has no @picker list from DingTalk; only group chats provide atUsers for real-user hints.
95
+ const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : undefined;
89
96
  // Strip quoted prefix before checking /learn to avoid false positives
90
97
  // when the quoted message itself contains a /learn command.
91
98
  const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
92
99
  const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
93
100
 
94
101
  if (
95
- !isGroup ||
96
102
  atMentions.length === 0 ||
97
103
  !cfg.agents?.list ||
98
104
  cfg.agents.list.length === 0 ||
@@ -114,9 +120,9 @@ export async function resolveSubAgentRoute(params: {
114
120
  if (hasInvalidAgentNames) {
115
121
  const fallbackReason = `未找到名为"${unmatchedNames.join("、")}"的助手`;
116
122
  try {
123
+ const sendOptions = isGroup ? { atUserId: senderId, log } : { log };
117
124
  await sendBySession(dingtalkConfig, sessionWebhook, `⚠️ ${fallbackReason}`, {
118
- atUserId: senderId,
119
- log,
125
+ ...sendOptions,
120
126
  });
121
127
  } catch (err: any) {
122
128
  log?.debug?.(`[DingTalk] Failed to send fallback notice: ${err.message}`);
@@ -149,7 +155,7 @@ export async function dispatchSubAgents(params: {
149
155
 
150
156
  // Pre-download media once to avoid duplication across sub-agents
151
157
  let preDownloadedMedia: { mediaPath?: string; mediaType?: string } | undefined;
152
- if (extractedContent.mediaPath && dingtalkConfig.robotCode) {
158
+ if (extractedContent.mediaPath && resolveRobotCode(dingtalkConfig)) {
153
159
  const media = await download(dingtalkConfig, extractedContent.mediaPath, log);
154
160
  if (media) {
155
161
  preDownloadedMedia = { mediaPath: media.path, mediaType: media.mimeType };