@soimy/dingtalk 3.6.3 → 3.6.5

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.
@@ -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 { resolveAtAgents } from "./agent-name-matcher";
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 type { AgentNameMatch, DingTalkConfig, DingTalkInboundMessage, HandleDingTalkMessageParams, Logger, MessageContent } from "../types";
25
+ import { resolveAtAgents } from "./agent-name-matcher";
19
26
 
20
27
  export class HostRoutingHelperUnavailableError extends Error {
21
- constructor(message = "DingTalk sub-agent routing requires runtime.channel.routing.buildAgentSessionKey from the host runtime.") {
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
- (routing.buildAgentSessionKey as (p: unknown) => string)({
47
- agentId,
48
- channel: "dingtalk",
49
- accountId,
50
- peer: { kind: peerKind, id: peerId },
51
- dmScope: cfg.session?.dmScope,
52
- identityLinks: cfg.session?.identityLinks,
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
- * Sanitize agent name for safe use in markdown prefix and context hints.
59
- * Strips brackets, newlines, and control characters to prevent markdown
60
- * breakage and prompt injection.
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
- function sanitizeAgentName(name: string): string {
63
- return name.replace(/[[\]\r\n]/g, "").trim();
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 @mention-based sub-agent routing for a group or direct message.
88
+ * Resolve how an inbound message should be routed.
68
89
  *
69
- * In group chats, @mentions are populated by the DingTalk SDK (atMentions field).
70
- * In direct messages (DM), the SDK also populates atMentions for text-type messages
71
- * via extractMessageContent in message-utils.ts, so the same field is reused here.
72
- * The !isGroup guard is removed to enable sub-agent routing in DM as well.
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
- * Returns matched agents if any @mentions resolve to configured agents,
75
- * or null if the message should be handled by the default agent.
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 async function resolveSubAgentRoute(params: {
99
+ export function resolveMessageTarget(params: {
78
100
  extractedContent: MessageContent;
79
101
  cfg: OpenClawConfig;
80
102
  isGroup: boolean;
81
- dingtalkConfig: DingTalkConfig;
82
- sessionWebhook: string;
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
- // DM has no @picker list from DingTalk; only group chats provide atUsers for real-user hints.
93
- const atUserDingtalkIds = isGroup ? extractedContent.atUserDingtalkIds : undefined;
94
- // Strip quoted prefix before checking commands to avoid false positives
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
- const isLearnCommand = parseLearnCommand(textForCommandCheck).scope !== "unknown";
98
- // Slash commands like /new, /stop, /reasoning etc. must bypass sub-agent
99
- // routing so they reach the framework's own command handling layer.
100
- // Strip leading @mention tokens first since DM text may look like "@Agent /new".
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
- const { matchedAgents, unmatchedNames, realUserCount, hasInvalidAgentNames } = resolveAtAgents(
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
- // Send fallback notice for unmatched agent names
124
- if (hasInvalidAgentNames) {
125
- const fallbackReason = `未找到名为"${unmatchedNames.join("、")}"的助手`;
126
- try {
127
- const sendOptions = isGroup ? { atUserId: senderId, log } : { log };
128
- await sendBySession(dingtalkConfig, sessionWebhook, `⚠️ ${fallbackReason}`, {
129
- ...sendOptions,
130
- });
131
- } catch (err: unknown) {
132
- log?.debug?.(`[DingTalk] Failed to send fallback notice: ${getErrorMessage(err)}`);
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 null;
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
- return { matchedAgents };
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: (config: DingTalkConfig, mediaPath: string, log?: Logger) => Promise<{ path: string; mimeType: string } | null>;
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 { matchedAgents, cfg, accountId, data, dingtalkConfig, sessionWebhook, extractedContent, handleMessage, downloadMedia: download, log } = params;
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
- mediaPath?: string;
163
- mediaType?: string;
164
- mediaPaths?: string[];
165
- mediaTypes?: string[];
166
- } | undefined;
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: `> 🤖 **${sanitizeAgentName(agentMatch.matchedName)}**:\n\n`,
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
  /**