@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.
- package/README.md +141 -12
- package/package.json +2 -2
- 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 +60 -28
- package/src/config-schema.ts +28 -6
- package/src/config.ts +29 -5
- package/src/inbound-handler.ts +694 -520
- 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 +62 -5
- 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/send-service.ts +140 -52
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +151 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +114 -9
- package/src/quote-journal.ts +0 -242
- package/src/quoted-msg-cache.ts +0 -226
|
@@ -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
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
@@ -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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}
|