@wecom/wecom-openclaw-plugin 1.0.9 → 1.0.10-beta.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/dist/index.cjs.js +938 -67
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.esm.js +921 -69
- package/dist/index.esm.js.map +1 -1
- package/dist/src/const.d.ts +12 -0
- package/dist/src/interface.d.ts +10 -0
- package/dist/src/media-uploader.d.ts +131 -0
- package/dist/src/openclaw-compat.d.ts +48 -0
- package/dist/src/utils.d.ts +2 -0
- package/package.json +2 -2
package/dist/index.cjs.js
CHANGED
|
@@ -3,15 +3,40 @@
|
|
|
3
3
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
4
|
|
|
5
5
|
var pluginSdk = require('openclaw/plugin-sdk');
|
|
6
|
+
var os$1 = require('os');
|
|
7
|
+
var path$1 = require('path');
|
|
6
8
|
var aibotNodeSdk = require('@wecom/aibot-node-sdk');
|
|
7
|
-
var
|
|
9
|
+
var fs = require('node:fs/promises');
|
|
10
|
+
var path = require('node:path');
|
|
11
|
+
var os = require('node:os');
|
|
12
|
+
var node_url = require('node:url');
|
|
8
13
|
var url = require('url');
|
|
9
14
|
var fileType = require('file-type');
|
|
10
|
-
var os = require('node:os');
|
|
11
|
-
var path$1 = require('node:path');
|
|
12
|
-
var os$1 = require('os');
|
|
13
15
|
|
|
14
16
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
17
|
+
function _interopNamespaceDefault(e) {
|
|
18
|
+
var n = Object.create(null);
|
|
19
|
+
if (e) {
|
|
20
|
+
Object.keys(e).forEach(function (k) {
|
|
21
|
+
if (k !== 'default') {
|
|
22
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
23
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
get: function () { return e[k]; }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
n.default = e;
|
|
31
|
+
return Object.freeze(n);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var os__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(os$1);
|
|
35
|
+
var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
|
|
36
|
+
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
|
|
37
|
+
var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path);
|
|
38
|
+
var os__namespace = /*#__PURE__*/_interopNamespaceDefault(os);
|
|
39
|
+
|
|
15
40
|
let runtime = null;
|
|
16
41
|
function setWeComRuntime(r) {
|
|
17
42
|
runtime = r;
|
|
@@ -23,6 +48,310 @@ function getWeComRuntime() {
|
|
|
23
48
|
return runtime;
|
|
24
49
|
}
|
|
25
50
|
|
|
51
|
+
/**
|
|
52
|
+
* openclaw plugin-sdk 高版本方法兼容层
|
|
53
|
+
*
|
|
54
|
+
* 部分方法(如 loadOutboundMediaFromUrl、detectMime、getDefaultMediaLocalRoots)
|
|
55
|
+
* 仅在较新版本的 openclaw plugin-sdk 中才导出。
|
|
56
|
+
*
|
|
57
|
+
* 本模块在加载时一次性探测 SDK 导出,存在则直接 re-export SDK 版本,
|
|
58
|
+
* 不存在则导出 fallback 实现。其他模块统一从本文件导入,无需关心底层兼容细节。
|
|
59
|
+
*/
|
|
60
|
+
const _sdkReady = import('openclaw/plugin-sdk')
|
|
61
|
+
.then((sdk) => {
|
|
62
|
+
const exports$1 = {};
|
|
63
|
+
if (typeof sdk.loadOutboundMediaFromUrl === "function") {
|
|
64
|
+
exports$1.loadOutboundMediaFromUrl = sdk.loadOutboundMediaFromUrl;
|
|
65
|
+
}
|
|
66
|
+
if (typeof sdk.detectMime === "function") {
|
|
67
|
+
exports$1.detectMime = sdk.detectMime;
|
|
68
|
+
}
|
|
69
|
+
if (typeof sdk.getDefaultMediaLocalRoots === "function") {
|
|
70
|
+
exports$1.getDefaultMediaLocalRoots = sdk.getDefaultMediaLocalRoots;
|
|
71
|
+
}
|
|
72
|
+
return exports$1;
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {
|
|
75
|
+
// openclaw/plugin-sdk 不可用或版本过低,全部使用 fallback
|
|
76
|
+
return {};
|
|
77
|
+
});
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// detectMime —— 检测 MIME 类型
|
|
80
|
+
// ============================================================================
|
|
81
|
+
const MIME_BY_EXT = {
|
|
82
|
+
".heic": "image/heic",
|
|
83
|
+
".heif": "image/heif",
|
|
84
|
+
".jpg": "image/jpeg",
|
|
85
|
+
".jpeg": "image/jpeg",
|
|
86
|
+
".png": "image/png",
|
|
87
|
+
".webp": "image/webp",
|
|
88
|
+
".gif": "image/gif",
|
|
89
|
+
".ogg": "audio/ogg",
|
|
90
|
+
".mp3": "audio/mpeg",
|
|
91
|
+
".m4a": "audio/x-m4a",
|
|
92
|
+
".mp4": "video/mp4",
|
|
93
|
+
".mov": "video/quicktime",
|
|
94
|
+
".pdf": "application/pdf",
|
|
95
|
+
".json": "application/json",
|
|
96
|
+
".zip": "application/zip",
|
|
97
|
+
".gz": "application/gzip",
|
|
98
|
+
".tar": "application/x-tar",
|
|
99
|
+
".7z": "application/x-7z-compressed",
|
|
100
|
+
".rar": "application/vnd.rar",
|
|
101
|
+
".doc": "application/msword",
|
|
102
|
+
".xls": "application/vnd.ms-excel",
|
|
103
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
104
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
105
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
106
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
107
|
+
".csv": "text/csv",
|
|
108
|
+
".txt": "text/plain",
|
|
109
|
+
".md": "text/markdown",
|
|
110
|
+
".amr": "audio/amr",
|
|
111
|
+
".aac": "audio/aac",
|
|
112
|
+
".wav": "audio/wav",
|
|
113
|
+
".webm": "video/webm",
|
|
114
|
+
".avi": "video/x-msvideo",
|
|
115
|
+
".bmp": "image/bmp",
|
|
116
|
+
".svg": "image/svg+xml",
|
|
117
|
+
};
|
|
118
|
+
/** 通过 buffer 魔术字节嗅探 MIME 类型(动态导入 file-type,不强依赖) */
|
|
119
|
+
async function sniffMimeFromBuffer(buffer) {
|
|
120
|
+
try {
|
|
121
|
+
const { fileTypeFromBuffer } = await import('file-type');
|
|
122
|
+
const type = await fileTypeFromBuffer(buffer);
|
|
123
|
+
return type?.mime ?? undefined;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** fallback 版 detectMime,参考 weclaw/src/media/mime.ts */
|
|
130
|
+
async function detectMimeFallback(opts) {
|
|
131
|
+
const ext = opts.filePath ? path__namespace.extname(opts.filePath).toLowerCase() : undefined;
|
|
132
|
+
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
|
|
133
|
+
const sniffed = opts.buffer ? await sniffMimeFromBuffer(opts.buffer) : undefined;
|
|
134
|
+
const isGeneric = (m) => !m || m === "application/octet-stream" || m === "application/zip";
|
|
135
|
+
if (sniffed && (!isGeneric(sniffed) || !extMime)) {
|
|
136
|
+
return sniffed;
|
|
137
|
+
}
|
|
138
|
+
if (extMime) {
|
|
139
|
+
return extMime;
|
|
140
|
+
}
|
|
141
|
+
const headerMime = opts.headerMime?.split(";")?.[0]?.trim().toLowerCase();
|
|
142
|
+
if (headerMime && !isGeneric(headerMime)) {
|
|
143
|
+
return headerMime;
|
|
144
|
+
}
|
|
145
|
+
if (sniffed) {
|
|
146
|
+
return sniffed;
|
|
147
|
+
}
|
|
148
|
+
if (headerMime) {
|
|
149
|
+
return headerMime;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 检测 MIME 类型(兼容入口)
|
|
155
|
+
*
|
|
156
|
+
* 支持两种调用签名以兼容不同使用场景:
|
|
157
|
+
* - detectMime(buffer) → 旧式调用
|
|
158
|
+
* - detectMime({ buffer, headerMime, filePath }) → 完整参数
|
|
159
|
+
*
|
|
160
|
+
* 优先使用 SDK 版本,不可用时使用 fallback。
|
|
161
|
+
*/
|
|
162
|
+
async function detectMime(bufferOrOpts) {
|
|
163
|
+
const sdk = await _sdkReady;
|
|
164
|
+
const opts = Buffer.isBuffer(bufferOrOpts)
|
|
165
|
+
? { buffer: bufferOrOpts }
|
|
166
|
+
: bufferOrOpts;
|
|
167
|
+
if (sdk.detectMime) {
|
|
168
|
+
try {
|
|
169
|
+
return await sdk.detectMime(opts);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// SDK detectMime 异常,降级到 fallback
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return detectMimeFallback(opts);
|
|
176
|
+
}
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// loadOutboundMediaFromUrl —— 从 URL/路径加载媒体文件
|
|
179
|
+
// ============================================================================
|
|
180
|
+
/** 安全的本地文件路径校验,参考 weclaw/src/web/media.ts */
|
|
181
|
+
async function assertLocalMediaAllowed(mediaPath, localRoots) {
|
|
182
|
+
if (!localRoots || localRoots.length === 0) {
|
|
183
|
+
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
|
|
184
|
+
}
|
|
185
|
+
let resolved;
|
|
186
|
+
try {
|
|
187
|
+
resolved = await fs__namespace.realpath(mediaPath);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
resolved = path__namespace.resolve(mediaPath);
|
|
191
|
+
}
|
|
192
|
+
for (const root of localRoots) {
|
|
193
|
+
let resolvedRoot;
|
|
194
|
+
try {
|
|
195
|
+
resolvedRoot = await fs__namespace.realpath(root);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
resolvedRoot = path__namespace.resolve(root);
|
|
199
|
+
}
|
|
200
|
+
if (resolvedRoot === path__namespace.parse(resolvedRoot).root) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path__namespace.sep)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
|
|
208
|
+
}
|
|
209
|
+
/** 从远程 URL 获取媒体 */
|
|
210
|
+
async function fetchRemoteMedia(url, maxBytes) {
|
|
211
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
212
|
+
if (!res.ok) {
|
|
213
|
+
throw new Error(`Failed to fetch media from ${url}: HTTP ${res.status} ${res.statusText}`);
|
|
214
|
+
}
|
|
215
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
216
|
+
if (maxBytes && buffer.length > maxBytes) {
|
|
217
|
+
throw new Error(`Media from ${url} exceeds max size (${buffer.length} > ${maxBytes})`);
|
|
218
|
+
}
|
|
219
|
+
const headerMime = res.headers.get("content-type")?.split(";")?.[0]?.trim();
|
|
220
|
+
let fileName;
|
|
221
|
+
const disposition = res.headers.get("content-disposition");
|
|
222
|
+
if (disposition) {
|
|
223
|
+
const match = /filename\*?\s*=\s*(?:UTF-8''|")?([^";]+)/i.exec(disposition);
|
|
224
|
+
if (match?.[1]) {
|
|
225
|
+
try {
|
|
226
|
+
fileName = path__namespace.basename(decodeURIComponent(match[1].replace(/["']/g, "").trim()));
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
fileName = path__namespace.basename(match[1].replace(/["']/g, "").trim());
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!fileName) {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = new URL(url);
|
|
236
|
+
const base = path__namespace.basename(parsed.pathname);
|
|
237
|
+
if (base && base.includes("."))
|
|
238
|
+
fileName = base;
|
|
239
|
+
}
|
|
240
|
+
catch { /* ignore */ }
|
|
241
|
+
}
|
|
242
|
+
const contentType = await detectMimeFallback({ buffer, headerMime, filePath: fileName ?? url });
|
|
243
|
+
return { buffer, contentType, fileName };
|
|
244
|
+
}
|
|
245
|
+
/** 展开 ~ 为用户主目录 */
|
|
246
|
+
function resolveUserPath(p) {
|
|
247
|
+
if (p.startsWith("~")) {
|
|
248
|
+
return path__namespace.join(os__namespace.homedir(), p.slice(1));
|
|
249
|
+
}
|
|
250
|
+
return p;
|
|
251
|
+
}
|
|
252
|
+
/** fallback 版 loadOutboundMediaFromUrl,参考 weclaw/src/web/media.ts */
|
|
253
|
+
async function loadOutboundMediaFromUrlFallback(mediaUrl, options = {}) {
|
|
254
|
+
const { maxBytes, mediaLocalRoots } = options;
|
|
255
|
+
// 去除 MEDIA: 前缀
|
|
256
|
+
mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, "");
|
|
257
|
+
// 处理 file:// URL
|
|
258
|
+
if (mediaUrl.startsWith("file://")) {
|
|
259
|
+
try {
|
|
260
|
+
mediaUrl = node_url.fileURLToPath(mediaUrl);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
throw new Error(`Invalid file:// URL: ${mediaUrl}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// 远程 URL
|
|
267
|
+
if (/^https?:\/\//i.test(mediaUrl)) {
|
|
268
|
+
const fetched = await fetchRemoteMedia(mediaUrl, maxBytes);
|
|
269
|
+
return {
|
|
270
|
+
buffer: fetched.buffer,
|
|
271
|
+
contentType: fetched.contentType,
|
|
272
|
+
fileName: fetched.fileName,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// 展开 ~ 路径
|
|
276
|
+
if (mediaUrl.startsWith("~")) {
|
|
277
|
+
mediaUrl = resolveUserPath(mediaUrl);
|
|
278
|
+
}
|
|
279
|
+
// 本地文件:安全校验
|
|
280
|
+
await assertLocalMediaAllowed(mediaUrl, mediaLocalRoots);
|
|
281
|
+
// 读取本地文件
|
|
282
|
+
let data;
|
|
283
|
+
try {
|
|
284
|
+
const stat = await fs__namespace.stat(mediaUrl);
|
|
285
|
+
if (!stat.isFile()) {
|
|
286
|
+
throw new Error(`Local media path is not a file: ${mediaUrl}`);
|
|
287
|
+
}
|
|
288
|
+
data = await fs__namespace.readFile(mediaUrl);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (err?.code === "ENOENT") {
|
|
292
|
+
throw new Error(`Local media file not found: ${mediaUrl}`);
|
|
293
|
+
}
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
296
|
+
if (maxBytes && data.length > maxBytes) {
|
|
297
|
+
throw new Error(`Local media exceeds max size (${data.length} > ${maxBytes})`);
|
|
298
|
+
}
|
|
299
|
+
const mime = await detectMimeFallback({ buffer: data, filePath: mediaUrl });
|
|
300
|
+
const fileName = path__namespace.basename(mediaUrl) || undefined;
|
|
301
|
+
return {
|
|
302
|
+
buffer: data,
|
|
303
|
+
contentType: mime,
|
|
304
|
+
fileName,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* 从 URL 或本地路径加载媒体文件(兼容入口)
|
|
309
|
+
*
|
|
310
|
+
* 优先使用 SDK 版本,不可用时使用 fallback。
|
|
311
|
+
* SDK 版本抛出的业务异常(如 LocalMediaAccessError)会直接透传。
|
|
312
|
+
*/
|
|
313
|
+
async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
|
|
314
|
+
const sdk = await _sdkReady;
|
|
315
|
+
if (sdk.loadOutboundMediaFromUrl) {
|
|
316
|
+
return sdk.loadOutboundMediaFromUrl(mediaUrl, options);
|
|
317
|
+
}
|
|
318
|
+
return loadOutboundMediaFromUrlFallback(mediaUrl, options);
|
|
319
|
+
}
|
|
320
|
+
// ============================================================================
|
|
321
|
+
// getDefaultMediaLocalRoots —— 获取默认媒体本地路径白名单
|
|
322
|
+
// ============================================================================
|
|
323
|
+
/** 解析 openclaw 状态目录 */
|
|
324
|
+
function resolveStateDir$1() {
|
|
325
|
+
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
326
|
+
if (stateOverride)
|
|
327
|
+
return stateOverride;
|
|
328
|
+
return path__namespace.join(os__namespace.homedir(), ".openclaw");
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 获取默认媒体本地路径白名单(兼容入口)
|
|
332
|
+
*
|
|
333
|
+
* 优先使用 SDK 版本,不可用时手动构建白名单(与 weclaw/src/media/local-roots.ts 逻辑一致)。
|
|
334
|
+
*/
|
|
335
|
+
async function getDefaultMediaLocalRoots() {
|
|
336
|
+
const sdk = await _sdkReady;
|
|
337
|
+
if (sdk.getDefaultMediaLocalRoots) {
|
|
338
|
+
try {
|
|
339
|
+
return sdk.getDefaultMediaLocalRoots();
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// SDK 版本异常,降级到 fallback
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// fallback: 手动构建默认白名单
|
|
346
|
+
const stateDir = path__namespace.resolve(resolveStateDir$1());
|
|
347
|
+
return [
|
|
348
|
+
path__namespace.join(stateDir, "media"),
|
|
349
|
+
path__namespace.join(stateDir, "agents"),
|
|
350
|
+
path__namespace.join(stateDir, "workspace"),
|
|
351
|
+
path__namespace.join(stateDir, "sandboxes"),
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
|
|
26
355
|
/**
|
|
27
356
|
* 企业微信渠道常量定义
|
|
28
357
|
*/
|
|
@@ -88,9 +417,9 @@ const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
|
|
|
88
417
|
/** MCP 配置拉取超时时间(毫秒) */
|
|
89
418
|
const MCP_CONFIG_FETCH_TIMEOUT_MS = 15000;
|
|
90
419
|
const __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs.js', document.baseURI).href)));
|
|
91
|
-
const __dirname$1 = path.dirname(__filename$1);
|
|
420
|
+
const __dirname$1 = path$1.dirname(__filename$1);
|
|
92
421
|
const PLUGIN_JSON_FILENAME = "openclaw.plugin.json";
|
|
93
|
-
path.resolve(path.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
|
|
422
|
+
path$1.resolve(path$1.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
|
|
94
423
|
// ============================================================================
|
|
95
424
|
// 默认值
|
|
96
425
|
// ============================================================================
|
|
@@ -98,6 +427,19 @@ path.resolve(path.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
|
|
|
98
427
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
99
428
|
/** 文本分块大小上限 */
|
|
100
429
|
const TEXT_CHUNK_LIMIT = 4000;
|
|
430
|
+
// ============================================================================
|
|
431
|
+
// 媒体上传相关常量
|
|
432
|
+
// ============================================================================
|
|
433
|
+
/** 图片大小上限(字节):10MB */
|
|
434
|
+
const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
435
|
+
/** 视频大小上限(字节):10MB */
|
|
436
|
+
const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
437
|
+
/** 语音大小上限(字节):2MB */
|
|
438
|
+
const VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
|
439
|
+
/** 文件大小上限(字节):20MB */
|
|
440
|
+
const FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
441
|
+
/** 文件绝对上限(字节):超过此值无法发送,等于 FILE_MAX_BYTES */
|
|
442
|
+
const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
|
|
101
443
|
|
|
102
444
|
/**
|
|
103
445
|
* 企业微信消息内容解析模块
|
|
@@ -239,12 +581,12 @@ async function sendWeComReply(params) {
|
|
|
239
581
|
}
|
|
240
582
|
const streamId = existingStreamId || aibotNodeSdk.generateReqId("stream");
|
|
241
583
|
if (!wsClient.isConnected) {
|
|
242
|
-
runtime.error?.(`[
|
|
584
|
+
runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
|
|
243
585
|
throw new Error("WSClient not connected");
|
|
244
586
|
}
|
|
245
587
|
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
|
|
246
588
|
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
|
|
247
|
-
runtime.log?.(`[
|
|
589
|
+
runtime.log?.(`[plugin -> server] streamId=${streamId}, finish=${finish}, text=${text}`);
|
|
248
590
|
return streamId;
|
|
249
591
|
}
|
|
250
592
|
|
|
@@ -285,7 +627,7 @@ async function downloadAndSaveImages(params) {
|
|
|
285
627
|
const mediaList = [];
|
|
286
628
|
for (const imageUrl of imageUrls) {
|
|
287
629
|
try {
|
|
288
|
-
runtime.log?.(`[
|
|
630
|
+
runtime.log?.(`[wecom] Downloading image: url=${imageUrl}`);
|
|
289
631
|
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
290
632
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
291
633
|
let imageBuffer;
|
|
@@ -298,26 +640,26 @@ async function downloadAndSaveImages(params) {
|
|
|
298
640
|
imageBuffer = result.buffer;
|
|
299
641
|
originalFilename = result.filename;
|
|
300
642
|
imageContentType = await detectImageContentType(imageBuffer);
|
|
301
|
-
runtime.log?.(`[
|
|
643
|
+
runtime.log?.(`[wecom] Image downloaded: size=${imageBuffer.length}, contentType=${imageContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
302
644
|
}
|
|
303
645
|
catch (sdkError) {
|
|
304
646
|
// 如果 SDK 方法失败,回退到原有方式(带超时保护)
|
|
305
|
-
runtime.log?.(`[
|
|
647
|
+
runtime.log?.(`[wecom] SDK download failed, fallback: ${String(sdkError)}`);
|
|
306
648
|
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
|
|
307
|
-
runtime.log?.(`[
|
|
649
|
+
runtime.log?.(`[wecom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
308
650
|
imageBuffer = fetched.buffer;
|
|
309
651
|
imageContentType = fetched.contentType ?? "application/octet-stream";
|
|
310
652
|
const isValidImage = await isImageBuffer(fetched.buffer);
|
|
311
653
|
if (!isValidImage) {
|
|
312
|
-
runtime.log?.(`[
|
|
654
|
+
runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
|
|
313
655
|
}
|
|
314
656
|
}
|
|
315
657
|
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
|
|
316
658
|
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
317
|
-
runtime.log?.(`[
|
|
659
|
+
runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
318
660
|
}
|
|
319
661
|
catch (err) {
|
|
320
|
-
runtime.error?.(`[
|
|
662
|
+
runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
|
|
321
663
|
}
|
|
322
664
|
}
|
|
323
665
|
return mediaList;
|
|
@@ -331,7 +673,7 @@ async function downloadAndSaveFiles(params) {
|
|
|
331
673
|
const mediaList = [];
|
|
332
674
|
for (const fileUrl of fileUrls) {
|
|
333
675
|
try {
|
|
334
|
-
runtime.log?.(`[
|
|
676
|
+
runtime.log?.(`[wecom] Downloading file: url=${fileUrl}`);
|
|
335
677
|
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
336
678
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
337
679
|
let fileBuffer;
|
|
@@ -346,27 +688,301 @@ async function downloadAndSaveFiles(params) {
|
|
|
346
688
|
// 检测文件类型
|
|
347
689
|
const type = await fileType.fileTypeFromBuffer(fileBuffer);
|
|
348
690
|
fileContentType = type?.mime ?? "application/octet-stream";
|
|
349
|
-
runtime.log?.(`[
|
|
691
|
+
runtime.log?.(`[wecom] File downloaded: size=${fileBuffer.length}, contentType=${fileContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
350
692
|
}
|
|
351
693
|
catch (sdkError) {
|
|
352
694
|
// 如果 SDK 方法失败,回退到 fetchRemoteMedia(带超时保护)
|
|
353
|
-
runtime.log?.(`[
|
|
695
|
+
runtime.log?.(`[wecom] SDK file download failed, fallback: ${String(sdkError)}`);
|
|
354
696
|
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
|
|
355
|
-
runtime.log?.(`[
|
|
697
|
+
runtime.log?.(`[wecom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
356
698
|
fileBuffer = fetched.buffer;
|
|
357
699
|
fileContentType = fetched.contentType ?? "application/octet-stream";
|
|
358
700
|
}
|
|
359
701
|
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
|
|
360
702
|
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
361
|
-
runtime.log?.(`[
|
|
703
|
+
runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
362
704
|
}
|
|
363
705
|
catch (err) {
|
|
364
|
-
runtime.error?.(`[
|
|
706
|
+
runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
|
|
365
707
|
}
|
|
366
708
|
}
|
|
367
709
|
return mediaList;
|
|
368
710
|
}
|
|
369
711
|
|
|
712
|
+
/**
|
|
713
|
+
* 企业微信出站媒体上传工具模块
|
|
714
|
+
*
|
|
715
|
+
* 负责:
|
|
716
|
+
* - 从 mediaUrl 加载文件 buffer(远程 URL 或本地路径均支持)
|
|
717
|
+
* - 检测 MIME 类型并映射为企微媒体类型
|
|
718
|
+
* - 文件大小检查与降级策略
|
|
719
|
+
*/
|
|
720
|
+
// ============================================================================
|
|
721
|
+
// MIME → 企微媒体类型映射
|
|
722
|
+
// ============================================================================
|
|
723
|
+
/**
|
|
724
|
+
* 根据 MIME 类型检测企微媒体类型
|
|
725
|
+
*
|
|
726
|
+
* @param mimeType - MIME 类型字符串
|
|
727
|
+
* @returns 企微媒体类型
|
|
728
|
+
*/
|
|
729
|
+
function detectWeComMediaType(mimeType) {
|
|
730
|
+
const mime = mimeType.toLowerCase();
|
|
731
|
+
// 图片类型
|
|
732
|
+
if (mime.startsWith("image/")) {
|
|
733
|
+
return "image";
|
|
734
|
+
}
|
|
735
|
+
// 视频类型
|
|
736
|
+
if (mime.startsWith("video/")) {
|
|
737
|
+
return "video";
|
|
738
|
+
}
|
|
739
|
+
// 语音类型
|
|
740
|
+
if (mime.startsWith("audio/") ||
|
|
741
|
+
mime === "application/ogg" // OGG 音频容器
|
|
742
|
+
) {
|
|
743
|
+
return "voice";
|
|
744
|
+
}
|
|
745
|
+
// 其他类型默认为文件
|
|
746
|
+
return "file";
|
|
747
|
+
}
|
|
748
|
+
// ============================================================================
|
|
749
|
+
// 媒体文件加载
|
|
750
|
+
// ============================================================================
|
|
751
|
+
/**
|
|
752
|
+
* 从 mediaUrl 加载媒体文件
|
|
753
|
+
*
|
|
754
|
+
* 支持远程 URL(http/https)和本地路径(file:// 或绝对路径),
|
|
755
|
+
* 利用 openclaw plugin-sdk 的 loadOutboundMediaFromUrl 统一处理。
|
|
756
|
+
*
|
|
757
|
+
* @param mediaUrl - 媒体文件的 URL 或本地路径
|
|
758
|
+
* @param mediaLocalRoots - 允许读取本地文件的安全白名单目录
|
|
759
|
+
* @returns 解析后的媒体文件信息
|
|
760
|
+
*/
|
|
761
|
+
async function resolveMediaFile(mediaUrl, mediaLocalRoots) {
|
|
762
|
+
// 使用兼容层加载媒体文件(优先 SDK,不可用时 fallback)
|
|
763
|
+
// 传入足够大的 maxBytes,由我们自己在后续步骤做大小检查
|
|
764
|
+
const result = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
765
|
+
maxBytes: ABSOLUTE_MAX_BYTES,
|
|
766
|
+
mediaLocalRoots,
|
|
767
|
+
});
|
|
768
|
+
if (!result.buffer || result.buffer.length === 0) {
|
|
769
|
+
throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
|
|
770
|
+
}
|
|
771
|
+
// 检测真实 MIME 类型
|
|
772
|
+
let contentType = result.contentType || "application/octet-stream";
|
|
773
|
+
// 如果没有返回准确的 contentType,尝试通过 buffer 魔术字节检测
|
|
774
|
+
if (contentType === "application/octet-stream" ||
|
|
775
|
+
contentType === "text/plain") {
|
|
776
|
+
const detected = await detectMime(result.buffer);
|
|
777
|
+
if (detected) {
|
|
778
|
+
contentType = detected;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// 提取文件名
|
|
782
|
+
const fileName = extractFileName(mediaUrl, result.fileName, contentType);
|
|
783
|
+
return {
|
|
784
|
+
buffer: result.buffer,
|
|
785
|
+
contentType,
|
|
786
|
+
fileName,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
// ============================================================================
|
|
790
|
+
// 文件大小检查与降级
|
|
791
|
+
// ============================================================================
|
|
792
|
+
/** 企微语音消息仅支持 AMR 格式 */
|
|
793
|
+
const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
|
|
794
|
+
/**
|
|
795
|
+
* 检查文件大小并执行降级策略
|
|
796
|
+
*
|
|
797
|
+
* 降级规则:
|
|
798
|
+
* - voice 非 AMR 格式 → 降级为 file(企微后台仅支持 AMR)
|
|
799
|
+
* - image 超过 10MB → 降级为 file
|
|
800
|
+
* - video 超过 10MB → 降级为 file
|
|
801
|
+
* - voice 超过 2MB → 降级为 file
|
|
802
|
+
* - file 超过 20MB → 拒绝发送
|
|
803
|
+
*
|
|
804
|
+
* @param fileSize - 文件大小(字节)
|
|
805
|
+
* @param detectedType - 检测到的企微媒体类型
|
|
806
|
+
* @param contentType - 文件的 MIME 类型(用于语音格式校验)
|
|
807
|
+
* @returns 大小检查结果
|
|
808
|
+
*/
|
|
809
|
+
function applyFileSizeLimits(fileSize, detectedType, contentType) {
|
|
810
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
811
|
+
// 先检查绝对上限(20MB)
|
|
812
|
+
if (fileSize > ABSOLUTE_MAX_BYTES) {
|
|
813
|
+
return {
|
|
814
|
+
finalType: detectedType,
|
|
815
|
+
shouldReject: true,
|
|
816
|
+
rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
|
|
817
|
+
downgraded: false,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
// 按类型检查大小限制
|
|
821
|
+
switch (detectedType) {
|
|
822
|
+
case "image":
|
|
823
|
+
if (fileSize > IMAGE_MAX_BYTES) {
|
|
824
|
+
return {
|
|
825
|
+
finalType: "file",
|
|
826
|
+
shouldReject: false,
|
|
827
|
+
downgraded: true,
|
|
828
|
+
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
case "video":
|
|
833
|
+
if (fileSize > VIDEO_MAX_BYTES) {
|
|
834
|
+
return {
|
|
835
|
+
finalType: "file",
|
|
836
|
+
shouldReject: false,
|
|
837
|
+
downgraded: true,
|
|
838
|
+
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
break;
|
|
842
|
+
case "voice":
|
|
843
|
+
// 企微语音消息仅支持 AMR 格式,非 AMR 一律降级为文件
|
|
844
|
+
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
|
|
845
|
+
return {
|
|
846
|
+
finalType: "file",
|
|
847
|
+
shouldReject: false,
|
|
848
|
+
downgraded: true,
|
|
849
|
+
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
if (fileSize > VOICE_MAX_BYTES) {
|
|
853
|
+
return {
|
|
854
|
+
finalType: "file",
|
|
855
|
+
shouldReject: false,
|
|
856
|
+
downgraded: true,
|
|
857
|
+
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
// 无需降级
|
|
863
|
+
return {
|
|
864
|
+
finalType: detectedType,
|
|
865
|
+
shouldReject: false,
|
|
866
|
+
downgraded: false,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
// ============================================================================
|
|
870
|
+
// 辅助函数
|
|
871
|
+
// ============================================================================
|
|
872
|
+
/**
|
|
873
|
+
* 从 URL/路径中提取文件名
|
|
874
|
+
*/
|
|
875
|
+
function extractFileName(mediaUrl, providedFileName, contentType) {
|
|
876
|
+
// 优先使用提供的文件名
|
|
877
|
+
if (providedFileName) {
|
|
878
|
+
return providedFileName;
|
|
879
|
+
}
|
|
880
|
+
// 尝试从 URL 中提取
|
|
881
|
+
try {
|
|
882
|
+
const urlObj = new URL(mediaUrl, "file://");
|
|
883
|
+
const pathParts = urlObj.pathname.split("/");
|
|
884
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
885
|
+
if (lastPart && lastPart.includes(".")) {
|
|
886
|
+
return decodeURIComponent(lastPart);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
// 尝试作为普通路径处理
|
|
891
|
+
const parts = mediaUrl.split("/");
|
|
892
|
+
const lastPart = parts[parts.length - 1];
|
|
893
|
+
if (lastPart && lastPart.includes(".")) {
|
|
894
|
+
return lastPart;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// 使用 MIME 类型生成默认文件名
|
|
898
|
+
const ext = mimeToExtension(contentType || "application/octet-stream");
|
|
899
|
+
return `media_${Date.now()}${ext}`;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* MIME 类型转文件扩展名
|
|
903
|
+
*/
|
|
904
|
+
function mimeToExtension(mime) {
|
|
905
|
+
const map = {
|
|
906
|
+
"image/jpeg": ".jpg",
|
|
907
|
+
"image/png": ".png",
|
|
908
|
+
"image/gif": ".gif",
|
|
909
|
+
"image/webp": ".webp",
|
|
910
|
+
"image/bmp": ".bmp",
|
|
911
|
+
"image/svg+xml": ".svg",
|
|
912
|
+
"video/mp4": ".mp4",
|
|
913
|
+
"video/quicktime": ".mov",
|
|
914
|
+
"video/x-msvideo": ".avi",
|
|
915
|
+
"video/webm": ".webm",
|
|
916
|
+
"audio/mpeg": ".mp3",
|
|
917
|
+
"audio/ogg": ".ogg",
|
|
918
|
+
"audio/wav": ".wav",
|
|
919
|
+
"audio/amr": ".amr",
|
|
920
|
+
"audio/aac": ".aac",
|
|
921
|
+
"application/pdf": ".pdf",
|
|
922
|
+
"application/zip": ".zip",
|
|
923
|
+
"application/msword": ".doc",
|
|
924
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
925
|
+
"application/vnd.ms-excel": ".xls",
|
|
926
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
927
|
+
"text/plain": ".txt",
|
|
928
|
+
};
|
|
929
|
+
return map[mime] || ".bin";
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* 公共媒体上传+发送流程
|
|
933
|
+
*
|
|
934
|
+
* 统一处理:resolveMediaFile → detectType → sizeCheck → uploadMedia → sendMediaMessage
|
|
935
|
+
* 媒体消息统一走 aibot_send_msg 主动发送,避免多文件场景下 reqId 只能用一次的问题。
|
|
936
|
+
* channel.ts 的 sendMedia 和 monitor.ts 的 deliver 回调都使用此函数。
|
|
937
|
+
*/
|
|
938
|
+
async function uploadAndSendMedia(options) {
|
|
939
|
+
const { wsClient, mediaUrl, chatId, mediaLocalRoots, log, errorLog } = options;
|
|
940
|
+
try {
|
|
941
|
+
// 1. 加载媒体文件
|
|
942
|
+
log?.(`[wecom] Uploading media: url=${mediaUrl}`);
|
|
943
|
+
const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
|
|
944
|
+
// 2. 检测企微媒体类型
|
|
945
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
946
|
+
// 3. 文件大小检查与降级策略
|
|
947
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
948
|
+
if (sizeCheck.shouldReject) {
|
|
949
|
+
errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
|
|
950
|
+
return {
|
|
951
|
+
ok: false,
|
|
952
|
+
rejected: true,
|
|
953
|
+
rejectReason: sizeCheck.rejectReason,
|
|
954
|
+
finalType: sizeCheck.finalType,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
const finalType = sizeCheck.finalType;
|
|
958
|
+
// 4. 分片上传获取 media_id
|
|
959
|
+
const uploadResult = await wsClient.uploadMedia(media.buffer, {
|
|
960
|
+
type: finalType,
|
|
961
|
+
filename: media.fileName,
|
|
962
|
+
});
|
|
963
|
+
log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
|
|
964
|
+
// 5. 统一通过 aibot_send_msg 主动发送媒体消息
|
|
965
|
+
const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
|
|
966
|
+
const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
|
|
967
|
+
log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
|
|
968
|
+
return {
|
|
969
|
+
ok: true,
|
|
970
|
+
messageId,
|
|
971
|
+
finalType,
|
|
972
|
+
downgraded: sizeCheck.downgraded,
|
|
973
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
const errMsg = String(err);
|
|
978
|
+
errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
|
|
979
|
+
return {
|
|
980
|
+
ok: false,
|
|
981
|
+
error: errMsg,
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
370
986
|
/**
|
|
371
987
|
* 企业微信群组访问控制模块
|
|
372
988
|
*
|
|
@@ -599,13 +1215,13 @@ function resolveStateDirFromEnv(env = process.env) {
|
|
|
599
1215
|
return stateOverride;
|
|
600
1216
|
}
|
|
601
1217
|
if (env.VITEST || env.NODE_ENV === "test") {
|
|
602
|
-
return path
|
|
1218
|
+
return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
|
|
603
1219
|
}
|
|
604
|
-
return path
|
|
1220
|
+
return path.join(os.homedir(), ".openclaw");
|
|
605
1221
|
}
|
|
606
1222
|
function resolveReqIdFilePath(accountId) {
|
|
607
1223
|
const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
608
|
-
return path
|
|
1224
|
+
return path.join(resolveStateDirFromEnv(), "wecom", `reqid-map-${safe}.json`);
|
|
609
1225
|
}
|
|
610
1226
|
// ============================================================================
|
|
611
1227
|
// 核心实现
|
|
@@ -1015,8 +1631,8 @@ async function saveMcpConfigToPluginJson(config, runtime) {
|
|
|
1015
1631
|
// const pluginJsonPath = PLUGIN_JSON_PATH;
|
|
1016
1632
|
// 获取绝对路径并写入到配置文件
|
|
1017
1633
|
// const absolutePath = path.resolve(pluginJsonPath);
|
|
1018
|
-
const wecomConfigDir = path.join(os$1.homedir(), ".openclaw", "wecomConfig");
|
|
1019
|
-
const wecomConfigPath = path.join(wecomConfigDir, "config.json");
|
|
1634
|
+
const wecomConfigDir = path$1.join(os$1.homedir(), ".openclaw", "wecomConfig");
|
|
1635
|
+
const wecomConfigPath = path$1.join(wecomConfigDir, "config.json");
|
|
1020
1636
|
// // 写入 wecom 配置文件
|
|
1021
1637
|
// try {
|
|
1022
1638
|
// const lockOptions = {
|
|
@@ -1110,6 +1726,79 @@ async function fetchAndSaveMcpConfig(wsClient, accountId, runtime) {
|
|
|
1110
1726
|
* - state-manager.ts : 全局状态管理(带 TTL 清理)
|
|
1111
1727
|
* - timeout.ts : 超时工具
|
|
1112
1728
|
*/
|
|
1729
|
+
/**
|
|
1730
|
+
* 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
|
|
1731
|
+
* 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
|
|
1732
|
+
*/
|
|
1733
|
+
function stripThinkTags(text) {
|
|
1734
|
+
return text;
|
|
1735
|
+
// return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
1736
|
+
}
|
|
1737
|
+
// ============================================================================
|
|
1738
|
+
// 媒体本地路径白名单扩展
|
|
1739
|
+
// ============================================================================
|
|
1740
|
+
/**
|
|
1741
|
+
* 解析 openclaw 状态目录(与 plugin-sdk 内部逻辑保持一致)
|
|
1742
|
+
*/
|
|
1743
|
+
function resolveStateDir() {
|
|
1744
|
+
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
1745
|
+
if (stateOverride)
|
|
1746
|
+
return stateOverride;
|
|
1747
|
+
return path__namespace$1.join(os__namespace$1.homedir(), ".openclaw");
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* 在 getDefaultMediaLocalRoots() 基础上,将 stateDir 本身也加入白名单,
|
|
1751
|
+
* 并合并用户在 WeComConfig 中配置的自定义 mediaLocalRoots。
|
|
1752
|
+
*
|
|
1753
|
+
* getDefaultMediaLocalRoots() 仅包含 stateDir 下的子目录(media/agents/workspace/sandboxes),
|
|
1754
|
+
* 但 agent 生成的文件可能直接放在 stateDir 根目录下(如 ~/.openclaw-dev/1.png),
|
|
1755
|
+
* 因此需要将 stateDir 本身也加入白名单以避免 LocalMediaAccessError。
|
|
1756
|
+
*
|
|
1757
|
+
* 用户可在 openclaw.json 中配置:
|
|
1758
|
+
* {
|
|
1759
|
+
* "channels": {
|
|
1760
|
+
* "wecom": {
|
|
1761
|
+
* "mediaLocalRoots": ["~/Downloads", "~/Documents"]
|
|
1762
|
+
* }
|
|
1763
|
+
* }
|
|
1764
|
+
* }
|
|
1765
|
+
*/
|
|
1766
|
+
async function getExtendedMediaLocalRoots(config) {
|
|
1767
|
+
// 从兼容层获取默认白名单(内部已处理低版本 SDK 的 fallback)
|
|
1768
|
+
const defaults = await getDefaultMediaLocalRoots();
|
|
1769
|
+
const roots = [...defaults];
|
|
1770
|
+
const stateDir = path__namespace$1.resolve(resolveStateDir());
|
|
1771
|
+
if (!roots.includes(stateDir)) {
|
|
1772
|
+
roots.push(stateDir);
|
|
1773
|
+
}
|
|
1774
|
+
// 合并用户在 WeComConfig 中配置的自定义路径
|
|
1775
|
+
if (config?.mediaLocalRoots) {
|
|
1776
|
+
for (const r of config.mediaLocalRoots) {
|
|
1777
|
+
const resolved = path__namespace$1.resolve(r.replace(/^~(?=\/|$)/, os__namespace$1.homedir()));
|
|
1778
|
+
if (!roots.includes(resolved)) {
|
|
1779
|
+
roots.push(resolved);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
return roots;
|
|
1784
|
+
}
|
|
1785
|
+
// ============================================================================
|
|
1786
|
+
// 媒体发送错误提示
|
|
1787
|
+
// ============================================================================
|
|
1788
|
+
/**
|
|
1789
|
+
* 根据媒体发送结果生成纯文本错误摘要(用于替换 thinking 流式消息展示给用户)。
|
|
1790
|
+
*
|
|
1791
|
+
* 使用纯文本而非 markdown 格式,因为 replyStream 只支持纯文本。
|
|
1792
|
+
*/
|
|
1793
|
+
function buildMediaErrorSummary(mediaUrl, result) {
|
|
1794
|
+
if (result.error?.includes("LocalMediaAccessError")) {
|
|
1795
|
+
return `⚠️ 文件发送失败:没有权限访问路径 ${mediaUrl}\n请在 openclaw.json 的 mediaLocalRoots 中添加该路径的父目录后重启生效。`;
|
|
1796
|
+
}
|
|
1797
|
+
if (result.rejectReason) {
|
|
1798
|
+
return `⚠️ 文件发送失败:${result.rejectReason}`;
|
|
1799
|
+
}
|
|
1800
|
+
return `⚠️ 文件发送失败:无法处理文件 ${mediaUrl},请稍后再试。`;
|
|
1801
|
+
}
|
|
1113
1802
|
// ============================================================================
|
|
1114
1803
|
// 消息上下文构建
|
|
1115
1804
|
// ============================================================================
|
|
@@ -1171,15 +1860,11 @@ function buildMessageContext(frame, account, config, text, mediaList, quoteConte
|
|
|
1171
1860
|
ReplyToBody: quoteContent,
|
|
1172
1861
|
});
|
|
1173
1862
|
}
|
|
1174
|
-
// ============================================================================
|
|
1175
|
-
// 消息处理和回复
|
|
1176
|
-
// ============================================================================
|
|
1177
1863
|
/**
|
|
1178
1864
|
* 发送"思考中"消息
|
|
1179
1865
|
*/
|
|
1180
1866
|
async function sendThinkingReply(params) {
|
|
1181
1867
|
const { wsClient, frame, streamId, runtime } = params;
|
|
1182
|
-
runtime.log?.(`[WeCom] Sending thinking message`);
|
|
1183
1868
|
try {
|
|
1184
1869
|
await sendWeComReply({
|
|
1185
1870
|
wsClient,
|
|
@@ -1191,15 +1876,105 @@ async function sendThinkingReply(params) {
|
|
|
1191
1876
|
});
|
|
1192
1877
|
}
|
|
1193
1878
|
catch (err) {
|
|
1194
|
-
runtime.error?.(`[
|
|
1879
|
+
runtime.error?.(`[wecom] Failed to send thinking message: ${String(err)}`);
|
|
1195
1880
|
}
|
|
1196
1881
|
}
|
|
1882
|
+
/**
|
|
1883
|
+
* 累积文本并判断是否有可见内容(去除 <think> 标签后)
|
|
1884
|
+
*/
|
|
1885
|
+
function accumulateText(state, text) {
|
|
1886
|
+
state.accumulatedText += text;
|
|
1887
|
+
if (!state.hasText && stripThinkTags(state.accumulatedText)) {
|
|
1888
|
+
state.hasText = true;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* 上传并发送一批媒体文件(统一走主动发送通道)
|
|
1893
|
+
*
|
|
1894
|
+
* replyMedia(被动回复)无法覆盖 replyStream 发出的 thinking 流式消息,
|
|
1895
|
+
* 因此所有媒体统一走 aibot_send_msg 主动发送。
|
|
1896
|
+
*/
|
|
1897
|
+
async function sendMediaBatch(ctx, mediaUrls) {
|
|
1898
|
+
const { wsClient, frame, state, account, runtime } = ctx;
|
|
1899
|
+
const body = frame.body;
|
|
1900
|
+
const chatId = body.chatid || body.from.userid;
|
|
1901
|
+
const mediaLocalRoots = await getExtendedMediaLocalRoots(account.config);
|
|
1902
|
+
runtime.log?.(`[wecom][debug] mediaLocalRoots=${JSON.stringify(mediaLocalRoots)}, mediaUrls=${JSON.stringify(mediaUrls)}, hasText=${!!state.hasText}`);
|
|
1903
|
+
for (const mediaUrl of mediaUrls) {
|
|
1904
|
+
const result = await uploadAndSendMedia({
|
|
1905
|
+
wsClient,
|
|
1906
|
+
mediaUrl,
|
|
1907
|
+
chatId,
|
|
1908
|
+
mediaLocalRoots,
|
|
1909
|
+
log: (...args) => runtime.log?.(...args),
|
|
1910
|
+
errorLog: (...args) => runtime.error?.(...args),
|
|
1911
|
+
});
|
|
1912
|
+
if (result.ok) {
|
|
1913
|
+
state.hasMedia = true;
|
|
1914
|
+
}
|
|
1915
|
+
else {
|
|
1916
|
+
state.hasMediaFailed = true;
|
|
1917
|
+
runtime.error?.(`[wecom] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
|
|
1918
|
+
// 收集错误摘要,后续在 finishThinkingStream 中直接替换 thinking 流展示给用户
|
|
1919
|
+
const summary = buildMediaErrorSummary(mediaUrl, result);
|
|
1920
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
1921
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
1922
|
+
: summary;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* 关闭 thinking 流(发送 finish=true 的流式消息)
|
|
1928
|
+
*
|
|
1929
|
+
* thinking 是通过 replyStream 用 streamId 发的流式消息,
|
|
1930
|
+
* 只有同一 streamId 的 replyStream(finish=true) 才能关闭它。
|
|
1931
|
+
*
|
|
1932
|
+
* ⚠️ 注意:企微会忽略空格等不可见内容,必须用有可见字符的文案才能真正
|
|
1933
|
+
* 替换掉 thinking 动画,否则 thinking 会一直残留。
|
|
1934
|
+
*
|
|
1935
|
+
* 关闭策略(按优先级):
|
|
1936
|
+
* 1. 有可见文本 → 用完整文本关闭
|
|
1937
|
+
* 2. 有媒体成功发送(通过 deliver 回调) → 用友好提示"文件已发送"
|
|
1938
|
+
* 3. 媒体发送失败 → 直接用错误摘要替换 thinking
|
|
1939
|
+
* 4. 其他 → 用通用"处理完成"提示
|
|
1940
|
+
* (agent 可能已通过内置 message 工具直接发送了文件,
|
|
1941
|
+
* 该路径走 outbound.sendMedia 完全绕过 deliver 回调,
|
|
1942
|
+
* 所以 state 中无记录,但文件已实际送达)
|
|
1943
|
+
*/
|
|
1944
|
+
async function finishThinkingStream(ctx) {
|
|
1945
|
+
const { wsClient, frame, state, runtime } = ctx;
|
|
1946
|
+
const visibleText = stripThinkTags(state.accumulatedText);
|
|
1947
|
+
let finishText;
|
|
1948
|
+
if (visibleText) {
|
|
1949
|
+
// 有可见文本:用完整文本关闭流(覆盖 thinking 为真实内容)
|
|
1950
|
+
finishText = state.accumulatedText;
|
|
1951
|
+
}
|
|
1952
|
+
else if (state.hasMedia) {
|
|
1953
|
+
// 媒体成功发送:用友好提示告知用户
|
|
1954
|
+
finishText = "📎 文件已发送,请查收。";
|
|
1955
|
+
}
|
|
1956
|
+
else if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
1957
|
+
// 媒体发送失败:直接用错误摘要替换 thinking 流(不再额外发 sendMessage)
|
|
1958
|
+
finishText = state.mediaErrorSummary;
|
|
1959
|
+
}
|
|
1960
|
+
else {
|
|
1961
|
+
// 核心无可见文本且 deliver 中未处理过媒体。
|
|
1962
|
+
//
|
|
1963
|
+
// 不使用错误提示,因为 agent 可能已通过内置 message 工具直接调用
|
|
1964
|
+
// outbound.sendMedia 成功发送了文件——该路径完全绕过 monitor 的 deliver
|
|
1965
|
+
// 回调,所以 state.deliverCalled / state.hasMedia 均为 false,但文件
|
|
1966
|
+
// 实际已送达用户。此时显示 "未生成回复" 会误导用户。
|
|
1967
|
+
finishText = "✅ 处理完成。";
|
|
1968
|
+
}
|
|
1969
|
+
await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
|
|
1970
|
+
}
|
|
1197
1971
|
/**
|
|
1198
1972
|
* 路由消息到核心处理流程并处理回复
|
|
1199
1973
|
*/
|
|
1200
1974
|
async function routeAndDispatchMessage(params) {
|
|
1201
|
-
const { ctxPayload, config, wsClient, frame, state, runtime, onCleanup } = params;
|
|
1975
|
+
const { ctxPayload, config, account, wsClient, frame, state, runtime, onCleanup } = params;
|
|
1202
1976
|
const core = getWeComRuntime();
|
|
1977
|
+
const ctx = { wsClient, frame, state, account, runtime };
|
|
1203
1978
|
// 防止 onCleanup 被多次调用(onError 回调与 catch 块可能重复触发)
|
|
1204
1979
|
let cleanedUp = false;
|
|
1205
1980
|
const safeCleanup = () => {
|
|
@@ -1214,37 +1989,54 @@ async function routeAndDispatchMessage(params) {
|
|
|
1214
1989
|
cfg: config,
|
|
1215
1990
|
dispatcherOptions: {
|
|
1216
1991
|
deliver: async (payload, info) => {
|
|
1217
|
-
state.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1992
|
+
state.deliverCalled = true;
|
|
1993
|
+
runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, text=${payload.text ?? ''}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
|
|
1994
|
+
// 累积文本
|
|
1995
|
+
if (payload.text) {
|
|
1996
|
+
accumulateText(state, payload.text);
|
|
1997
|
+
}
|
|
1998
|
+
// 发送媒体(统一走主动发送)
|
|
1999
|
+
const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
2000
|
+
if (mediaUrls.length > 0) {
|
|
2001
|
+
try {
|
|
2002
|
+
await sendMediaBatch(ctx, mediaUrls);
|
|
2003
|
+
}
|
|
2004
|
+
catch (mediaErr) {
|
|
2005
|
+
// sendMediaBatch 内部异常(如 getDefaultMediaLocalRoots 不可用等)
|
|
2006
|
+
// 必须标记 state,否则 finishThinkingStream 会显示"处理完成"误导用户
|
|
2007
|
+
state.hasMediaFailed = true;
|
|
2008
|
+
const errMsg = String(mediaErr);
|
|
2009
|
+
const summary = `⚠️ 文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
|
|
2010
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
2011
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
2012
|
+
: summary;
|
|
2013
|
+
runtime.error?.(`[wecom] sendMediaBatch threw: ${errMsg}`);
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
// 中间帧:有可见文本时流式更新
|
|
2017
|
+
if (info.kind !== "final" && state.hasText && state.accumulatedText) {
|
|
2018
|
+
await sendWeComReply({ wsClient, frame, text: state.accumulatedText, runtime, finish: false, streamId: state.streamId });
|
|
1227
2019
|
}
|
|
1228
2020
|
},
|
|
1229
2021
|
onError: (err, info) => {
|
|
1230
|
-
runtime.error?.(`[
|
|
1231
|
-
// 仅记录错误,不立即 cleanup,让外层 try/catch 统一处理最终回复和 cleanup
|
|
2022
|
+
runtime.error?.(`[wecom] ${info.kind} reply failed: ${String(err)}`);
|
|
1232
2023
|
},
|
|
1233
2024
|
},
|
|
1234
2025
|
});
|
|
1235
|
-
//
|
|
1236
|
-
await
|
|
1237
|
-
wsClient,
|
|
1238
|
-
frame,
|
|
1239
|
-
text: state.accumulatedText,
|
|
1240
|
-
runtime,
|
|
1241
|
-
finish: true,
|
|
1242
|
-
streamId: state.streamId,
|
|
1243
|
-
});
|
|
2026
|
+
// 关闭 thinking 流
|
|
2027
|
+
await finishThinkingStream(ctx);
|
|
1244
2028
|
safeCleanup();
|
|
1245
2029
|
}
|
|
1246
2030
|
catch (err) {
|
|
1247
|
-
runtime.error?.(`[
|
|
2031
|
+
runtime.error?.(`[wecom][plugin] Failed to process message: ${String(err)}`);
|
|
2032
|
+
// 即使 dispatch 抛异常,也需要关闭 thinking 流,
|
|
2033
|
+
// 避免 deliver 已成功发送媒体但后续出错时 thinking 消息残留或被错误文案覆盖
|
|
2034
|
+
try {
|
|
2035
|
+
await finishThinkingStream(ctx);
|
|
2036
|
+
}
|
|
2037
|
+
catch (finishErr) {
|
|
2038
|
+
runtime.error?.(`[wecom] Failed to finish thinking stream after dispatch error: ${String(finishErr)}`);
|
|
2039
|
+
}
|
|
1248
2040
|
safeCleanup();
|
|
1249
2041
|
}
|
|
1250
2042
|
}
|
|
@@ -1279,14 +2071,13 @@ async function processWeComMessage(params) {
|
|
|
1279
2071
|
// 如果文本为空但存在引用消息,使用引用消息内容
|
|
1280
2072
|
if (!text && quoteContent) {
|
|
1281
2073
|
text = quoteContent;
|
|
1282
|
-
runtime.log?.("[
|
|
2074
|
+
runtime.log?.("[wecom][plugin] Using quote content as message body (user only mentioned bot)");
|
|
1283
2075
|
}
|
|
1284
2076
|
// 如果既没有文本也没有图片也没有文件也没有引用内容,则跳过
|
|
1285
2077
|
if (!text && imageUrls.length === 0 && fileUrls.length === 0) {
|
|
1286
|
-
runtime.log?.("[
|
|
2078
|
+
runtime.log?.("[wecom][plugin] Skipping empty message (no text, image, file or quote)");
|
|
1287
2079
|
return;
|
|
1288
2080
|
}
|
|
1289
|
-
runtime.log?.(`[WeCom] Processing ${chatType} message from chat: ${chatId} user: ${body.from.userid} reqId: ${reqId}${imageUrls.length > 0 ? ` (with ${imageUrls.length} image(s))` : ""}${fileUrls.length > 0 ? ` (with ${fileUrls.length} file(s))` : ""}${quoteContent ? ` (with quote)` : ""}`);
|
|
1290
2081
|
// Step 2: 群组策略检查(仅群聊)
|
|
1291
2082
|
if (chatType === "group") {
|
|
1292
2083
|
const groupPolicyResult = checkGroupPolicy({
|
|
@@ -1347,10 +2138,12 @@ async function processWeComMessage(params) {
|
|
|
1347
2138
|
}
|
|
1348
2139
|
// Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
|
|
1349
2140
|
const ctxPayload = buildMessageContext(frame, account, config, text, mediaList, quoteContent);
|
|
2141
|
+
runtime.log?.(`[plugin -> openclaw] body=${text}, mediaPaths=${JSON.stringify(mediaList.map(m => m.path))}${quoteContent ? `, quote=${quoteContent}` : ''}`);
|
|
1350
2142
|
try {
|
|
1351
2143
|
await withTimeout(routeAndDispatchMessage({
|
|
1352
2144
|
ctxPayload,
|
|
1353
2145
|
config,
|
|
2146
|
+
account,
|
|
1354
2147
|
wsClient,
|
|
1355
2148
|
frame,
|
|
1356
2149
|
state,
|
|
@@ -1359,7 +2152,23 @@ async function processWeComMessage(params) {
|
|
|
1359
2152
|
}), MESSAGE_PROCESS_TIMEOUT_MS, `Message processing timed out (msgId=${messageId})`);
|
|
1360
2153
|
}
|
|
1361
2154
|
catch (err) {
|
|
1362
|
-
runtime.error?.(`[
|
|
2155
|
+
runtime.error?.(`[wecom][plugin] Message processing failed or timed out: ${String(err)}`);
|
|
2156
|
+
// 确保 thinking 流被关闭,防止异常/超时时 thinking 消息一直残留
|
|
2157
|
+
try {
|
|
2158
|
+
if (shouldSendThinking) {
|
|
2159
|
+
await sendWeComReply({
|
|
2160
|
+
wsClient,
|
|
2161
|
+
frame,
|
|
2162
|
+
text: "处理消息时出现异常,请稍后重试。",
|
|
2163
|
+
runtime,
|
|
2164
|
+
finish: true,
|
|
2165
|
+
streamId: state.streamId,
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
catch (finishErr) {
|
|
2170
|
+
runtime.error?.(`[wecom] Failed to finish thinking stream on error: ${String(finishErr)}`);
|
|
2171
|
+
}
|
|
1363
2172
|
cleanupState();
|
|
1364
2173
|
}
|
|
1365
2174
|
}
|
|
@@ -1649,7 +2458,6 @@ async function sendWeComMessage({ to, content, accountId, }) {
|
|
|
1649
2458
|
// 从 to 中提取 chatId(格式是 "${CHANNEL_ID}:chatId" 或直接是 chatId)
|
|
1650
2459
|
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
1651
2460
|
const chatId = to.replace(channelPrefix, "");
|
|
1652
|
-
console.log(`[WeCom] sendWeComMessage: ${JSON.stringify({ to, content, accountId })}`);
|
|
1653
2461
|
// 获取 WSClient 实例
|
|
1654
2462
|
const wsClient = getWeComWebSocket(resolvedAccountId);
|
|
1655
2463
|
if (!wsClient) {
|
|
@@ -1661,7 +2469,6 @@ async function sendWeComMessage({ to, content, accountId, }) {
|
|
|
1661
2469
|
markdown: { content },
|
|
1662
2470
|
});
|
|
1663
2471
|
const messageId = result?.headers?.req_id ?? `wecom-${Date.now()}`;
|
|
1664
|
-
console.log(`[WeCom] Sent message to ${chatId}, messageId=${messageId}`);
|
|
1665
2472
|
return {
|
|
1666
2473
|
channel: CHANNEL_ID,
|
|
1667
2474
|
messageId,
|
|
@@ -1694,7 +2501,7 @@ const wecomPlugin = {
|
|
|
1694
2501
|
// content: " pairing approved",
|
|
1695
2502
|
// accountId: cfg.accountId,
|
|
1696
2503
|
// });
|
|
1697
|
-
|
|
2504
|
+
// Pairing approved for user
|
|
1698
2505
|
},
|
|
1699
2506
|
},
|
|
1700
2507
|
onboarding: wecomOnboardingAdapter,
|
|
@@ -1821,14 +2628,51 @@ const wecomPlugin = {
|
|
|
1821
2628
|
deliveryMode: "gateway",
|
|
1822
2629
|
chunker: (text, limit) => getWeComRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
1823
2630
|
textChunkLimit: TEXT_CHUNK_LIMIT,
|
|
1824
|
-
sendText: async ({ to, text, accountId
|
|
1825
|
-
console.log(`[WeCom] sendText: ${JSON.stringify({ to, text, accountId, ...rest })}`);
|
|
2631
|
+
sendText: async ({ to, text, accountId }) => {
|
|
1826
2632
|
return sendWeComMessage({ to, content: text, accountId: accountId ?? undefined });
|
|
1827
2633
|
},
|
|
1828
|
-
sendMedia: async ({ to, text, mediaUrl,
|
|
1829
|
-
|
|
1830
|
-
const
|
|
1831
|
-
|
|
2634
|
+
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId }) => {
|
|
2635
|
+
const resolvedAccountId = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2636
|
+
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
2637
|
+
const chatId = to.replace(channelPrefix, "");
|
|
2638
|
+
// 获取 WSClient 实例
|
|
2639
|
+
const wsClient = getWeComWebSocket(resolvedAccountId);
|
|
2640
|
+
if (!wsClient) {
|
|
2641
|
+
throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
|
|
2642
|
+
}
|
|
2643
|
+
// 如果没有 mediaUrl,fallback 为纯文本
|
|
2644
|
+
if (!mediaUrl) {
|
|
2645
|
+
return sendWeComMessage({ to, content: text || "", accountId: resolvedAccountId });
|
|
2646
|
+
}
|
|
2647
|
+
const result = await uploadAndSendMedia({
|
|
2648
|
+
wsClient,
|
|
2649
|
+
mediaUrl,
|
|
2650
|
+
chatId,
|
|
2651
|
+
mediaLocalRoots,
|
|
2652
|
+
});
|
|
2653
|
+
if (result.rejected) {
|
|
2654
|
+
return sendWeComMessage({ to, content: `⚠️ ${result.rejectReason}`, accountId: resolvedAccountId });
|
|
2655
|
+
}
|
|
2656
|
+
if (!result.ok) {
|
|
2657
|
+
// 上传/发送失败,fallback 为文本 + URL
|
|
2658
|
+
const fallbackContent = text
|
|
2659
|
+
? `${text}\n📎 ${mediaUrl}`
|
|
2660
|
+
: `📎 ${mediaUrl}`;
|
|
2661
|
+
return sendWeComMessage({ to, content: fallbackContent, accountId: resolvedAccountId });
|
|
2662
|
+
}
|
|
2663
|
+
// 如有伴随文本,额外发送一条 markdown
|
|
2664
|
+
if (text) {
|
|
2665
|
+
await sendWeComMessage({ to, content: text, accountId: resolvedAccountId });
|
|
2666
|
+
}
|
|
2667
|
+
// 如果有降级说明,额外发送提示
|
|
2668
|
+
if (result.downgradeNote) {
|
|
2669
|
+
await sendWeComMessage({ to, content: `ℹ️ ${result.downgradeNote}`, accountId: resolvedAccountId });
|
|
2670
|
+
}
|
|
2671
|
+
return {
|
|
2672
|
+
channel: CHANNEL_ID,
|
|
2673
|
+
messageId: result.messageId,
|
|
2674
|
+
chatId,
|
|
2675
|
+
};
|
|
1832
2676
|
},
|
|
1833
2677
|
},
|
|
1834
2678
|
status: {
|
|
@@ -1937,6 +2781,33 @@ const plugin = {
|
|
|
1937
2781
|
register(api) {
|
|
1938
2782
|
setWeComRuntime(api.runtime);
|
|
1939
2783
|
api.registerChannel({ plugin: wecomPlugin });
|
|
2784
|
+
// 注入媒体发送指令和文件大小限制提示词
|
|
2785
|
+
api.on("before_prompt_build", () => {
|
|
2786
|
+
return {
|
|
2787
|
+
appendSystemContext: [
|
|
2788
|
+
"【发送文件/图片/视频/语音】",
|
|
2789
|
+
"当你需要向用户发送文件、图片、视频或语音时,必须在回复中单独一行使用 MEDIA: 指令,后面跟文件的本地路径。",
|
|
2790
|
+
"格式:MEDIA: /文件的绝对路径",
|
|
2791
|
+
"文件优先存放到 ~/.openclaw 目录下,确保路径可访问。",
|
|
2792
|
+
"示例:",
|
|
2793
|
+
" MEDIA: ~/.openclaw/output.png",
|
|
2794
|
+
" MEDIA: ~/.openclaw/report.pdf",
|
|
2795
|
+
"系统会自动识别文件类型并发送给用户。",
|
|
2796
|
+
"",
|
|
2797
|
+
"注意事项:",
|
|
2798
|
+
"- MEDIA: 必须在行首,后面紧跟文件路径(不是 URL)",
|
|
2799
|
+
"- 如果路径中包含空格,可以用反引号包裹:MEDIA: `/path/to/my file.png`",
|
|
2800
|
+
"- 每个文件单独一行 MEDIA: 指令",
|
|
2801
|
+
"- 可以在 MEDIA: 指令前后附带文字说明",
|
|
2802
|
+
"",
|
|
2803
|
+
"【文件大小限制】",
|
|
2804
|
+
"- 图片不超过 10MB,视频不超过 10MB,语音不超过 2MB(仅支持 AMR 格式),文件不超过 20MB",
|
|
2805
|
+
"- 语音消息仅支持 AMR 格式(.amr),如需发送语音请确保文件为 AMR 格式",
|
|
2806
|
+
"- 超过大小限制的图片/视频/语音会被自动转为文件格式发送",
|
|
2807
|
+
"- 如果文件超过 20MB,将无法发送,请提前告知用户并尝试缩减文件大小",
|
|
2808
|
+
].join("\n"),
|
|
2809
|
+
};
|
|
2810
|
+
});
|
|
1940
2811
|
},
|
|
1941
2812
|
};
|
|
1942
2813
|
|