@soimy/dingtalk 3.5.1 → 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)) {
@@ -10,6 +10,7 @@ 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";
14
15
  import { attachCardRunController } from "./card/card-run-registry";
15
16
  import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
@@ -18,7 +19,7 @@ import type { AICardInstance } from "./types";
18
19
  import { AICardStatus } from "./types";
19
20
  import { formatDingTalkErrorPayloadLog } from "./utils";
20
21
 
21
- const FILE_ONLY_FALLBACK_ANSWER = "附件已发送,请查收。";
22
+ const EMPTY_FINAL_REPLY = "✅ Done";
22
23
 
23
24
  export function createCardReplyStrategy(
24
25
  ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
@@ -26,6 +27,7 @@ export function createCardReplyStrategy(
26
27
  const { card, config, log, isStopRequested } = ctx;
27
28
 
28
29
  const controller = createCardDraftController({ card, log });
30
+ const reasoningAssembler = createReasoningBlockAssembler();
29
31
  if (card.outTrackId) {
30
32
  attachCardRunController(card.outTrackId, controller);
31
33
  }
@@ -33,25 +35,61 @@ export function createCardReplyStrategy(
33
35
  let sawFinalDelivery = false;
34
36
 
35
37
  const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
36
- const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? FILE_ONLY_FALLBACK_ANSWER : undefined);
38
+ const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
37
39
  return controller.getRenderedContent({
38
40
  fallbackAnswer,
39
41
  overrideAnswer: options.preferFinalAnswer ? finalTextForFallback : undefined,
40
42
  });
41
43
  };
42
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
+
43
73
  return {
44
74
  getReplyOptions(): ReplyOptions {
45
75
  return {
46
- // Card mode: intermediate blocks are unused card updates go through
47
- // onPartialReply (real-time) or deliver(final) -> finishAICard.
48
- 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,
49
79
 
50
80
  onAssistantMessageStart: async () => {
51
81
  if (isStopRequested?.()) {
52
82
  return;
53
83
  }
54
- await controller.notifyNewAssistantTurn();
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;
55
93
  },
56
94
 
57
95
  onPartialReply: config.cardRealTimeStream
@@ -64,7 +102,7 @@ export function createCardReplyStrategy(
64
102
 
65
103
  onReasoningStream: async (payload) => {
66
104
  if (payload.text && !isStopRequested?.()) {
67
- await controller.updateThinking(payload.text);
105
+ await ingestReasoningSnapshot(payload.text);
68
106
  }
69
107
  },
70
108
  };
@@ -82,6 +120,7 @@ export function createCardReplyStrategy(
82
120
 
83
121
  // ---- final: defer to finalize, just save text ----
84
122
  if (payload.kind === "final") {
123
+ await flushPendingReasoning();
85
124
  sawFinalDelivery = true;
86
125
  log?.info?.(
87
126
  `[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
@@ -106,6 +145,7 @@ export function createCardReplyStrategy(
106
145
  log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
107
146
  return;
108
147
  }
148
+ await flushPendingReasoning();
109
149
  log?.info?.(
110
150
  `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
111
151
  );
@@ -113,7 +153,16 @@ export function createCardReplyStrategy(
113
153
  return;
114
154
  }
115
155
 
116
- // ---- 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) ----
117
166
  if (payload.mediaUrls.length > 0) {
118
167
  await ctx.deliverMedia(payload.mediaUrls);
119
168
  }
@@ -173,13 +222,15 @@ export function createCardReplyStrategy(
173
222
 
174
223
  // Normal finalize.
175
224
  try {
225
+ await flushPendingReasoning();
176
226
  await controller.flush();
177
227
  await controller.waitForInFlight();
178
- const finalText = getRenderedTimeline() || "✅ Done";
228
+ const renderedTimeline = getRenderedTimeline({ preferFinalAnswer: true });
229
+ const finalText = renderedTimeline || EMPTY_FINAL_REPLY;
179
230
  controller.stop();
180
231
  log?.info?.(
181
232
  `[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
182
- `source=${controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
233
+ `source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
183
234
  `preview="${finalText.slice(0, 120)}"`,
184
235
  );
185
236
  await finishAICard(card, finalText, log, {
@@ -228,9 +279,9 @@ export function createCardReplyStrategy(
228
279
  },
229
280
 
230
281
  getFinalText(): string | undefined {
231
- return controller.getFinalAnswerContent()
232
- || finalTextForFallback
233
- || (sawFinalDelivery ? FILE_ONLY_FALLBACK_ANSWER : undefined);
282
+ return finalTextForFallback
283
+ || controller.getFinalAnswerContent()
284
+ || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
234
285
  },
235
286
  };
236
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,6 +52,9 @@ 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;
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import axios from "axios";
2
+ import axios from "./http-client";
3
3
  import { getAccessToken } from "./auth";
4
4
  import {
5
5
  isCardInTerminalState,
@@ -37,6 +37,53 @@ import type {
37
37
 
38
38
  export { detectMediaTypeFromExtension } from "./media-utils";
39
39
 
40
+ const MARKDOWN_LOCAL_IMAGE_RE =
41
+ /!\[([^\]]*)\]\((file:\/\/\/[^)]+|\/(?:tmp|var|private|Users|home|root)[^)]+|[A-Za-z]:[\\/][^)]+)\)/g;
42
+
43
+ function decodeMarkdownLocalImagePath(rawPath: string): string {
44
+ const unescapedPath = rawPath.replace(/\\ /g, " ");
45
+ if (unescapedPath.startsWith("file://")) {
46
+ try {
47
+ return decodeURIComponent(unescapedPath.replace("file://", ""));
48
+ } catch {
49
+ return unescapedPath.replace("file://", "");
50
+ }
51
+ }
52
+ return unescapedPath;
53
+ }
54
+
55
+ async function replaceMarkdownLocalImages(params: {
56
+ config: DingTalkConfig;
57
+ text: string;
58
+ log?: Logger;
59
+ mediaLocalRoots?: string[];
60
+ }): Promise<string> {
61
+ const matches = [...params.text.matchAll(MARKDOWN_LOCAL_IMAGE_RE)];
62
+ if (matches.length === 0) {
63
+ return params.text;
64
+ }
65
+
66
+ let result = params.text;
67
+ for (const match of matches) {
68
+ const [fullMatch, altText, rawPath] = match;
69
+ const mediaPath = decodeMarkdownLocalImagePath(rawPath);
70
+ const uploadResult = await uploadMedia(params.config, mediaPath, "image", params.log, {
71
+ mediaLocalRoots: params.mediaLocalRoots,
72
+ });
73
+
74
+ if (!uploadResult?.mediaId) {
75
+ params.log?.warn?.(
76
+ `[DingTalk] Markdown local image upload failed, keep original reference: ${mediaPath}`,
77
+ );
78
+ continue;
79
+ }
80
+
81
+ result = result.replace(fullMatch, () => `![${altText}](${uploadResult.mediaId})`);
82
+ }
83
+
84
+ return result;
85
+ }
86
+
40
87
  type ProactiveTextSendResult = AxiosResponse | { tracking: DingTalkTrackingMetadata };
41
88
 
42
89
  function isTrackingResult(result: ProactiveTextSendResult): result is { tracking: DingTalkTrackingMetadata } {
@@ -252,7 +299,16 @@ export async function sendProactiveTextOrMarkdown(
252
299
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
253
300
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
254
301
 
255
- const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
302
+ const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
303
+ config,
304
+ text,
305
+ log,
306
+ mediaLocalRoots: options.mediaLocalRoots,
307
+ });
308
+ const normalizedText =
309
+ config.convertMarkdownTables !== false
310
+ ? convertMarkdownTablesToPlainText(textWithUploadedLocalImages)
311
+ : textWithUploadedLocalImages;
256
312
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
257
313
 
258
314
  log?.debug?.(
@@ -531,7 +587,16 @@ export async function sendBySession(
531
587
  }
532
588
 
533
589
  // Fallback to text/markdown reply payload.
534
- const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
590
+ const textWithUploadedLocalImages = await replaceMarkdownLocalImages({
591
+ config,
592
+ text,
593
+ log,
594
+ mediaLocalRoots: options.mediaLocalRoots,
595
+ });
596
+ const normalizedText =
597
+ config.convertMarkdownTables !== false
598
+ ? convertMarkdownTablesToPlainText(textWithUploadedLocalImages)
599
+ : textWithUploadedLocalImages;
535
600
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "Clawdbot 消息");
536
601
  const chunks = splitMarkdownChunks(normalizedText, DINGTALK_TEXT_CHUNK_LIMIT);
537
602
 
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
11
+ import { maybeResolveTextAlias } from "openclaw/plugin-sdk/command-auth";
11
12
  import { resolveAtAgents } from "./agent-name-matcher";
12
13
  import { resolveRobotCode } from "../config";
13
14
  import { parseLearnCommand } from "../learning-command-service";
@@ -93,16 +94,22 @@ export async function resolveSubAgentRoute(params: {
93
94
  const atMentions = extractedContent.atMentions || [];
94
95
  // DM has no @picker list from DingTalk; only group chats provide atUsers for real-user hints.
95
96
  const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : undefined;
96
- // Strip quoted prefix before checking /learn to avoid false positives
97
- // when the quoted message itself contains a /learn command.
97
+ // Strip quoted prefix before checking commands to avoid false positives
98
+ // when the quoted message itself contains a command.
98
99
  const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
99
100
  const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
101
+ // Slash commands like /new, /stop, /reasoning etc. must bypass sub-agent
102
+ // routing so they reach the framework's own command handling layer.
103
+ // Strip leading @mention tokens first since DM text may look like "@Agent /new".
104
+ const textWithoutMentions = textForCommandCheck.replace(/^(?:@\S+\s+)*/u, "").trim();
105
+ const isSlashCommand = maybeResolveTextAlias(textWithoutMentions, cfg) !== null;
100
106
 
101
107
  if (
102
108
  atMentions.length === 0 ||
103
109
  !cfg.agents?.list ||
104
110
  cfg.agents.list.length === 0 ||
105
- isLearnCommand
111
+ isLearnCommand ||
112
+ isSlashCommand
106
113
  ) {
107
114
  return null;
108
115
  }
@@ -173,7 +180,7 @@ export async function dispatchSubAgents(params: {
173
180
  dingtalkConfig,
174
181
  subAgentOptions: {
175
182
  agentId: agentMatch.agentId,
176
- responsePrefix: `[${sanitizeAgentName(agentMatch.matchedName)}] `,
183
+ responsePrefix: `> 🤖 **${sanitizeAgentName(agentMatch.matchedName)}**:\n\n`,
177
184
  matchedName: agentMatch.matchedName,
178
185
  },
179
186
  preDownloadedMedia,
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { readNamespaceJson, writeNamespaceJsonAtomic } from "./persistence-store";
3
+ import { readNamespaceJson, writeNamespaceJsonAtomic } from "../persistence-store";
4
4
 
5
5
  const GROUP_MEMBERS_NAMESPACE = "members.group-roster";
6
6