@soimy/dingtalk 3.3.0 → 3.4.0

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Reply strategy interface for DingTalk message delivery.
3
+ *
4
+ * Abstracts the "how to deliver a reply" concern away from
5
+ * handleDingTalkMessage, so card and markdown modes each manage
6
+ * their own state and lifecycle independently.
7
+ */
8
+
9
+ import type { AICardInstance, DingTalkConfig, Logger, QuotedRef } from "./types";
10
+ import { createCardReplyStrategy } from "./reply-strategy-card";
11
+ import { createMarkdownReplyStrategy } from "./reply-strategy-markdown";
12
+
13
+ // ---- Public types ------------------------------------------------
14
+
15
+ export interface DeliverPayload {
16
+ text?: string;
17
+ mediaUrls: string[];
18
+ kind: "block" | "final" | "tool";
19
+ }
20
+
21
+ export interface ReplyOptions {
22
+ disableBlockStreaming: boolean;
23
+ onPartialReply?: (payload: { text?: string }) => void;
24
+ onReasoningStream?: (payload: { text?: string }) => void;
25
+ onAssistantMessageStart?: () => void;
26
+ }
27
+
28
+ export interface ReplyStrategy {
29
+ /** Options forwarded to the runtime dispatcher. */
30
+ getReplyOptions(): ReplyOptions;
31
+
32
+ /** Called by the deliver callback for each payload chunk. */
33
+ deliver(payload: DeliverPayload): Promise<void>;
34
+
35
+ /** Called after dispatch completes successfully. */
36
+ finalize(): Promise<void>;
37
+
38
+ /** Called when dispatch throws an error. */
39
+ abort(error: Error): Promise<void>;
40
+
41
+ /** Last known final text (for external consumers such as logging). */
42
+ getFinalText(): string | undefined;
43
+ }
44
+
45
+ /** Shared context passed to every strategy implementation. */
46
+ export interface ReplyStrategyContext {
47
+ config: DingTalkConfig;
48
+ to: string;
49
+ sessionWebhook: string;
50
+ senderId: string;
51
+ isDirect: boolean;
52
+ accountId: string;
53
+ storePath: string;
54
+ groupId?: string;
55
+ log?: Logger;
56
+ replyQuotedRef?: QuotedRef;
57
+ deliverMedia: (urls: string[]) => Promise<void>;
58
+ }
59
+
60
+ // ---- Factory -----------------------------------------------------
61
+
62
+ export function createReplyStrategy(
63
+ params: ReplyStrategyContext & {
64
+ card: AICardInstance | undefined;
65
+ useCardMode: boolean;
66
+ },
67
+ ): ReplyStrategy {
68
+ if (params.useCardMode && params.card) {
69
+ return createCardReplyStrategy({ ...params, card: params.card });
70
+ }
71
+ return createMarkdownReplyStrategy(params);
72
+ }
@@ -10,8 +10,8 @@ import { stripTargetPrefix } from "./config";
10
10
  import { getLogger } from "./logger-context";
11
11
  import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
12
12
  import { convertMarkdownTablesToPlainText, detectMarkdownAndExtractTitle } from "./message-utils";
13
+ import { DEFAULT_MESSAGE_CONTEXT_TTL_DAYS, upsertOutboundMessageContext } from "./message-context-store";
13
14
  import { resolveOriginalPeerId } from "./peer-id-registry";
14
- import { appendOutboundToQuoteJournal, appendProactiveOutboundJournal } from "./quote-journal";
15
15
  import {
16
16
  deleteProactiveRiskObservation,
17
17
  getProactiveRiskObservation,
@@ -25,6 +25,7 @@ import type {
25
25
  DingTalkTrackingMetadata,
26
26
  Logger,
27
27
  ProactiveMessagePayload,
28
+ QuotedRef,
28
29
  SendMessageOptions,
29
30
  SessionWebhookResponse,
30
31
  } from "./types";
@@ -38,24 +39,83 @@ function isTrackingResult(result: ProactiveTextSendResult): result is { tracking
38
39
  return "tracking" in result;
39
40
  }
40
41
 
41
- function extractOutboundMessageId(payload: unknown): string | undefined {
42
+ function firstTrimmedString(...candidates: unknown[]): string | undefined {
43
+ for (const candidate of candidates) {
44
+ if (typeof candidate === "string" && candidate.trim()) {
45
+ return candidate.trim();
46
+ }
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function extractOutboundDeliveryMetadata(payload: unknown): {
52
+ messageId?: string;
53
+ processQueryKey?: string;
54
+ outTrackId?: string;
55
+ cardInstanceId?: string;
56
+ } {
42
57
  if (!payload || typeof payload !== "object") {
43
- return undefined;
58
+ return {};
44
59
  }
45
60
  const data = payload as Record<string, unknown>;
46
61
  const tracking =
47
62
  data.tracking && typeof data.tracking === "object"
48
63
  ? (data.tracking as Record<string, unknown>)
49
64
  : undefined;
50
- const value =
51
- data.processQueryKey ??
52
- data.messageId ??
53
- data.msgid ??
54
- tracking?.processQueryKey ??
55
- tracking?.messageId ??
56
- tracking?.msgid ??
57
- tracking?.outTrackId;
58
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
65
+ const messageId = firstTrimmedString(data.messageId, data.msgid, tracking?.messageId, tracking?.msgid);
66
+ const processQueryKey = firstTrimmedString(data.processQueryKey, tracking?.processQueryKey);
67
+ const outTrackId = firstTrimmedString(data.outTrackId, tracking?.outTrackId);
68
+ const cardInstanceId = firstTrimmedString(data.cardInstanceId, tracking?.cardInstanceId);
69
+ return { messageId, processQueryKey, outTrackId, cardInstanceId };
70
+ }
71
+
72
+ function persistOutboundMessageContext(params: {
73
+ storePath?: string;
74
+ accountId?: string;
75
+ conversationId: string;
76
+ text?: string;
77
+ messageType?: string;
78
+ createdAt?: number;
79
+ quotedRef?: QuotedRef;
80
+ log?: Logger;
81
+ delivery: {
82
+ messageId?: string;
83
+ processQueryKey?: string;
84
+ outTrackId?: string;
85
+ cardInstanceId?: string;
86
+ kind?: "session" | "proactive-text" | "proactive-card" | "proactive-media";
87
+ };
88
+ }): void {
89
+ if (!params.storePath || !params.accountId) {
90
+ return;
91
+ }
92
+ params.log?.debug?.(
93
+ `[DingTalk][QuotedRef][Persist] direction=outbound scope=${params.conversationId} ` +
94
+ `messageType=${params.messageType || "(none)"} processQueryKey=${params.delivery.processQueryKey || "(none)"} ` +
95
+ `messageId=${params.delivery.messageId || "(none)"} quotedRef=${params.quotedRef ? JSON.stringify(params.quotedRef) : "(none)"}`,
96
+ );
97
+ upsertOutboundMessageContext({
98
+ storePath: params.storePath,
99
+ accountId: params.accountId,
100
+ conversationId: params.conversationId,
101
+ createdAt: params.createdAt ?? Date.now(),
102
+ text: params.text,
103
+ messageType: params.messageType,
104
+ ttlMs: DEFAULT_MESSAGE_CONTEXT_TTL_DAYS * 24 * 60 * 60 * 1000,
105
+ topic: null,
106
+ quotedRef: params.quotedRef,
107
+ delivery: params.delivery,
108
+ });
109
+ }
110
+
111
+ function buildPersistedOutboundText(text: string, options: SendMessageOptions): string {
112
+ if (text) {
113
+ return text;
114
+ }
115
+ if (options.mediaPath && options.mediaType) {
116
+ return `[media:${options.mediaType}] ${options.mediaPath}`;
117
+ }
118
+ return text;
59
119
  }
60
120
 
61
121
  function composeCardContentForAppend(previous: string | undefined, incoming: string): string {
@@ -174,7 +234,7 @@ export async function sendProactiveTextOrMarkdown(
174
234
 
175
235
  // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
176
236
  const messageType = config.messageType || "markdown";
177
- if (messageType === "card" && config.cardTemplateId) {
237
+ if (messageType === "card" && config.cardTemplateId && !options.forceMarkdown) {
178
238
  log?.debug?.(
179
239
  `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
180
240
  );
@@ -201,7 +261,7 @@ export async function sendProactiveTextOrMarkdown(
201
261
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
202
262
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
203
263
 
204
- const normalizedText = convertMarkdownTablesToPlainText(text);
264
+ const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
205
265
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
206
266
 
207
267
  log?.debug?.(
@@ -349,18 +409,21 @@ export async function sendProactiveMedia(
349
409
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
350
410
  }
351
411
 
352
- const messageId = extractOutboundMessageId(result.data);
353
- if (options.storePath && options.accountId) {
354
- await appendProactiveOutboundJournal({
355
- storePath: options.storePath,
356
- accountId: options.accountId,
357
- conversationId: options.conversationId || resolvedTarget,
358
- messageId,
359
- text: `[media:${mediaType}] ${mediaPath}`,
360
- messageType: "outbound-proactive-media",
361
- log,
362
- });
363
- }
412
+ const delivery = extractOutboundDeliveryMetadata(result.data);
413
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
414
+ persistOutboundMessageContext({
415
+ storePath: options.storePath,
416
+ accountId: options.accountId,
417
+ conversationId: options.conversationId || resolvedTarget,
418
+ text: `[media:${mediaType}] ${mediaPath}`,
419
+ messageType: "outbound-proactive-media",
420
+ quotedRef: options.quotedRef,
421
+ log,
422
+ delivery: {
423
+ ...delivery,
424
+ kind: "proactive-media",
425
+ },
426
+ });
364
427
  return { ok: true, data: result.data, messageId };
365
428
  } catch (err: any) {
366
429
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
@@ -390,10 +453,12 @@ export async function sendProactiveMedia(
390
453
  }
391
454
 
392
455
  // Fallback: ensure user still gets a usable link/path text.
456
+ const fallbackDisplayText = `📎 媒体发送失败,兜底链接/路径:${mediaPath}`;
457
+ const fallbackPersistedText = `媒体发送失败,兜底链接/路径:${mediaPath}`;
393
458
  const fallback = await sendProactiveTextOrMarkdown(
394
459
  config,
395
460
  target,
396
- `📎 媒体发送失败,兜底链接/路径:${mediaPath}`,
461
+ fallbackDisplayText,
397
462
  options,
398
463
  ).catch((fallbackErr: any) => ({ __fallbackError: fallbackErr }));
399
464
 
@@ -401,7 +466,23 @@ export async function sendProactiveMedia(
401
466
  return { ok: false, error: `${err.message}; fallback failed: ${(fallback as any).__fallbackError?.message || "unknown"}` };
402
467
  }
403
468
 
404
- return { ok: true, data: fallback, messageId: (fallback as any)?.processQueryKey || (fallback as any)?.messageId };
469
+ const fallbackDelivery = extractOutboundDeliveryMetadata(fallback);
470
+ const fallbackMessageId =
471
+ fallbackDelivery.messageId || fallbackDelivery.processQueryKey || fallbackDelivery.outTrackId;
472
+ persistOutboundMessageContext({
473
+ storePath: options.storePath,
474
+ accountId: options.accountId,
475
+ conversationId: options.conversationId || normalizedTarget,
476
+ text: fallbackPersistedText,
477
+ messageType: "outbound-proactive-fallback",
478
+ quotedRef: options.quotedRef,
479
+ log,
480
+ delivery: {
481
+ ...fallbackDelivery,
482
+ kind: isTrackingResult(fallback as ProactiveTextSendResult) ? "proactive-card" : "proactive-text",
483
+ },
484
+ });
485
+ return { ok: true, data: fallback, messageId: fallbackMessageId };
405
486
  }
406
487
  }
407
488
 
@@ -449,7 +530,7 @@ export async function sendBySession(
449
530
  }
450
531
 
451
532
  // Fallback to text/markdown reply payload.
452
- const normalizedText = convertMarkdownTablesToPlainText(text);
533
+ const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
453
534
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "Clawdbot 消息");
454
535
  const chunks = splitMarkdownChunks(normalizedText, DINGTALK_TEXT_CHUNK_LIMIT);
455
536
 
@@ -492,7 +573,7 @@ export async function sendMessage(
492
573
  const messageType = config.messageType || "markdown";
493
574
  const log = options.log || getLogger();
494
575
 
495
- if (messageType === "card" && options.card) {
576
+ if (messageType === "card" && options.card && !options.forceMarkdown) {
496
577
  const card = options.card;
497
578
  if (isCardInTerminalState(card.state)) {
498
579
  if (options.sessionWebhook) {
@@ -530,34 +611,41 @@ export async function sendMessage(
530
611
 
531
612
  if (options.sessionWebhook) {
532
613
  const data = await sendBySession(config, options.sessionWebhook, text, options);
533
- const messageId = extractOutboundMessageId(data);
534
- if (options.storePath && options.accountId) {
535
- await appendOutboundToQuoteJournal({
536
- storePath: options.storePath,
537
- accountId: options.accountId,
538
- conversationId: options.conversationId || conversationId,
539
- messageId,
540
- text,
541
- messageType: "outbound",
542
- log,
543
- });
544
- }
545
- return { ok: true, data, messageId };
546
- }
547
-
548
- const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
549
- const messageId = extractOutboundMessageId(result);
550
- if (options.storePath && options.accountId) {
551
- await appendProactiveOutboundJournal({
614
+ const delivery = extractOutboundDeliveryMetadata(data);
615
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
616
+ const persistedText = buildPersistedOutboundText(text, options);
617
+ persistOutboundMessageContext({
552
618
  storePath: options.storePath,
553
619
  accountId: options.accountId,
554
620
  conversationId: options.conversationId || conversationId,
555
- messageId,
556
- text,
557
- messageType: "outbound-proactive",
621
+ text: persistedText,
622
+ messageType: options.mediaPath && options.mediaType ? "outbound-media" : "outbound",
623
+ quotedRef: options.quotedRef,
558
624
  log,
625
+ delivery: {
626
+ ...delivery,
627
+ kind: "session",
628
+ },
559
629
  });
630
+ return { ok: true, data, messageId };
560
631
  }
632
+
633
+ const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
634
+ const delivery = extractOutboundDeliveryMetadata(result);
635
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
636
+ persistOutboundMessageContext({
637
+ storePath: options.storePath,
638
+ accountId: options.accountId,
639
+ conversationId: options.conversationId || conversationId,
640
+ text,
641
+ messageType: "outbound-proactive",
642
+ quotedRef: options.quotedRef,
643
+ log,
644
+ delivery: {
645
+ ...delivery,
646
+ kind: isTrackingResult(result) ? "proactive-card" : "proactive-text",
647
+ },
648
+ });
561
649
  if (isTrackingResult(result)) {
562
650
  return { ok: true, tracking: result.tracking };
563
651
  }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Agent name matcher for @sub-agent feature
3
+ *
4
+ * Matches @mentions to agent IDs based on name and id fields in agents.list config.
5
+ */
6
+
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+ import type { AtMention, AgentNameMatch } from "../types";
9
+
10
+ interface AgentConfig {
11
+ id: string;
12
+ name?: string;
13
+ default?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Normalize name for case-insensitive matching
18
+ */
19
+ function normalizeName(name: string): string {
20
+ return name.trim().toLowerCase();
21
+ }
22
+
23
+ /**
24
+ * Get main agent ID from agents.list
25
+ */
26
+ export function getMainAgentId(agents: AgentConfig[] | undefined): string {
27
+ if (!agents || agents.length === 0) {
28
+ return "main";
29
+ }
30
+
31
+ const defaultAgent = agents.find((a) => a.default);
32
+ if (defaultAgent) {
33
+ return defaultAgent.id;
34
+ }
35
+
36
+ return agents[0].id;
37
+ }
38
+
39
+ /**
40
+ * Match a single @name to an agent
41
+ */
42
+ function matchAtName(atName: string, agents: AgentConfig[]): AgentNameMatch | null {
43
+ const normalizedAtName = normalizeName(atName);
44
+
45
+ for (const agent of agents) {
46
+ // 1. Match by name field
47
+ if (agent.name && normalizeName(agent.name) === normalizedAtName) {
48
+ return {
49
+ agentId: agent.id,
50
+ matchSource: "name",
51
+ matchedName: agent.name,
52
+ };
53
+ }
54
+
55
+ // 2. Match by id field
56
+ if (normalizeName(agent.id) === normalizedAtName) {
57
+ return {
58
+ agentId: agent.id,
59
+ matchSource: "id",
60
+ matchedName: agent.id,
61
+ };
62
+ }
63
+ }
64
+
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Resolve @mentions to agent matches
70
+ *
71
+ * @param atMentions - List of @mentions extracted from message
72
+ * @param cfg - OpenClaw configuration
73
+ * @param atUserDingtalkIds - dingtalkIds from webhook atUsers field (real users selected via @picker)
74
+ * @returns Matched agents, unmatched names, main agent ID, and whether there are invalid agent names
75
+ *
76
+ * @remarks
77
+ * Exclusion logic for unmatched @mentions:
78
+ * - If mention.userId is set (from richText), it's a real user → excluded from unmatchedNames
79
+ * - In text mode, we cannot map dingtalkIds to specific @mention names
80
+ * - To avoid false positives (reporting real user names as missing agents),
81
+ * we use a conservative heuristic:
82
+ * - If realUserCount > 0, hasInvalidAgentNames is always false
83
+ * - This means we never report "agent not found" in text mode when there are real users
84
+ * - In richText mode, mention.userId allows precise exclusion, so the heuristic is not needed
85
+ */
86
+ export function resolveAtAgents(
87
+ atMentions: AtMention[],
88
+ cfg: OpenClawConfig,
89
+ atUserDingtalkIds?: string[],
90
+ ): {
91
+ matchedAgents: AgentNameMatch[];
92
+ unmatchedNames: string[];
93
+ mainAgentId: string;
94
+ /** Count of @mentions that are likely real users (from atUserDingtalkIds) */
95
+ realUserCount: number;
96
+ /** Whether there are invalid agent names (conservative: false if realUserCount > 0) */
97
+ hasInvalidAgentNames: boolean;
98
+ } {
99
+ const agents = cfg?.agents?.list as AgentConfig[] | undefined;
100
+ const mainAgentId = getMainAgentId(agents);
101
+
102
+ if (!atMentions || atMentions.length === 0) {
103
+ return {
104
+ matchedAgents: [],
105
+ unmatchedNames: [],
106
+ mainAgentId,
107
+ realUserCount: 0,
108
+ hasInvalidAgentNames: false,
109
+ };
110
+ }
111
+
112
+ const matchedAgents: AgentNameMatch[] = [];
113
+ const unmatchedNames: string[] = [];
114
+
115
+ for (const mention of atMentions) {
116
+ const match = agents ? matchAtName(mention.name, agents) : null;
117
+
118
+ if (match) {
119
+ // Avoid duplicate agents
120
+ if (!matchedAgents.some((m) => m.agentId === match.agentId)) {
121
+ matchedAgents.push(match);
122
+ }
123
+ } else {
124
+ // Exclude @real users (those with userId are real users from richText)
125
+ if (!mention.userId) {
126
+ unmatchedNames.push(mention.name);
127
+ }
128
+ }
129
+ }
130
+
131
+ // Count real users from atUserDingtalkIds
132
+ // These are real DingTalk users selected via @picker, but we don't know which names they correspond to
133
+ const realUserCount = atUserDingtalkIds?.length || 0;
134
+
135
+ // Conservative heuristic: if there are real users, never report invalid agent names
136
+ // This avoids false positives where real user names are incorrectly reported as missing agents
137
+ // In text mode, we cannot distinguish which @mentions correspond to real users
138
+ // In richText mode, mention.userId provides precise exclusion, so this is just a safety net
139
+ const hasInvalidAgentNames = realUserCount === 0 && unmatchedNames.length > 0;
140
+
141
+ return {
142
+ matchedAgents,
143
+ unmatchedNames,
144
+ mainAgentId,
145
+ realUserCount,
146
+ hasInvalidAgentNames,
147
+ };
148
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Sub-agent routing for @mention-based multi-agent support.
3
+ *
4
+ * Extracts @mentions from inbound messages and resolves them to agent IDs
5
+ * using agents.list configuration. This is a plugin-layer routing mechanism
6
+ * because the framework's resolveAgentRoute only supports static matching
7
+ * (channel + accountId + peer), not content-based dynamic routing.
8
+ */
9
+
10
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
11
+ import { resolveAtAgents } from "./agent-name-matcher";
12
+ import { parseLearnCommand } from "../learning-command-service";
13
+ import { getDingTalkRuntime } from "../runtime";
14
+ import { sendBySession } from "../send-service";
15
+ import type { AgentNameMatch, DingTalkConfig, DingTalkInboundMessage, HandleDingTalkMessageParams, Logger, MessageContent } from "../types";
16
+
17
+ /**
18
+ * Build a session key for a specific agent using the runtime API.
19
+ * Falls back to framework's resolveAgentRoute if buildAgentSessionKey is unavailable.
20
+ */
21
+ export function buildAgentSessionKey(params: {
22
+ rt: ReturnType<typeof getDingTalkRuntime>;
23
+ cfg: OpenClawConfig;
24
+ accountId: string;
25
+ agentId: string;
26
+ peerKind: "direct" | "group";
27
+ peerId: string;
28
+ }): string {
29
+ const { rt, cfg, accountId, agentId, peerKind, peerId } = params;
30
+ const routing = rt.channel.routing as Record<string, unknown>;
31
+ if (typeof routing.buildAgentSessionKey === "function") {
32
+ return (
33
+ (routing.buildAgentSessionKey as (p: unknown) => string)({
34
+ agentId,
35
+ channel: "dingtalk",
36
+ accountId,
37
+ peer: { kind: peerKind, id: peerId },
38
+ dmScope: cfg.session?.dmScope,
39
+ identityLinks: cfg.session?.identityLinks,
40
+ })
41
+ ).toLowerCase();
42
+ }
43
+ // Fallback: derive a session key with agentId suffix to ensure isolation.
44
+ // resolveAgentRoute routes to the default agent, so we append the target
45
+ // agentId to prevent session key collisions between sub-agents.
46
+ // @migration-note: When SDK exposes buildAgentSessionKey in type definitions,
47
+ // sessions created via this fallback path will become orphaned. Remove this
48
+ // fallback and the typeof check once the SDK is updated.
49
+ const fallbackRoute = rt.channel.routing.resolveAgentRoute({
50
+ cfg,
51
+ channel: "dingtalk",
52
+ accountId,
53
+ peer: { kind: peerKind, id: peerId },
54
+ });
55
+ return `${fallbackRoute.sessionKey}:subagent:${agentId}`;
56
+ }
57
+
58
+ /**
59
+ * Sanitize agent name for safe use in markdown prefix and context hints.
60
+ * Strips brackets, newlines, and control characters to prevent markdown
61
+ * breakage and prompt injection.
62
+ */
63
+ function sanitizeAgentName(name: string): string {
64
+ return name.replace(/[[\]\r\n]/g, "").trim();
65
+ }
66
+
67
+ /**
68
+ * Resolve @mention-based sub-agent routing for a group message.
69
+ *
70
+ * Returns matched agents if any @mentions resolve to configured agents,
71
+ * or null if the message should be handled by the default agent.
72
+ */
73
+ export async function resolveSubAgentRoute(params: {
74
+ extractedContent: MessageContent;
75
+ cfg: OpenClawConfig;
76
+ isGroup: boolean;
77
+ dingtalkConfig: DingTalkConfig;
78
+ sessionWebhook: string;
79
+ senderId: string;
80
+ log?: Logger;
81
+ }): Promise<{
82
+ matchedAgents: AgentNameMatch[];
83
+ preDownloadedMedia?: { mediaPath?: string; mediaType?: string };
84
+ } | null> {
85
+ const { extractedContent, cfg, isGroup, dingtalkConfig, sessionWebhook, senderId, log } = params;
86
+
87
+ const atMentions = extractedContent.atMentions || [];
88
+ const atUserDingtalkIds = extractedContent.atUserDingtalkIds;
89
+ // Strip quoted prefix before checking /learn to avoid false positives
90
+ // when the quoted message itself contains a /learn command.
91
+ const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
92
+ const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
93
+
94
+ if (
95
+ !isGroup ||
96
+ atMentions.length === 0 ||
97
+ !cfg.agents?.list ||
98
+ cfg.agents.list.length === 0 ||
99
+ isLearnCommand
100
+ ) {
101
+ return null;
102
+ }
103
+
104
+ const { matchedAgents, unmatchedNames, realUserCount, hasInvalidAgentNames } = resolveAtAgents(
105
+ atMentions,
106
+ cfg,
107
+ atUserDingtalkIds,
108
+ );
109
+ log?.info?.(
110
+ `[DingTalk] Sub-agent resolve: matched=${matchedAgents.map((a) => a.agentId).join(",")} unmatched=${unmatchedNames.join(",")} realUsers=${realUserCount}`,
111
+ );
112
+
113
+ // Send fallback notice for unmatched agent names
114
+ if (hasInvalidAgentNames) {
115
+ const fallbackReason = `未找到名为"${unmatchedNames.join("、")}"的助手`;
116
+ try {
117
+ await sendBySession(dingtalkConfig, sessionWebhook, `⚠️ ${fallbackReason}`, {
118
+ atUserId: senderId,
119
+ log,
120
+ });
121
+ } catch (err: any) {
122
+ log?.debug?.(`[DingTalk] Failed to send fallback notice: ${err.message}`);
123
+ }
124
+ }
125
+
126
+ if (matchedAgents.length === 0) {
127
+ return null;
128
+ }
129
+
130
+ return { matchedAgents };
131
+ }
132
+
133
+ /**
134
+ * Process matched sub-agents by dispatching each to handleDingTalkMessage.
135
+ */
136
+ export async function dispatchSubAgents(params: {
137
+ matchedAgents: AgentNameMatch[];
138
+ cfg: OpenClawConfig;
139
+ accountId: string;
140
+ data: DingTalkInboundMessage;
141
+ dingtalkConfig: DingTalkConfig;
142
+ sessionWebhook: string;
143
+ extractedContent: MessageContent;
144
+ handleMessage: (params: HandleDingTalkMessageParams) => Promise<void>;
145
+ downloadMedia: (config: DingTalkConfig, mediaPath: string, log?: Logger) => Promise<{ path: string; mimeType: string } | null>;
146
+ log?: Logger;
147
+ }): Promise<void> {
148
+ const { matchedAgents, cfg, accountId, data, dingtalkConfig, sessionWebhook, extractedContent, handleMessage, downloadMedia: download, log } = params;
149
+
150
+ // Pre-download media once to avoid duplication across sub-agents
151
+ let preDownloadedMedia: { mediaPath?: string; mediaType?: string } | undefined;
152
+ if (extractedContent.mediaPath && dingtalkConfig.robotCode) {
153
+ const media = await download(dingtalkConfig, extractedContent.mediaPath, log);
154
+ if (media) {
155
+ preDownloadedMedia = { mediaPath: media.path, mediaType: media.mimeType };
156
+ }
157
+ }
158
+
159
+ for (const agentMatch of matchedAgents) {
160
+ try {
161
+ await handleMessage({
162
+ cfg,
163
+ accountId,
164
+ data,
165
+ sessionWebhook,
166
+ log,
167
+ dingtalkConfig,
168
+ subAgentOptions: {
169
+ agentId: agentMatch.agentId,
170
+ responsePrefix: `[${sanitizeAgentName(agentMatch.matchedName)}] `,
171
+ matchedName: agentMatch.matchedName,
172
+ },
173
+ preDownloadedMedia,
174
+ });
175
+ } catch (error) {
176
+ log?.error?.(
177
+ `[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${error instanceof Error ? error.message : String(error)}`,
178
+ );
179
+ }
180
+ }
181
+ }