@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.
- package/README.md +141 -12
- package/index.ts +71 -66
- package/package.json +6 -5
- package/src/access-control.ts +65 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +17 -4
- package/src/ack-reaction-service.ts +66 -19
- package/src/attachment-text-extractor.ts +2 -1
- package/src/card-service.ts +145 -257
- package/src/channel.ts +106 -47
- package/src/config-schema.ts +28 -6
- package/src/config.ts +30 -6
- package/src/connection-manager.ts +16 -5
- package/src/inbound-handler.ts +694 -520
- package/src/media-utils.ts +99 -36
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +221 -42
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +381 -269
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/runtime.ts +5 -7
- package/src/send-service.ts +164 -62
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +152 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +124 -21
- package/src/quote-journal.ts +0 -242
- package/src/quoted-msg-cache.ts +0 -226
package/src/send-service.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
291
|
-
|
|
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
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
420
|
-
|
|
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
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}
|