@soimy/dingtalk 3.6.2 → 3.6.4
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/dist/index.js +387 -159
- package/dist/index.js.map +3 -3
- package/dist/src/card-draft-controller.d.ts.map +1 -1
- package/dist/src/card-service.d.ts.map +1 -1
- package/dist/src/inbound-handler.d.ts.map +1 -1
- package/dist/src/message-utils.d.ts +10 -0
- package/dist/src/message-utils.d.ts.map +1 -1
- package/dist/src/reply-strategy-card.d.ts.map +1 -1
- package/dist/src/reply-strategy-markdown.d.ts.map +1 -1
- package/dist/src/reply-strategy-types.d.ts +2 -0
- package/dist/src/reply-strategy-types.d.ts.map +1 -1
- package/dist/src/send-service.d.ts.map +1 -1
- package/dist/src/targeting/agent-routing.d.ts +55 -16
- package/dist/src/targeting/agent-routing.d.ts.map +1 -1
- package/dist/src/types.d.ts +7 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/card-callback-service.ts +1 -1
- package/src/card-draft-controller.ts +4 -3
- package/src/card-service.ts +28 -4
- package/src/inbound-handler.ts +82 -33
- package/src/message-utils.ts +103 -7
- package/src/reply-strategy-card.ts +31 -3
- package/src/reply-strategy-markdown.ts +139 -20
- package/src/reply-strategy-types.ts +3 -0
- package/src/send-service.ts +67 -65
- package/src/targeting/agent-routing.ts +151 -85
- package/src/types.ts +7 -0
|
@@ -7,18 +7,27 @@
|
|
|
7
7
|
* (channel + accountId + peer), not content-based dynamic routing.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
11
10
|
import { maybeResolveTextAlias } from "openclaw/plugin-sdk/command-auth";
|
|
12
|
-
import {
|
|
11
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
13
12
|
import { resolveRobotCode } from "../config";
|
|
14
13
|
import { parseLearnCommand } from "../learning-command-service";
|
|
15
14
|
import { getDingTalkRuntime } from "../runtime";
|
|
16
15
|
import { sendBySession } from "../send-service";
|
|
16
|
+
import type {
|
|
17
|
+
AgentNameMatch,
|
|
18
|
+
DingTalkConfig,
|
|
19
|
+
DingTalkInboundMessage,
|
|
20
|
+
HandleDingTalkMessageParams,
|
|
21
|
+
Logger,
|
|
22
|
+
MessageContent,
|
|
23
|
+
} from "../types";
|
|
17
24
|
import { getErrorMessage } from "../utils";
|
|
18
|
-
import
|
|
25
|
+
import { resolveAtAgents } from "./agent-name-matcher";
|
|
19
26
|
|
|
20
27
|
export class HostRoutingHelperUnavailableError extends Error {
|
|
21
|
-
constructor(
|
|
28
|
+
constructor(
|
|
29
|
+
message = "DingTalk sub-agent routing requires runtime.channel.routing.buildAgentSessionKey from the host runtime.",
|
|
30
|
+
) {
|
|
22
31
|
super(message);
|
|
23
32
|
this.name = "HostRoutingHelperUnavailableError";
|
|
24
33
|
}
|
|
@@ -42,109 +51,147 @@ export function buildAgentSessionKey(params: {
|
|
|
42
51
|
if (typeof routing.buildAgentSessionKey !== "function") {
|
|
43
52
|
throw new HostRoutingHelperUnavailableError();
|
|
44
53
|
}
|
|
45
|
-
return (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
54
|
-
).toLowerCase();
|
|
54
|
+
return (routing.buildAgentSessionKey as (p: unknown) => string)({
|
|
55
|
+
agentId,
|
|
56
|
+
channel: "dingtalk",
|
|
57
|
+
accountId,
|
|
58
|
+
peer: { kind: peerKind, id: peerId },
|
|
59
|
+
dmScope: cfg.session?.dmScope,
|
|
60
|
+
identityLinks: cfg.session?.identityLinks,
|
|
61
|
+
}).toLowerCase();
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
65
|
+
* The single routing decision for an inbound message.
|
|
66
|
+
*
|
|
67
|
+
* - `default` — route to the peer's default agent via `resolveAgentRoute`.
|
|
68
|
+
* Covers messages with no @mention, learn/session commands, and slash
|
|
69
|
+
* commands that mention only real users.
|
|
70
|
+
* - `subagent-content` — `@agent <message>` targeting one or more configured
|
|
71
|
+
* agents; each is dispatched recursively. `unmatchedNames` /
|
|
72
|
+
* `hasInvalidAgentNames` drive the "agent not found" notice.
|
|
73
|
+
* - `subagent-command` — `@agent /command` targeting a configured agent. The
|
|
74
|
+
* command is dispatched to that agent's session with the @mention prefix
|
|
75
|
+
* stripped from `commandText`.
|
|
61
76
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
export type MessageTarget =
|
|
78
|
+
| { kind: "default" }
|
|
79
|
+
| {
|
|
80
|
+
kind: "subagent-content";
|
|
81
|
+
matchedAgents: AgentNameMatch[];
|
|
82
|
+
unmatchedNames: string[];
|
|
83
|
+
hasInvalidAgentNames: boolean;
|
|
84
|
+
}
|
|
85
|
+
| { kind: "subagent-command"; agent: AgentNameMatch; commandText: string };
|
|
65
86
|
|
|
66
87
|
/**
|
|
67
|
-
* Resolve
|
|
88
|
+
* Resolve how an inbound message should be routed.
|
|
68
89
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
90
|
+
* This is the single source of truth for "who does this message target": both
|
|
91
|
+
* content sub-agent routing and targeted slash commands are decided here, so
|
|
92
|
+
* the @mention/alias parsing happens exactly once. The function is pure — the
|
|
93
|
+
* caller is responsible for any side effects (dispatch, fallback notices).
|
|
73
94
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
95
|
+
* In group chats @mentions come from the DingTalk SDK (`atMentions`); in DMs
|
|
96
|
+
* `extractMessageContent` populates the same field for text messages, so the
|
|
97
|
+
* same logic enables sub-agent routing in DMs without an `isGroup` guard.
|
|
76
98
|
*/
|
|
77
|
-
export
|
|
99
|
+
export function resolveMessageTarget(params: {
|
|
78
100
|
extractedContent: MessageContent;
|
|
79
101
|
cfg: OpenClawConfig;
|
|
80
102
|
isGroup: boolean;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
senderId: string;
|
|
84
|
-
log?: Logger;
|
|
85
|
-
}): Promise<{
|
|
86
|
-
matchedAgents: AgentNameMatch[];
|
|
87
|
-
preDownloadedMedia?: { mediaPath?: string; mediaType?: string };
|
|
88
|
-
} | null> {
|
|
89
|
-
const { extractedContent, cfg, isGroup, dingtalkConfig, sessionWebhook, senderId, log } = params;
|
|
90
|
-
|
|
103
|
+
}): MessageTarget {
|
|
104
|
+
const { extractedContent, cfg, isGroup } = params;
|
|
91
105
|
const atMentions = extractedContent.atMentions || [];
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
106
|
+
// No @mentions or no configured agents → nothing to route dynamically.
|
|
107
|
+
if (atMentions.length === 0 || !cfg.agents?.list || cfg.agents.list.length === 0) {
|
|
108
|
+
return { kind: "default" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Strip quoted prefix before inspecting commands to avoid false positives
|
|
95
112
|
// when the quoted message itself contains a command.
|
|
96
113
|
const textForCommandCheck = extractedContent.text.replace(/^\[引用[^\]]*\]\s*/, "");
|
|
97
|
-
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const textWithoutMentions = textForCommandCheck.replace(/^(?:@\S+\s+)*/u, "").trim();
|
|
102
|
-
const isSlashCommand = maybeResolveTextAlias(textWithoutMentions, cfg) !== null;
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
atMentions.length === 0 ||
|
|
106
|
-
!cfg.agents?.list ||
|
|
107
|
-
cfg.agents.list.length === 0 ||
|
|
108
|
-
isLearnCommand ||
|
|
109
|
-
isSlashCommand
|
|
110
|
-
) {
|
|
111
|
-
return null;
|
|
114
|
+
// Learn/session commands are handled by the plugin command layer on the
|
|
115
|
+
// default route, so they must bypass sub-agent routing entirely.
|
|
116
|
+
if (parseLearnCommand(textForCommandCheck).scope !== "unknown") {
|
|
117
|
+
return { kind: "default" };
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
|
|
120
|
+
// DM has no @picker list from DingTalk; only group chats provide atUsers for real-user hints.
|
|
121
|
+
const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : undefined;
|
|
122
|
+
const { matchedAgents, unmatchedNames, hasInvalidAgentNames } = resolveAtAgents(
|
|
115
123
|
atMentions,
|
|
116
124
|
cfg,
|
|
117
125
|
atUserDingtalkIds,
|
|
118
126
|
);
|
|
119
|
-
log?.info?.(
|
|
120
|
-
`[DingTalk] Sub-agent resolve: matched=${matchedAgents.map((a) => a.agentId).join(",")} unmatched=${unmatchedNames.join(",")} realUsers=${realUserCount}`,
|
|
121
|
-
);
|
|
122
127
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
// Slash commands like /new, /stop, /reasoning must reach the framework's own
|
|
129
|
+
// command layer. When one targets a configured agent, route it to that
|
|
130
|
+
// agent's session with the leading @mention tokens stripped. When no agent
|
|
131
|
+
// matched, fall through: an unmatched agent name still produces the "not
|
|
132
|
+
// found" notice, while a slash command that only @mentions real users (or no
|
|
133
|
+
// agent at all) goes to the default route.
|
|
134
|
+
const commandText = textForCommandCheck.replace(/^(?:@\S+\s+)*/u, "").trim();
|
|
135
|
+
if (maybeResolveTextAlias(commandText, cfg) !== null) {
|
|
136
|
+
const firstMatch = matchedAgents[0];
|
|
137
|
+
if (firstMatch) {
|
|
138
|
+
return { kind: "subagent-command", agent: firstMatch, commandText };
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
if (matchedAgents.length === 0) {
|
|
137
|
-
return
|
|
142
|
+
if (matchedAgents.length === 0 && !hasInvalidAgentNames) {
|
|
143
|
+
return { kind: "default" };
|
|
138
144
|
}
|
|
145
|
+
return { kind: "subagent-content", matchedAgents, unmatchedNames, hasInvalidAgentNames };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Sanitize agent name for safe use in markdown prefix and context hints.
|
|
150
|
+
* Strips brackets, newlines, and control characters to prevent markdown
|
|
151
|
+
* breakage and prompt injection.
|
|
152
|
+
*/
|
|
153
|
+
function sanitizeAgentName(name: string): string {
|
|
154
|
+
return name.replace(/[[\]\r\n]/g, "").trim();
|
|
155
|
+
}
|
|
139
156
|
|
|
140
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Send the "agent not found" fallback notice for unmatched @mention names.
|
|
159
|
+
*
|
|
160
|
+
* In group chats the notice @s the sender back; in DMs it is a plain reply.
|
|
161
|
+
* Failures are swallowed — the notice is best-effort and must not abort the
|
|
162
|
+
* inbound pipeline.
|
|
163
|
+
*/
|
|
164
|
+
export async function sendUnmatchedAgentNotice(params: {
|
|
165
|
+
unmatchedNames: string[];
|
|
166
|
+
isGroup: boolean;
|
|
167
|
+
senderId: string;
|
|
168
|
+
dingtalkConfig: DingTalkConfig;
|
|
169
|
+
sessionWebhook: string;
|
|
170
|
+
log?: Logger;
|
|
171
|
+
}): Promise<void> {
|
|
172
|
+
const { unmatchedNames, isGroup, senderId, dingtalkConfig, sessionWebhook, log } = params;
|
|
173
|
+
const fallbackReason = `未找到名为"${unmatchedNames.join("、")}"的助手`;
|
|
174
|
+
try {
|
|
175
|
+
const sendOptions = isGroup ? { atUserId: senderId, log } : { log };
|
|
176
|
+
await sendBySession(dingtalkConfig, sessionWebhook, `⚠️ ${fallbackReason}`, sendOptions);
|
|
177
|
+
} catch (err: unknown) {
|
|
178
|
+
log?.debug?.(`[DingTalk] Failed to send fallback notice: ${getErrorMessage(err)}`);
|
|
179
|
+
}
|
|
141
180
|
}
|
|
142
181
|
|
|
143
182
|
/**
|
|
144
183
|
* Process matched sub-agents by dispatching each to handleDingTalkMessage.
|
|
184
|
+
*
|
|
185
|
+
* When `commandText` is set, this is a targeted slash command (`@agent /new`):
|
|
186
|
+
* `matchedAgents` holds the single resolved agent, the command is threaded
|
|
187
|
+
* through `subAgentOptions.commandText`, and no response prefix is added.
|
|
188
|
+
* Otherwise it is content routing — each matched agent is dispatched with a
|
|
189
|
+
* `> 🤖 **agent**:` prefix. Both modes share the same recursive dispatch path,
|
|
190
|
+
* so the host-helper-missing fallback below is the only copy.
|
|
145
191
|
*/
|
|
146
192
|
export async function dispatchSubAgents(params: {
|
|
147
193
|
matchedAgents: AgentNameMatch[];
|
|
194
|
+
commandText?: string;
|
|
148
195
|
cfg: OpenClawConfig;
|
|
149
196
|
accountId: string;
|
|
150
197
|
data: DingTalkInboundMessage;
|
|
@@ -152,18 +199,36 @@ export async function dispatchSubAgents(params: {
|
|
|
152
199
|
sessionWebhook: string;
|
|
153
200
|
extractedContent: MessageContent;
|
|
154
201
|
handleMessage: (params: HandleDingTalkMessageParams) => Promise<void>;
|
|
155
|
-
downloadMedia: (
|
|
202
|
+
downloadMedia: (
|
|
203
|
+
config: DingTalkConfig,
|
|
204
|
+
mediaPath: string,
|
|
205
|
+
log?: Logger,
|
|
206
|
+
) => Promise<{ path: string; mimeType: string } | null>;
|
|
156
207
|
log?: Logger;
|
|
157
208
|
}): Promise<void> {
|
|
158
|
-
const {
|
|
209
|
+
const {
|
|
210
|
+
matchedAgents,
|
|
211
|
+
commandText,
|
|
212
|
+
cfg,
|
|
213
|
+
accountId,
|
|
214
|
+
data,
|
|
215
|
+
dingtalkConfig,
|
|
216
|
+
sessionWebhook,
|
|
217
|
+
extractedContent,
|
|
218
|
+
handleMessage,
|
|
219
|
+
downloadMedia: download,
|
|
220
|
+
log,
|
|
221
|
+
} = params;
|
|
159
222
|
|
|
160
223
|
// Pre-download media once to avoid duplication across sub-agents
|
|
161
|
-
let preDownloadedMedia:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
224
|
+
let preDownloadedMedia:
|
|
225
|
+
| {
|
|
226
|
+
mediaPath?: string;
|
|
227
|
+
mediaType?: string;
|
|
228
|
+
mediaPaths?: string[];
|
|
229
|
+
mediaTypes?: string[];
|
|
230
|
+
}
|
|
231
|
+
| undefined;
|
|
167
232
|
const robotCode = resolveRobotCode(dingtalkConfig);
|
|
168
233
|
if (robotCode) {
|
|
169
234
|
const downloadCodes =
|
|
@@ -203,16 +268,17 @@ export async function dispatchSubAgents(params: {
|
|
|
203
268
|
dingtalkConfig,
|
|
204
269
|
subAgentOptions: {
|
|
205
270
|
agentId: agentMatch.agentId,
|
|
206
|
-
responsePrefix:
|
|
271
|
+
responsePrefix: commandText
|
|
272
|
+
? ""
|
|
273
|
+
: `> 🤖 **${sanitizeAgentName(agentMatch.matchedName)}**:\n\n`,
|
|
207
274
|
matchedName: agentMatch.matchedName,
|
|
275
|
+
commandText,
|
|
208
276
|
},
|
|
209
277
|
preDownloadedMedia,
|
|
210
278
|
});
|
|
211
279
|
} catch (error) {
|
|
212
280
|
const message = getErrorMessage(error);
|
|
213
|
-
log?.error?.(
|
|
214
|
-
`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`,
|
|
215
|
-
);
|
|
281
|
+
log?.error?.(`[DingTalk] Sub-agent ${agentMatch.agentId} failed: ${message}`);
|
|
216
282
|
if (error instanceof HostRoutingHelperUnavailableError && !helperMissingWarningSent) {
|
|
217
283
|
helperMissingWarningSent = true;
|
|
218
284
|
try {
|
package/src/types.ts
CHANGED
|
@@ -453,6 +453,13 @@ export interface SubAgentOptions {
|
|
|
453
453
|
responsePrefix: string;
|
|
454
454
|
/** The matched agent name */
|
|
455
455
|
matchedName: string;
|
|
456
|
+
/**
|
|
457
|
+
* When set, this dispatch is a targeted slash command (`@agent /new`).
|
|
458
|
+
* The value is the command text with the leading `@mention` stripped, used
|
|
459
|
+
* as `CommandBody` so the framework command layer can recognize it. Content
|
|
460
|
+
* sub-agent dispatches leave this undefined.
|
|
461
|
+
*/
|
|
462
|
+
commandText?: string;
|
|
456
463
|
}
|
|
457
464
|
|
|
458
465
|
/**
|