@youdu/openclaw-youdu 2026.5.6 → 2026.5.8-2

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.
@@ -4,6 +4,7 @@ import { TEXT_CHUNK_LIMIT, CHANNEL_ID, DEFAULT_DM_POLICY } from './const.js';
4
4
  import { youduSetupAdapter, youduSetupWizard } from './onboarding.js';
5
5
  import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from './openclaw-compat.js';
6
6
  import { getYouDuRuntime } from './runtime.js';
7
+ import { sendYouDuMediaMessage, sendYouDuTextMessage } from './message-sender.js';
7
8
 
8
9
  /*
9
10
  * Licensed under the MIT License.
@@ -11,7 +12,7 @@ import { getYouDuRuntime } from './runtime.js';
11
12
  const meta = {
12
13
  id: CHANNEL_ID,
13
14
  label: '有度',
14
- selectionLabel: '有度 (YouDu)',
15
+ selectionLabel: '有度',
15
16
  detailLabel: '有度机器人',
16
17
  docsPath: `/channels/${CHANNEL_ID}`,
17
18
  docsLabel: CHANNEL_ID,
@@ -147,6 +148,26 @@ const youduPlugin = {
147
148
  deliveryMode: "gateway",
148
149
  chunker: (text, limit) => getYouDuRuntime().channel.text.chunkMarkdownText(text, limit),
149
150
  textChunkLimit: TEXT_CHUNK_LIMIT,
151
+ sendText: async ({ to, text, accountId }) => {
152
+ return sendYouDuTextMessage({
153
+ to,
154
+ text,
155
+ accountId,
156
+ });
157
+ },
158
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId }) => {
159
+ if (!mediaUrl) {
160
+ throw new Error("No media URL provided");
161
+ }
162
+ return sendYouDuMediaMessage({
163
+ cfg,
164
+ to,
165
+ text,
166
+ mediaUrl,
167
+ mediaLocalRoots,
168
+ accountId,
169
+ });
170
+ },
150
171
  },
151
172
  status: {
152
173
  defaultRuntime: {
@@ -3,7 +3,7 @@ import type { ResolvedYouDuAccount, YouDuConfig } from './utils.js';
3
3
  import { WSClient, WsFrame } from "./sdk/ws-client";
4
4
  import { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
5
5
  export declare namespace Youdu {
6
- const RequestTimeoutMs = 30000;
6
+ const requestTimeoutMs = 30000;
7
7
  type MediaKind = 'image' | 'file' | 'audio' | 'video';
8
8
  interface MediaBody {
9
9
  mediaId?: string;
@@ -34,8 +34,26 @@ export declare namespace Youdu {
34
34
  headers: IncomingHttpHeaders;
35
35
  body: Buffer;
36
36
  }
37
+ interface UploadMediaResponse {
38
+ kind?: string;
39
+ mediaId?: string;
40
+ name?: string;
41
+ contentType?: string;
42
+ size?: number;
43
+ width?: number;
44
+ height?: number;
45
+ duration?: number;
46
+ data?: {
47
+ mediaId?: string;
48
+ name?: string;
49
+ contentType?: string;
50
+ size?: number;
51
+ width?: number;
52
+ height?: number;
53
+ duration?: number;
54
+ };
55
+ }
37
56
  }
38
- export declare const YouDuRequestTimeoutMs = 30000;
39
57
  export declare function inferInboundMessageText(body: {
40
58
  text?: {
41
59
  content?: string;
@@ -49,6 +67,7 @@ export declare function inferInboundMessageText(body: {
49
67
  video?: Youdu.MediaBody;
50
68
  }): string;
51
69
  export declare function extractInboundMedia(body: {
70
+ msgType?: string;
52
71
  media?: Array<Youdu.MediaBody & {
53
72
  kind?: string;
54
73
  }>;
@@ -73,3 +92,8 @@ export declare function downloadInboundMedia(params: {
73
92
  config: YouDuConfig;
74
93
  token?: string;
75
94
  }): Promise<Array<Youdu.DownloadedInboundMedia>>;
95
+ /**
96
+ * 统一构建有度发送媒体时可访问的本地路径白名单。
97
+ * 这样无论是回复链路还是官方 message send 链路,都能走同一套目录规则。
98
+ */
99
+ export declare function resolveYouDuMediaLocalRoots(config?: YouDuConfig): Promise<string[]>;
package/dist/src/media.js CHANGED
@@ -1,17 +1,18 @@
1
- import { loadOutboundMediaFromUrl } from './openclaw-compat.js';
1
+ import { getDefaultMediaLocalRoots, resolveStateDir, loadOutboundMediaFromUrl } from './openclaw-compat.js';
2
2
  import * as path from 'path';
3
3
  import * as https from 'node:https';
4
4
  import { getYouDuRuntime } from './runtime.js';
5
- import { DEFAULT_MEDIA_MAX_SIZE, DOWNLOAD_MEDIA_URL, HTTP_SCHEME, PORT, UPLOAD_MEDIA_URL } from './const.js';
5
+ import { UPLOAD_MEDIA_URL, DEFAULT_MEDIA_MAX_SIZE, DOWNLOAD_MEDIA_URL, HTTP_SCHEME, PORT } from './const.js';
6
6
  import { sendYouDuMediaReply } from './message-sender.js';
7
7
  import { buildYouDuTlsConnectOptions } from './tls.js';
8
+ import os from 'os';
8
9
 
9
10
  /*
10
11
  * Licensed under the MIT License.
11
12
  */
12
13
  var Youdu;
13
14
  (function (Youdu) {
14
- Youdu.RequestTimeoutMs = 30000;
15
+ Youdu.requestTimeoutMs = 30000;
15
16
  })(Youdu || (Youdu = {}));
16
17
  function getMediaAuthHeaders(token) {
17
18
  const headers = {};
@@ -77,8 +78,8 @@ function request(url, options) {
77
78
  });
78
79
  });
79
80
  req.on("error", reject);
80
- req.setTimeout(Youdu.RequestTimeoutMs, () => {
81
- req.destroy(new Error(`Youdu request timed out after ${Youdu.RequestTimeoutMs}ms`));
81
+ req.setTimeout(Youdu.requestTimeoutMs, () => {
82
+ req.destroy(new Error(`Youdu request timed out after ${Youdu.requestTimeoutMs}ms`));
82
83
  });
83
84
  if (options.body) {
84
85
  req.write(options.body);
@@ -102,6 +103,10 @@ function fallbackFileNameFromUrl(mediaUrl) {
102
103
  }
103
104
  function kindFromContentType(contentType) {
104
105
  const normalized = String(contentType ?? '').toLowerCase();
106
+ // SVG 更适合作为文件发送,避免被客户端按图片类型处理后无法正常展示或接收。
107
+ if (normalized === 'image/svg+xml') {
108
+ return 'file';
109
+ }
105
110
  if (normalized.startsWith('image/')) {
106
111
  return 'image';
107
112
  }
@@ -125,6 +130,15 @@ function kindLabel(kind) {
125
130
  return '[文件]';
126
131
  }
127
132
  }
133
+ function readUploadedMediaField(payload, key) {
134
+ // 兼容两种上传返回格式:
135
+ // 1. 当前协议文档里的根级字段;
136
+ // 2. 旧实现里包在 data 下的字段。
137
+ if (payload[key] !== undefined && payload[key] !== null) {
138
+ return payload[key];
139
+ }
140
+ return payload.data?.[key];
141
+ }
128
142
  function inferInboundMessageText(body) {
129
143
  const text = body.text?.content?.trim();
130
144
  if (text) {
@@ -139,6 +153,24 @@ function inferInboundMessageText(body) {
139
153
  }
140
154
  function extractInboundMedia(body) {
141
155
  const items = [];
156
+ // 某些回调里 media[] 只有 mediaId/name/size,没有显式 kind,
157
+ // 这里根据顶层 msgType 或具名媒体节点做一次回退推断。
158
+ const fallbackKindFromBody = (() => {
159
+ const normalizedMsgType = String(body.msgType ?? "").toLowerCase();
160
+ if (normalizedMsgType === "image" || body.image?.mediaId) {
161
+ return "image";
162
+ }
163
+ if (normalizedMsgType === "audio" || body.audio?.mediaId) {
164
+ return "audio";
165
+ }
166
+ if (normalizedMsgType === "video" || body.video?.mediaId) {
167
+ return "video";
168
+ }
169
+ if (normalizedMsgType === "file" || body.file?.mediaId) {
170
+ return "file";
171
+ }
172
+ return undefined;
173
+ })();
142
174
  const push = (kind, node) => {
143
175
  if (!node?.mediaId) {
144
176
  return;
@@ -163,6 +195,10 @@ function extractInboundMedia(body) {
163
195
  push(kind, media);
164
196
  continue;
165
197
  }
198
+ if (!kind && fallbackKindFromBody) {
199
+ push(fallbackKindFromBody, media);
200
+ continue;
201
+ }
166
202
  push('file', media);
167
203
  }
168
204
  if (items.length > 0) {
@@ -215,21 +251,21 @@ async function uploadOutboundMedia(params) {
215
251
  if (response.statusCode >= 400) {
216
252
  throw new Error(`upload media failed: ${response.statusCode} ${response.body.toString("utf8")}`);
217
253
  }
254
+ // 上传接口返回成功后,统一抽取 mediaId 和媒体元数据,供 send_msg/respond_msg 复用。
218
255
  const payload = JSON.parse(response.body.toString("utf8"));
219
- const data = payload?.data ?? {};
220
- const mediaId = String(data.mediaId ?? '');
256
+ const mediaId = String(readUploadedMediaField(payload, "mediaId") ?? "");
221
257
  if (!mediaId) {
222
258
  throw new Error('upload media failed: mediaId is missing');
223
259
  }
224
260
  return {
225
261
  kind,
226
262
  mediaId: mediaId,
227
- name: String(data.name ?? fileName),
228
- size: Number(data.size ?? media.buffer.length),
229
- width: Number(data.width ?? 0) || undefined,
230
- height: Number(data.height ?? 0) || undefined,
231
- duration: Number(data.duration ?? 0) || undefined,
232
- contentType: String(data.contentType ?? media.contentType ?? ''),
263
+ name: String(readUploadedMediaField(payload, "name") ?? fileName),
264
+ size: Number(readUploadedMediaField(payload, "size") ?? media.buffer.length),
265
+ width: Number(readUploadedMediaField(payload, "width") ?? 0) || undefined,
266
+ height: Number(readUploadedMediaField(payload, "height") ?? 0) || undefined,
267
+ duration: Number(readUploadedMediaField(payload, "duration") ?? 0) || undefined,
268
+ contentType: String(readUploadedMediaField(payload, "contentType") ?? media.contentType ?? ''),
233
269
  };
234
270
  }
235
271
  catch (error) {
@@ -248,13 +284,13 @@ function buildMediaMessageBody(media) {
248
284
  };
249
285
  switch (media.kind) {
250
286
  case 'image':
251
- return { msgtype: 'image', image: payload };
287
+ return { msgType: 'image', image: payload };
252
288
  case 'audio':
253
- return { msgtype: 'audio', audio: payload };
289
+ return { msgType: 'audio', audio: payload };
254
290
  case 'video':
255
- return { msgtype: 'video', video: payload };
291
+ return { msgType: 'video', video: payload };
256
292
  default:
257
- return { msgtype: 'file', file: payload };
293
+ return { msgType: 'file', file: payload };
258
294
  }
259
295
  }
260
296
  async function downloadInboundMedia(params) {
@@ -291,5 +327,24 @@ async function downloadInboundMedia(params) {
291
327
  }
292
328
  return mediaList;
293
329
  }
330
+ /**
331
+ * 统一构建有度发送媒体时可访问的本地路径白名单。
332
+ * 这样无论是回复链路还是官方 message send 链路,都能走同一套目录规则。
333
+ */
334
+ async function resolveYouDuMediaLocalRoots(config) {
335
+ const defaults = await getDefaultMediaLocalRoots();
336
+ const roots = new Set();
337
+ for (const root of defaults) {
338
+ roots.add(path.resolve(root));
339
+ }
340
+ roots.add(path.resolve(resolveStateDir()));
341
+ if (config?.mediaLocalRoots) {
342
+ for (const root of config.mediaLocalRoots) {
343
+ const resolved = path.resolve(root.replace(/^~(?=\/|$)/, os.homedir()));
344
+ roots.add(resolved);
345
+ }
346
+ }
347
+ return Array.from(roots);
348
+ }
294
349
 
295
- export { Youdu, buildMediaMessageBody, downloadInboundMedia, extractInboundMedia, inferInboundMessageText, uploadAndSendMedia, uploadOutboundMedia };
350
+ export { Youdu, buildMediaMessageBody, downloadInboundMedia, extractInboundMedia, inferInboundMessageText, resolveYouDuMediaLocalRoots, uploadAndSendMedia, uploadOutboundMedia };
@@ -1,6 +1,6 @@
1
1
  import { RuntimeEnv } from 'openclaw/plugin-sdk/runtime-env';
2
- import { WSClient, WsFrame } from './sdk/ws-client';
3
2
  import { type Youdu } from "./media";
3
+ import { WSClient, WsFrame } from './sdk/ws-client';
4
4
  /**
5
5
  * 发送 YouDu 回复
6
6
  */
@@ -16,3 +16,30 @@ export declare function sendYouDuMediaReply(params: {
16
16
  mediaAttachment: Youdu.InboundMediaAttachment;
17
17
  runtime: RuntimeEnv;
18
18
  }): Promise<void>;
19
+ /**
20
+ * 主动发送有度文本消息。
21
+ */
22
+ export declare function sendYouDuTextMessage(params: {
23
+ to: string;
24
+ text: string;
25
+ accountId?: string | null;
26
+ }): Promise<{
27
+ channel: string;
28
+ messageId: string;
29
+ chatId: string;
30
+ }>;
31
+ /**
32
+ * 主动发送有度媒体消息。
33
+ */
34
+ export declare function sendYouDuMediaMessage(params: {
35
+ cfg: any;
36
+ to: string;
37
+ mediaUrl: string;
38
+ text?: string;
39
+ mediaLocalRoots?: readonly string[];
40
+ accountId?: string | null;
41
+ }): Promise<{
42
+ channel: string;
43
+ messageId: string;
44
+ chatId: string;
45
+ }>;
@@ -1,4 +1,8 @@
1
- import { buildMediaMessageBody } from './media.js';
1
+ import { DEFAULT_ACCOUNT_ID } from './openclaw-compat.js';
2
+ import { getDirectChatTarget, getYouDuWebSocket } from './state-manager.js';
3
+ import { CHANNEL_ID } from './const.js';
4
+ import { resolveYouDuMediaLocalRoots, uploadOutboundMedia, buildMediaMessageBody } from './media.js';
5
+ import { resolveYouDuAccount } from './utils.js';
2
6
 
3
7
  /*
4
8
  * Licensed under the MIT License.
@@ -10,7 +14,7 @@ async function sendYouDuReply(params) {
10
14
  const { wsClient, frame, text, runtime } = params;
11
15
  try {
12
16
  await wsClient.reply(frame, {
13
- msgtype: 'text',
17
+ msgType: 'text',
14
18
  text: { content: text },
15
19
  });
16
20
  }
@@ -31,5 +35,76 @@ async function sendYouDuMediaReply(params) {
31
35
  throw err;
32
36
  }
33
37
  }
38
+ function getOutboundWsClient(accountId) {
39
+ const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
40
+ const wsClient = getYouDuWebSocket(resolvedAccountId);
41
+ if (!wsClient) {
42
+ throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
43
+ }
44
+ return wsClient;
45
+ }
46
+ function resolveOutboundChatId(target, accountId) {
47
+ const normalizedTarget = target.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
48
+ if (/^\d+$/.test(normalizedTarget)) {
49
+ return normalizedTarget;
50
+ }
51
+ const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
52
+ // 私聊主动发送优先复用运行期学习到的 account -> chatId 映射,
53
+ // 这样 message send 可以直接使用账号字符串作为目标。
54
+ const rememberedChatId = getDirectChatTarget(resolvedAccountId, normalizedTarget);
55
+ if (rememberedChatId) {
56
+ return rememberedChatId;
57
+ }
58
+ throw new Error(`Unknown YouDu direct target: ${normalizedTarget}. Send a message from that account to the bot first so the plugin can learn its numeric chatId.`);
59
+ }
60
+ /**
61
+ * 主动发送有度文本消息。
62
+ */
63
+ async function sendYouDuTextMessage(params) {
64
+ const { to, text, accountId } = params;
65
+ const chatId = resolveOutboundChatId(to, accountId);
66
+ const wsClient = getOutboundWsClient(accountId);
67
+ const ack = await wsClient.sendMessage(chatId, {
68
+ msgType: "text",
69
+ text: { content: text },
70
+ });
71
+ return {
72
+ channel: CHANNEL_ID,
73
+ messageId: ack.headers?.reqId ?? `youdu-${Date.now()}`,
74
+ chatId,
75
+ };
76
+ }
77
+ /**
78
+ * 主动发送有度媒体消息。
79
+ */
80
+ async function sendYouDuMediaMessage(params) {
81
+ const { cfg, to, mediaUrl, text, mediaLocalRoots, accountId } = params;
82
+ const chatId = resolveOutboundChatId(to, accountId);
83
+ const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
84
+ const wsClient = getOutboundWsClient(resolvedAccountId);
85
+ const account = resolveYouDuAccount(cfg);
86
+ const effectiveMediaLocalRoots = mediaLocalRoots && mediaLocalRoots.length > 0
87
+ ? [...mediaLocalRoots]
88
+ : await resolveYouDuMediaLocalRoots(account.config);
89
+ // 先走 CGI 上传拿到 mediaId,再通过 send_msg 发送正式媒体消息。
90
+ const mediaAttachment = await uploadOutboundMedia({
91
+ account,
92
+ mediaUrl,
93
+ mediaLocalRoots: effectiveMediaLocalRoots,
94
+ token: wsClient.getToken(),
95
+ });
96
+ if (text?.trim()) {
97
+ await wsClient.sendMessage(chatId, {
98
+ msgType: "text",
99
+ text: { content: text.trim() },
100
+ });
101
+ }
102
+ const ack = await wsClient.sendMessage(chatId, buildMediaMessageBody(mediaAttachment));
103
+ return {
104
+ channel: CHANNEL_ID,
105
+ messageId: ack.headers?.reqId ?? `youdu-${Date.now()}`,
106
+ chatId,
107
+ };
108
+ }
34
109
 
35
- export { sendYouDuMediaReply, sendYouDuReply };
110
+ export { sendYouDuMediaMessage, sendYouDuMediaReply, sendYouDuReply, sendYouDuTextMessage };
@@ -6,17 +6,17 @@ import { type Youdu } from './media.js';
6
6
  * 消息体类型(待用户完善)
7
7
  */
8
8
  export interface YouDuMessageBody {
9
- msgid: string;
10
- chattype: 'single' | 'group';
11
- chatid: string;
9
+ msgId: string;
10
+ chatType: 'single' | 'group';
11
+ chatId: string;
12
12
  from: {
13
- userid: string;
13
+ userId: string;
14
14
  account: string;
15
- name: string;
15
+ name?: string;
16
16
  };
17
- create_time?: number;
18
- response_url?: string;
19
- msgtype: string;
17
+ createTime?: number;
18
+ responseUrl?: string;
19
+ msgType: string;
20
20
  text?: {
21
21
  content: string;
22
22
  };
@@ -3,15 +3,12 @@ import { getYouDuRuntime } from './runtime.js';
3
3
  import { resolveYouDuAccount } from './utils.js';
4
4
  import { WS_MAX_RECONNECT_ATTEMPTS, WS_HEARTBEAT_INTERVAL_MS, PORT, THINKING_MESSAGE, MESSAGE_PROCESS_TIMEOUT_MS, CHANNEL_ID, MEDIA_IMAGE_PLACEHOLDER, MEDIA_DOCUMENT_PLACEHOLDER } from './const.js';
5
5
  import { buildYouDuTlsConnectOptions } from './tls.js';
6
- import { cleanupAccount, startMessageStateCleanup, setYouDuWebSocket, stopMessageStateCleanup, setMessageState, deleteMessageState } from './state-manager.js';
6
+ import { cleanupAccount, startMessageStateCleanup, setYouDuWebSocket, stopMessageStateCleanup, rememberDirectChatTarget, setMessageState, deleteMessageState } from './state-manager.js';
7
7
  import { checkDmPolicy } from './dm-policy.js';
8
8
  import { checkGroupPolicy } from './group-policy.js';
9
- import { extractInboundMedia, inferInboundMessageText, downloadInboundMedia, uploadAndSendMedia } from './media.js';
9
+ import { extractInboundMedia, inferInboundMessageText, downloadInboundMedia, uploadAndSendMedia, resolveYouDuMediaLocalRoots } from './media.js';
10
10
  import { sendYouDuReply } from './message-sender.js';
11
11
  import { generateReqId } from './sdk/ws-utils.js';
12
- import path__default from 'path';
13
- import os from 'os';
14
- import { getDefaultMediaLocalRoots, resolveStateDir } from './openclaw-compat.js';
15
12
 
16
13
  /*
17
14
  * Licensed under the MIT License.
@@ -19,8 +16,12 @@ import { getDefaultMediaLocalRoots, resolveStateDir } from './openclaw-compat.js
19
16
  function buildMessageContext(frame, account, config, text, mediaList) {
20
17
  const core = getYouDuRuntime();
21
18
  const body = frame.body;
22
- const chatId = body.chatid || body.from.account;
23
- const chatType = body.chattype === 'group' ? 'group' : 'direct';
19
+ const senderId = String(body.from.userId).trim();
20
+ const senderAccount = String(body.from.account).trim();
21
+ const senderName = String(body.from.name ?? "").trim();
22
+ const senderDisplay = senderName ? `${senderAccount} (${senderName})` : senderAccount;
23
+ const chatId = body.chatId || senderAccount;
24
+ const chatType = body.chatType === 'group' ? 'group' : 'direct';
24
25
  const route = core.channel.routing.resolveAgentRoute({
25
26
  cfg: config,
26
27
  channel: CHANNEL_ID,
@@ -30,7 +31,9 @@ function buildMessageContext(frame, account, config, text, mediaList) {
30
31
  id: chatId,
31
32
  },
32
33
  });
33
- const fromLabel = chatType === 'group' ? `group:${chatId}` : `user:${body.from.userid}`;
34
+ const fromLabel = chatType === 'group'
35
+ ? `group:${chatId}`
36
+ : `user:${senderDisplay}`;
34
37
  // 当只有媒体没有文本时,使用占位符标识媒体类型
35
38
  const hasImages = mediaList.some((m) => m.contentType?.startsWith("image/"));
36
39
  const messageBody = text || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
@@ -43,22 +46,23 @@ function buildMessageContext(frame, account, config, text, mediaList) {
43
46
  Body: messageBody,
44
47
  RawBody: messageBody,
45
48
  CommandBody: messageBody,
46
- MessageSid: body.msgid,
47
- From: chatType === 'group' ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${body.from.userid}`,
49
+ MessageSid: body.msgId,
50
+ From: chatType === 'group' ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${senderId}`,
48
51
  To: `${CHANNEL_ID}:${chatId}`,
49
- SenderId: body.from.userid,
52
+ SenderId: senderId,
53
+ SenderName: senderDisplay,
50
54
  SessionKey: route.sessionKey,
51
55
  AccountId: account.accountId,
52
56
  ChatType: chatType,
53
57
  ConversationLabel: fromLabel,
54
- Timestamp: Date.now(),
58
+ Timestamp: body.createTime || Date.now(),
55
59
  Provider: CHANNEL_ID,
56
60
  Surface: CHANNEL_ID,
57
61
  OriginatingChannel: CHANNEL_ID,
58
62
  OriginatingTo: `${CHANNEL_ID}:${chatId}`,
59
63
  YouDuFrame: frame,
60
64
  CommandAuthorized: true,
61
- ResponseUrl: body.response_url,
65
+ ResponseUrl: body.responseUrl,
62
66
  ReqId: frame.headers.reqId,
63
67
  MediaPath: mediaList[0]?.path,
64
68
  MediaType: mediaList[0]?.contentType,
@@ -76,11 +80,14 @@ function buildMessageContext(frame, account, config, text, mediaList) {
76
80
  async function processYouDuMessage(params) {
77
81
  const { frame, account, config, runtime, wsClient } = params;
78
82
  const body = frame.body;
79
- const chatId = body.chatid;
80
- const youduAccount = body.from.account;
81
- const chatType = body.chattype === 'group' ? 'group' : 'direct';
82
- const messageId = body.msgid;
83
+ const chatId = body.chatId;
84
+ const youduAccount = String(body.from.account).trim();
85
+ const chatType = body.chatType === 'group' ? 'group' : 'direct';
86
+ const messageId = body.msgId;
83
87
  const youduConfig = resolveYouDuAccount(config).config;
88
+ if (chatType === "direct" && chatId && youduAccount) {
89
+ rememberDirectChatTarget(account.accountId, youduAccount, chatId);
90
+ }
84
91
  let text = '';
85
92
  const inboundMedia = extractInboundMedia(body);
86
93
  text = inferInboundMessageText(body);
@@ -328,23 +335,7 @@ function createSdkLogger(runtime, accountId) {
328
335
  };
329
336
  }
330
337
  async function getExtendedMediaLocalRoots(config) {
331
- // 从兼容层获取默认白名单(内部已处理低版本 SDK 的 fallback)
332
- const defaults = await getDefaultMediaLocalRoots();
333
- const roots = [...defaults];
334
- const stateDir = path__default.resolve(resolveStateDir());
335
- if (!roots.includes(stateDir)) {
336
- roots.push(stateDir);
337
- }
338
- // 合并用户在 YouDu 中配置的自定义路径
339
- if (config?.mediaLocalRoots) {
340
- for (const r of config.mediaLocalRoots) {
341
- const resolved = path__default.resolve(r.replace(/^~(?=\/|$)/, os.homedir()));
342
- if (!roots.includes(resolved)) {
343
- roots.push(resolved);
344
- }
345
- }
346
- }
347
- return roots;
338
+ return resolveYouDuMediaLocalRoots(config);
348
339
  }
349
340
  /**
350
341
  * 监听 YouDu WebSocket 连接(主函数)
@@ -14,6 +14,7 @@ export declare class WSClient extends EventEmitter {
14
14
  connect(): this;
15
15
  disconnect(): void;
16
16
  reply(frame: WsFrame, body: any, cmd?: string): Promise<WsFrame>;
17
+ sendMessage(chatId: string, body: any): Promise<WsFrame>;
17
18
  getToken(): string;
18
19
  }
19
20
  export type { Logger, WsFrame, WSClientOptions, WSClientEventMap, } from "./types";
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { WsConnectionManager } from './ws-manager.js';
3
- import { DefaultLogger } from './ws-utils.js';
4
- export { WsCmd, WsErrCode } from './ws-utils.js';
3
+ import { DefaultLogger, generateReqId, WsCmd } from './ws-utils.js';
4
+ export { WsErrCode } from './ws-utils.js';
5
5
 
6
6
  /*
7
7
  * Licensed under the MIT License.
@@ -72,9 +72,13 @@ class WSClient extends EventEmitter {
72
72
  const reqId = frame.headers?.reqId || "";
73
73
  return this.wsManager.sendReply(reqId, body, cmd);
74
74
  }
75
+ sendMessage(chatId, body) {
76
+ const reqId = generateReqId(WsCmd.SEND_MSG);
77
+ return this.wsManager.sendReply(reqId, { chatId, ...body }, WsCmd.SEND_MSG);
78
+ }
75
79
  getToken() {
76
80
  return this.wsManager.getToken();
77
81
  }
78
82
  }
79
83
 
80
- export { DefaultLogger, WSClient };
84
+ export { DefaultLogger, WSClient, WsCmd };
@@ -106,11 +106,11 @@ class WsConnectionManager extends EventEmitter {
106
106
  cmd: WsCmd.CALLBACK,
107
107
  headers: { reqId: generateReqId("raw_text") },
108
108
  body: {
109
- msgid: `msg_${Date.now()}`,
110
- chatid: "default_chat",
111
- chattype: "single",
109
+ msgId: `msg_${Date.now()}`,
110
+ chatId: "default_chat",
111
+ chatType: "single",
112
112
  from: { userid: "default_user" },
113
- msgtype: "text",
113
+ msgType: "text",
114
114
  text: { content: raw },
115
115
  },
116
116
  };
@@ -22,6 +22,14 @@ export declare function setYouDuWebSocket(accountId: string, wsClient: WSClient)
22
22
  * 获取账户的 WebSocket 客户端
23
23
  */
24
24
  export declare function getYouDuWebSocket(accountId: string): WSClient | null;
25
+ /**
26
+ * 记录私聊账号对应的数值 chatId。
27
+ */
28
+ export declare function rememberDirectChatTarget(accountId: string, account: string, chatId: string): void;
29
+ /**
30
+ * 读取私聊账号对应的数值 chatId。
31
+ */
32
+ export declare function getDirectChatTarget(accountId: string, account: string): string | undefined;
25
33
  /**
26
34
  * 设置消息状态
27
35
  */
@@ -9,6 +9,8 @@ const wsClients = new Map();
9
9
  const messageStates = new Map();
10
10
  /** chatId 到 reqId 的映射 */
11
11
  const reqIdStore = new Map();
12
+ /** 私聊账号到数值 chatId 的运行时映射 */
13
+ const directChatStore = new Map();
12
14
  /** 清理定时器 */
13
15
  let cleanupTimer = null;
14
16
  /**
@@ -21,6 +23,33 @@ function setYouDuWebSocket(accountId, wsClient) {
21
23
  }
22
24
  wsClients.set(accountId, wsClient);
23
25
  }
26
+ /**
27
+ * 获取账户的 WebSocket 客户端
28
+ */
29
+ function getYouDuWebSocket(accountId) {
30
+ return wsClients.get(accountId) ?? null;
31
+ }
32
+ /**
33
+ * 记录私聊账号对应的数值 chatId。
34
+ */
35
+ function rememberDirectChatTarget(accountId, account, chatId) {
36
+ const normalizedAccount = String(account).trim().toLowerCase();
37
+ const normalizedChatId = String(chatId).trim();
38
+ if (!normalizedAccount || !normalizedChatId) {
39
+ return;
40
+ }
41
+ directChatStore.set(`${accountId}:${normalizedAccount}`, normalizedChatId);
42
+ }
43
+ /**
44
+ * 读取私聊账号对应的数值 chatId。
45
+ */
46
+ function getDirectChatTarget(accountId, account) {
47
+ const normalizedAccount = String(account).trim().toLowerCase();
48
+ if (!normalizedAccount) {
49
+ return undefined;
50
+ }
51
+ return directChatStore.get(`${accountId}:${normalizedAccount}`);
52
+ }
24
53
  /**
25
54
  * 设置消息状态
26
55
  */
@@ -78,6 +107,11 @@ async function cleanupAccount(accountId, expectedClient) {
78
107
  reqIdStore.delete(key);
79
108
  }
80
109
  }
110
+ for (const key of directChatStore.keys()) {
111
+ if (key.startsWith(`${accountId}:`)) {
112
+ directChatStore.delete(key);
113
+ }
114
+ }
81
115
  }
82
116
 
83
- export { cleanupAccount, deleteMessageState, setMessageState, setYouDuWebSocket, startMessageStateCleanup, stopMessageStateCleanup };
117
+ export { cleanupAccount, deleteMessageState, getDirectChatTarget, getYouDuWebSocket, rememberDirectChatTarget, setMessageState, setYouDuWebSocket, startMessageStateCleanup, stopMessageStateCleanup };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdu/openclaw-youdu",
3
- "version": "2026.5.6",
3
+ "version": "2026.5.8-2",
4
4
  "type": "module",
5
5
  "main": "dist/index.esm.js",
6
6
  "module": "dist/index.esm.js",
@@ -36,7 +36,7 @@
36
36
  "channel": {
37
37
  "id": "youdu",
38
38
  "label": "有度",
39
- "selectionLabel": "有度 (YouDu)",
39
+ "selectionLabel": "有度",
40
40
  "docsPath": "/channels/youdu",
41
41
  "docsLabel": "openclaw-youdu",
42
42
  "blurb": "有度机器人接入插件",