@soimy/dingtalk 3.3.0 → 3.4.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.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
@@ -8,10 +8,10 @@ import {
8
8
  } from "./card-service";
9
9
  import { stripTargetPrefix } from "./config";
10
10
  import { getLogger } from "./logger-context";
11
- import { getVoiceDurationMs, uploadMedia as uploadMediaUtil } from "./media-utils";
11
+ import { getVoiceDurationMs, uploadMedia as uploadMediaUtil, type UploadMediaResult } 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 {
@@ -143,14 +203,16 @@ function isProactivePermissionOrScopeError(code: string | null): boolean {
143
203
 
144
204
  /**
145
205
  * Wrapper to upload media with shared getAccessToken binding.
206
+ * Supports sandbox/container paths via mediaLocalRoots option.
146
207
  */
147
208
  export async function uploadMedia(
148
209
  config: DingTalkConfig,
149
210
  mediaPath: string,
150
211
  mediaType: "image" | "voice" | "video" | "file",
151
212
  log?: Logger,
152
- ): Promise<string | null> {
153
- return uploadMediaUtil(config, mediaPath, mediaType, getAccessToken, log);
213
+ options?: { mediaLocalRoots?: string[] },
214
+ ): Promise<UploadMediaResult | null> {
215
+ return uploadMediaUtil(config, mediaPath, mediaType, getAccessToken, log, options);
154
216
  }
155
217
 
156
218
  export async function sendProactiveTextOrMarkdown(
@@ -174,7 +236,7 @@ export async function sendProactiveTextOrMarkdown(
174
236
 
175
237
  // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
176
238
  const messageType = config.messageType || "markdown";
177
- if (messageType === "card" && config.cardTemplateId) {
239
+ if (messageType === "card" && config.cardTemplateId && !options.forceMarkdown) {
178
240
  log?.debug?.(
179
241
  `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
180
242
  );
@@ -201,7 +263,7 @@ export async function sendProactiveTextOrMarkdown(
201
263
  ? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
202
264
  : "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
203
265
 
204
- const normalizedText = convertMarkdownTablesToPlainText(text);
266
+ const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
205
267
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "OpenClaw 提醒");
206
268
 
207
269
  log?.debug?.(
@@ -281,16 +343,19 @@ export async function sendProactiveMedia(
281
343
  target: string,
282
344
  mediaPath: string,
283
345
  mediaType: "image" | "voice" | "video" | "file",
284
- options: SendMessageOptions & { accountId?: string } = {},
346
+ options: SendMessageOptions & { accountId?: string; mediaLocalRoots?: string[] } = {},
285
347
  ): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
286
348
  const log = options.log || getLogger();
287
349
 
288
350
  try {
289
351
  // Upload first, then send by media_id.
290
- const mediaId = await uploadMedia(config, mediaPath, mediaType, log);
291
- if (!mediaId) {
352
+ const uploadResult = await uploadMedia(config, mediaPath, mediaType, log, {
353
+ mediaLocalRoots: options.mediaLocalRoots,
354
+ });
355
+ if (!uploadResult) {
292
356
  return { ok: false, error: "Failed to upload media" };
293
357
  }
358
+ const { mediaId, buffer: uploadedBuffer } = uploadResult;
294
359
 
295
360
  const token = await getAccessToken(config, log);
296
361
  const { targetId, isExplicitUser } = stripTargetPrefix(target);
@@ -311,7 +376,10 @@ export async function sendProactiveMedia(
311
376
  msgParam = JSON.stringify({ photoURL: mediaId });
312
377
  } else if (mediaType === "voice") {
313
378
  msgKey = "sampleAudio";
314
- const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log);
379
+ // Reuse buffer from upload to avoid reading the file twice
380
+ const durationMs = await getVoiceDurationMs(mediaPath, mediaType, log, {
381
+ preReadBuffer: uploadedBuffer,
382
+ });
315
383
  msgParam = JSON.stringify({ mediaId, duration: String(durationMs) });
316
384
  } else {
317
385
  // sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
@@ -349,18 +417,21 @@ export async function sendProactiveMedia(
349
417
  deleteProactiveRiskObservation(options.accountId, resolvedTarget);
350
418
  }
351
419
 
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
- }
420
+ const delivery = extractOutboundDeliveryMetadata(result.data);
421
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
422
+ persistOutboundMessageContext({
423
+ storePath: options.storePath,
424
+ accountId: options.accountId,
425
+ conversationId: options.conversationId || resolvedTarget,
426
+ text: `[media:${mediaType}] ${mediaPath}`,
427
+ messageType: "outbound-proactive-media",
428
+ quotedRef: options.quotedRef,
429
+ log,
430
+ delivery: {
431
+ ...delivery,
432
+ kind: "proactive-media",
433
+ },
434
+ });
364
435
  return { ok: true, data: result.data, messageId };
365
436
  } catch (err: any) {
366
437
  log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
@@ -390,10 +461,12 @@ export async function sendProactiveMedia(
390
461
  }
391
462
 
392
463
  // Fallback: ensure user still gets a usable link/path text.
464
+ const fallbackDisplayText = `📎 媒体发送失败,兜底链接/路径:${mediaPath}`;
465
+ const fallbackPersistedText = `媒体发送失败,兜底链接/路径:${mediaPath}`;
393
466
  const fallback = await sendProactiveTextOrMarkdown(
394
467
  config,
395
468
  target,
396
- `📎 媒体发送失败,兜底链接/路径:${mediaPath}`,
469
+ fallbackDisplayText,
397
470
  options,
398
471
  ).catch((fallbackErr: any) => ({ __fallbackError: fallbackErr }));
399
472
 
@@ -401,7 +474,23 @@ export async function sendProactiveMedia(
401
474
  return { ok: false, error: `${err.message}; fallback failed: ${(fallback as any).__fallbackError?.message || "unknown"}` };
402
475
  }
403
476
 
404
- return { ok: true, data: fallback, messageId: (fallback as any)?.processQueryKey || (fallback as any)?.messageId };
477
+ const fallbackDelivery = extractOutboundDeliveryMetadata(fallback);
478
+ const fallbackMessageId =
479
+ fallbackDelivery.messageId || fallbackDelivery.processQueryKey || fallbackDelivery.outTrackId;
480
+ persistOutboundMessageContext({
481
+ storePath: options.storePath,
482
+ accountId: options.accountId,
483
+ conversationId: options.conversationId || normalizedTarget,
484
+ text: fallbackPersistedText,
485
+ messageType: "outbound-proactive-fallback",
486
+ quotedRef: options.quotedRef,
487
+ log,
488
+ delivery: {
489
+ ...fallbackDelivery,
490
+ kind: isTrackingResult(fallback as ProactiveTextSendResult) ? "proactive-card" : "proactive-text",
491
+ },
492
+ });
493
+ return { ok: true, data: fallback, messageId: fallbackMessageId };
405
494
  }
406
495
  }
407
496
 
@@ -416,14 +505,20 @@ export async function sendBySession(
416
505
 
417
506
  // Session webhook supports native media messages; prefer that when media info is available.
418
507
  if (options.mediaPath && options.mediaType) {
419
- const mediaId = await uploadMedia(config, options.mediaPath, options.mediaType, log);
420
- if (mediaId) {
508
+ const uploadResult = await uploadMedia(config, options.mediaPath, options.mediaType, log, {
509
+ mediaLocalRoots: options.mediaLocalRoots,
510
+ });
511
+ if (uploadResult) {
512
+ const { mediaId, buffer: uploadedBuffer } = uploadResult;
421
513
  let body: any;
422
514
 
423
515
  if (options.mediaType === "image") {
424
516
  body = { msgtype: "image", image: { media_id: mediaId } };
425
517
  } else if (options.mediaType === "voice") {
426
- const durationMs = await getVoiceDurationMs(options.mediaPath, options.mediaType, log);
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
+ });
427
522
  body = { msgtype: "voice", voice: { media_id: mediaId, duration: String(durationMs) } };
428
523
  } else if (options.mediaType === "video") {
429
524
  body = { msgtype: "video", video: { media_id: mediaId } };
@@ -449,7 +544,7 @@ export async function sendBySession(
449
544
  }
450
545
 
451
546
  // Fallback to text/markdown reply payload.
452
- const normalizedText = convertMarkdownTablesToPlainText(text);
547
+ const normalizedText = config.convertMarkdownTables !== false ? convertMarkdownTablesToPlainText(text) : text;
453
548
  const { useMarkdown, title } = detectMarkdownAndExtractTitle(normalizedText, options, "Clawdbot 消息");
454
549
  const chunks = splitMarkdownChunks(normalizedText, DINGTALK_TEXT_CHUNK_LIMIT);
455
550
 
@@ -492,7 +587,7 @@ export async function sendMessage(
492
587
  const messageType = config.messageType || "markdown";
493
588
  const log = options.log || getLogger();
494
589
 
495
- if (messageType === "card" && options.card) {
590
+ if (messageType === "card" && options.card && !options.forceMarkdown) {
496
591
  const card = options.card;
497
592
  if (isCardInTerminalState(card.state)) {
498
593
  if (options.sessionWebhook) {
@@ -530,34 +625,41 @@ export async function sendMessage(
530
625
 
531
626
  if (options.sessionWebhook) {
532
627
  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({
628
+ const delivery = extractOutboundDeliveryMetadata(data);
629
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
630
+ const persistedText = buildPersistedOutboundText(text, options);
631
+ persistOutboundMessageContext({
552
632
  storePath: options.storePath,
553
633
  accountId: options.accountId,
554
634
  conversationId: options.conversationId || conversationId,
555
- messageId,
556
- text,
557
- messageType: "outbound-proactive",
635
+ text: persistedText,
636
+ messageType: options.mediaPath && options.mediaType ? "outbound-media" : "outbound",
637
+ quotedRef: options.quotedRef,
558
638
  log,
639
+ delivery: {
640
+ ...delivery,
641
+ kind: "session",
642
+ },
559
643
  });
644
+ return { ok: true, data, messageId };
560
645
  }
646
+
647
+ const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
648
+ const delivery = extractOutboundDeliveryMetadata(result);
649
+ const messageId = delivery.messageId || delivery.processQueryKey || delivery.outTrackId;
650
+ persistOutboundMessageContext({
651
+ storePath: options.storePath,
652
+ accountId: options.accountId,
653
+ conversationId: options.conversationId || conversationId,
654
+ text,
655
+ messageType: "outbound-proactive",
656
+ quotedRef: options.quotedRef,
657
+ log,
658
+ delivery: {
659
+ ...delivery,
660
+ kind: isTrackingResult(result) ? "proactive-card" : "proactive-text",
661
+ },
662
+ });
561
663
  if (isTrackingResult(result)) {
562
664
  return { ok: true, tracking: result.tracking };
563
665
  }
@@ -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/core";
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/core";
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
+ }