@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.esm.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { readJsonFileWithFallback, withFileLock, writeJsonFileAtomically, DEFAULT_ACCOUNT_ID, addWildcardAllowFrom, formatPairingApproveHint, emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
|
|
2
|
+
import * as os$1 from 'os';
|
|
3
|
+
import os__default$1 from 'os';
|
|
4
|
+
import * as path$1 from 'path';
|
|
5
|
+
import path__default from 'path';
|
|
2
6
|
import { generateReqId, WSClient } from '@wecom/aibot-node-sdk';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
7
|
+
import * as fs from 'node:fs/promises';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import path__default$1 from 'node:path';
|
|
10
|
+
import * as os from 'node:os';
|
|
11
|
+
import os__default from 'node:os';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { fileURLToPath as fileURLToPath$1 } from 'url';
|
|
5
14
|
import { fileTypeFromBuffer } from 'file-type';
|
|
6
|
-
import os from 'node:os';
|
|
7
|
-
import path$1 from 'node:path';
|
|
8
|
-
import os$1 from 'os';
|
|
9
15
|
|
|
10
16
|
let runtime = null;
|
|
11
17
|
function setWeComRuntime(r) {
|
|
@@ -18,6 +24,310 @@ function getWeComRuntime() {
|
|
|
18
24
|
return runtime;
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
/**
|
|
28
|
+
* openclaw plugin-sdk 高版本方法兼容层
|
|
29
|
+
*
|
|
30
|
+
* 部分方法(如 loadOutboundMediaFromUrl、detectMime、getDefaultMediaLocalRoots)
|
|
31
|
+
* 仅在较新版本的 openclaw plugin-sdk 中才导出。
|
|
32
|
+
*
|
|
33
|
+
* 本模块在加载时一次性探测 SDK 导出,存在则直接 re-export SDK 版本,
|
|
34
|
+
* 不存在则导出 fallback 实现。其他模块统一从本文件导入,无需关心底层兼容细节。
|
|
35
|
+
*/
|
|
36
|
+
const _sdkReady = import('openclaw/plugin-sdk')
|
|
37
|
+
.then((sdk) => {
|
|
38
|
+
const exports$1 = {};
|
|
39
|
+
if (typeof sdk.loadOutboundMediaFromUrl === "function") {
|
|
40
|
+
exports$1.loadOutboundMediaFromUrl = sdk.loadOutboundMediaFromUrl;
|
|
41
|
+
}
|
|
42
|
+
if (typeof sdk.detectMime === "function") {
|
|
43
|
+
exports$1.detectMime = sdk.detectMime;
|
|
44
|
+
}
|
|
45
|
+
if (typeof sdk.getDefaultMediaLocalRoots === "function") {
|
|
46
|
+
exports$1.getDefaultMediaLocalRoots = sdk.getDefaultMediaLocalRoots;
|
|
47
|
+
}
|
|
48
|
+
return exports$1;
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {
|
|
51
|
+
// openclaw/plugin-sdk 不可用或版本过低,全部使用 fallback
|
|
52
|
+
return {};
|
|
53
|
+
});
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// detectMime —— 检测 MIME 类型
|
|
56
|
+
// ============================================================================
|
|
57
|
+
const MIME_BY_EXT = {
|
|
58
|
+
".heic": "image/heic",
|
|
59
|
+
".heif": "image/heif",
|
|
60
|
+
".jpg": "image/jpeg",
|
|
61
|
+
".jpeg": "image/jpeg",
|
|
62
|
+
".png": "image/png",
|
|
63
|
+
".webp": "image/webp",
|
|
64
|
+
".gif": "image/gif",
|
|
65
|
+
".ogg": "audio/ogg",
|
|
66
|
+
".mp3": "audio/mpeg",
|
|
67
|
+
".m4a": "audio/x-m4a",
|
|
68
|
+
".mp4": "video/mp4",
|
|
69
|
+
".mov": "video/quicktime",
|
|
70
|
+
".pdf": "application/pdf",
|
|
71
|
+
".json": "application/json",
|
|
72
|
+
".zip": "application/zip",
|
|
73
|
+
".gz": "application/gzip",
|
|
74
|
+
".tar": "application/x-tar",
|
|
75
|
+
".7z": "application/x-7z-compressed",
|
|
76
|
+
".rar": "application/vnd.rar",
|
|
77
|
+
".doc": "application/msword",
|
|
78
|
+
".xls": "application/vnd.ms-excel",
|
|
79
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
80
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
81
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
82
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
83
|
+
".csv": "text/csv",
|
|
84
|
+
".txt": "text/plain",
|
|
85
|
+
".md": "text/markdown",
|
|
86
|
+
".amr": "audio/amr",
|
|
87
|
+
".aac": "audio/aac",
|
|
88
|
+
".wav": "audio/wav",
|
|
89
|
+
".webm": "video/webm",
|
|
90
|
+
".avi": "video/x-msvideo",
|
|
91
|
+
".bmp": "image/bmp",
|
|
92
|
+
".svg": "image/svg+xml",
|
|
93
|
+
};
|
|
94
|
+
/** 通过 buffer 魔术字节嗅探 MIME 类型(动态导入 file-type,不强依赖) */
|
|
95
|
+
async function sniffMimeFromBuffer(buffer) {
|
|
96
|
+
try {
|
|
97
|
+
const { fileTypeFromBuffer } = await import('file-type');
|
|
98
|
+
const type = await fileTypeFromBuffer(buffer);
|
|
99
|
+
return type?.mime ?? undefined;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** fallback 版 detectMime,参考 weclaw/src/media/mime.ts */
|
|
106
|
+
async function detectMimeFallback(opts) {
|
|
107
|
+
const ext = opts.filePath ? path.extname(opts.filePath).toLowerCase() : undefined;
|
|
108
|
+
const extMime = ext ? MIME_BY_EXT[ext] : undefined;
|
|
109
|
+
const sniffed = opts.buffer ? await sniffMimeFromBuffer(opts.buffer) : undefined;
|
|
110
|
+
const isGeneric = (m) => !m || m === "application/octet-stream" || m === "application/zip";
|
|
111
|
+
if (sniffed && (!isGeneric(sniffed) || !extMime)) {
|
|
112
|
+
return sniffed;
|
|
113
|
+
}
|
|
114
|
+
if (extMime) {
|
|
115
|
+
return extMime;
|
|
116
|
+
}
|
|
117
|
+
const headerMime = opts.headerMime?.split(";")?.[0]?.trim().toLowerCase();
|
|
118
|
+
if (headerMime && !isGeneric(headerMime)) {
|
|
119
|
+
return headerMime;
|
|
120
|
+
}
|
|
121
|
+
if (sniffed) {
|
|
122
|
+
return sniffed;
|
|
123
|
+
}
|
|
124
|
+
if (headerMime) {
|
|
125
|
+
return headerMime;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 检测 MIME 类型(兼容入口)
|
|
131
|
+
*
|
|
132
|
+
* 支持两种调用签名以兼容不同使用场景:
|
|
133
|
+
* - detectMime(buffer) → 旧式调用
|
|
134
|
+
* - detectMime({ buffer, headerMime, filePath }) → 完整参数
|
|
135
|
+
*
|
|
136
|
+
* 优先使用 SDK 版本,不可用时使用 fallback。
|
|
137
|
+
*/
|
|
138
|
+
async function detectMime(bufferOrOpts) {
|
|
139
|
+
const sdk = await _sdkReady;
|
|
140
|
+
const opts = Buffer.isBuffer(bufferOrOpts)
|
|
141
|
+
? { buffer: bufferOrOpts }
|
|
142
|
+
: bufferOrOpts;
|
|
143
|
+
if (sdk.detectMime) {
|
|
144
|
+
try {
|
|
145
|
+
return await sdk.detectMime(opts);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// SDK detectMime 异常,降级到 fallback
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return detectMimeFallback(opts);
|
|
152
|
+
}
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// loadOutboundMediaFromUrl —— 从 URL/路径加载媒体文件
|
|
155
|
+
// ============================================================================
|
|
156
|
+
/** 安全的本地文件路径校验,参考 weclaw/src/web/media.ts */
|
|
157
|
+
async function assertLocalMediaAllowed(mediaPath, localRoots) {
|
|
158
|
+
if (!localRoots || localRoots.length === 0) {
|
|
159
|
+
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
|
|
160
|
+
}
|
|
161
|
+
let resolved;
|
|
162
|
+
try {
|
|
163
|
+
resolved = await fs.realpath(mediaPath);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
resolved = path.resolve(mediaPath);
|
|
167
|
+
}
|
|
168
|
+
for (const root of localRoots) {
|
|
169
|
+
let resolvedRoot;
|
|
170
|
+
try {
|
|
171
|
+
resolvedRoot = await fs.realpath(root);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
resolvedRoot = path.resolve(root);
|
|
175
|
+
}
|
|
176
|
+
if (resolvedRoot === path.parse(resolvedRoot).root) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw new Error(`Local media path is not under an allowed directory: ${mediaPath}`);
|
|
184
|
+
}
|
|
185
|
+
/** 从远程 URL 获取媒体 */
|
|
186
|
+
async function fetchRemoteMedia(url, maxBytes) {
|
|
187
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
throw new Error(`Failed to fetch media from ${url}: HTTP ${res.status} ${res.statusText}`);
|
|
190
|
+
}
|
|
191
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
192
|
+
if (maxBytes && buffer.length > maxBytes) {
|
|
193
|
+
throw new Error(`Media from ${url} exceeds max size (${buffer.length} > ${maxBytes})`);
|
|
194
|
+
}
|
|
195
|
+
const headerMime = res.headers.get("content-type")?.split(";")?.[0]?.trim();
|
|
196
|
+
let fileName;
|
|
197
|
+
const disposition = res.headers.get("content-disposition");
|
|
198
|
+
if (disposition) {
|
|
199
|
+
const match = /filename\*?\s*=\s*(?:UTF-8''|")?([^";]+)/i.exec(disposition);
|
|
200
|
+
if (match?.[1]) {
|
|
201
|
+
try {
|
|
202
|
+
fileName = path.basename(decodeURIComponent(match[1].replace(/["']/g, "").trim()));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
fileName = path.basename(match[1].replace(/["']/g, "").trim());
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!fileName) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = new URL(url);
|
|
212
|
+
const base = path.basename(parsed.pathname);
|
|
213
|
+
if (base && base.includes("."))
|
|
214
|
+
fileName = base;
|
|
215
|
+
}
|
|
216
|
+
catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
const contentType = await detectMimeFallback({ buffer, headerMime, filePath: fileName ?? url });
|
|
219
|
+
return { buffer, contentType, fileName };
|
|
220
|
+
}
|
|
221
|
+
/** 展开 ~ 为用户主目录 */
|
|
222
|
+
function resolveUserPath(p) {
|
|
223
|
+
if (p.startsWith("~")) {
|
|
224
|
+
return path.join(os.homedir(), p.slice(1));
|
|
225
|
+
}
|
|
226
|
+
return p;
|
|
227
|
+
}
|
|
228
|
+
/** fallback 版 loadOutboundMediaFromUrl,参考 weclaw/src/web/media.ts */
|
|
229
|
+
async function loadOutboundMediaFromUrlFallback(mediaUrl, options = {}) {
|
|
230
|
+
const { maxBytes, mediaLocalRoots } = options;
|
|
231
|
+
// 去除 MEDIA: 前缀
|
|
232
|
+
mediaUrl = mediaUrl.replace(/^\s*MEDIA\s*:\s*/i, "");
|
|
233
|
+
// 处理 file:// URL
|
|
234
|
+
if (mediaUrl.startsWith("file://")) {
|
|
235
|
+
try {
|
|
236
|
+
mediaUrl = fileURLToPath(mediaUrl);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
throw new Error(`Invalid file:// URL: ${mediaUrl}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// 远程 URL
|
|
243
|
+
if (/^https?:\/\//i.test(mediaUrl)) {
|
|
244
|
+
const fetched = await fetchRemoteMedia(mediaUrl, maxBytes);
|
|
245
|
+
return {
|
|
246
|
+
buffer: fetched.buffer,
|
|
247
|
+
contentType: fetched.contentType,
|
|
248
|
+
fileName: fetched.fileName,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// 展开 ~ 路径
|
|
252
|
+
if (mediaUrl.startsWith("~")) {
|
|
253
|
+
mediaUrl = resolveUserPath(mediaUrl);
|
|
254
|
+
}
|
|
255
|
+
// 本地文件:安全校验
|
|
256
|
+
await assertLocalMediaAllowed(mediaUrl, mediaLocalRoots);
|
|
257
|
+
// 读取本地文件
|
|
258
|
+
let data;
|
|
259
|
+
try {
|
|
260
|
+
const stat = await fs.stat(mediaUrl);
|
|
261
|
+
if (!stat.isFile()) {
|
|
262
|
+
throw new Error(`Local media path is not a file: ${mediaUrl}`);
|
|
263
|
+
}
|
|
264
|
+
data = await fs.readFile(mediaUrl);
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
if (err?.code === "ENOENT") {
|
|
268
|
+
throw new Error(`Local media file not found: ${mediaUrl}`);
|
|
269
|
+
}
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
if (maxBytes && data.length > maxBytes) {
|
|
273
|
+
throw new Error(`Local media exceeds max size (${data.length} > ${maxBytes})`);
|
|
274
|
+
}
|
|
275
|
+
const mime = await detectMimeFallback({ buffer: data, filePath: mediaUrl });
|
|
276
|
+
const fileName = path.basename(mediaUrl) || undefined;
|
|
277
|
+
return {
|
|
278
|
+
buffer: data,
|
|
279
|
+
contentType: mime,
|
|
280
|
+
fileName,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* 从 URL 或本地路径加载媒体文件(兼容入口)
|
|
285
|
+
*
|
|
286
|
+
* 优先使用 SDK 版本,不可用时使用 fallback。
|
|
287
|
+
* SDK 版本抛出的业务异常(如 LocalMediaAccessError)会直接透传。
|
|
288
|
+
*/
|
|
289
|
+
async function loadOutboundMediaFromUrl(mediaUrl, options = {}) {
|
|
290
|
+
const sdk = await _sdkReady;
|
|
291
|
+
if (sdk.loadOutboundMediaFromUrl) {
|
|
292
|
+
return sdk.loadOutboundMediaFromUrl(mediaUrl, options);
|
|
293
|
+
}
|
|
294
|
+
return loadOutboundMediaFromUrlFallback(mediaUrl, options);
|
|
295
|
+
}
|
|
296
|
+
// ============================================================================
|
|
297
|
+
// getDefaultMediaLocalRoots —— 获取默认媒体本地路径白名单
|
|
298
|
+
// ============================================================================
|
|
299
|
+
/** 解析 openclaw 状态目录 */
|
|
300
|
+
function resolveStateDir$1() {
|
|
301
|
+
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
302
|
+
if (stateOverride)
|
|
303
|
+
return stateOverride;
|
|
304
|
+
return path.join(os.homedir(), ".openclaw");
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 获取默认媒体本地路径白名单(兼容入口)
|
|
308
|
+
*
|
|
309
|
+
* 优先使用 SDK 版本,不可用时手动构建白名单(与 weclaw/src/media/local-roots.ts 逻辑一致)。
|
|
310
|
+
*/
|
|
311
|
+
async function getDefaultMediaLocalRoots() {
|
|
312
|
+
const sdk = await _sdkReady;
|
|
313
|
+
if (sdk.getDefaultMediaLocalRoots) {
|
|
314
|
+
try {
|
|
315
|
+
return sdk.getDefaultMediaLocalRoots();
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// SDK 版本异常,降级到 fallback
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// fallback: 手动构建默认白名单
|
|
322
|
+
const stateDir = path.resolve(resolveStateDir$1());
|
|
323
|
+
return [
|
|
324
|
+
path.join(stateDir, "media"),
|
|
325
|
+
path.join(stateDir, "agents"),
|
|
326
|
+
path.join(stateDir, "workspace"),
|
|
327
|
+
path.join(stateDir, "sandboxes"),
|
|
328
|
+
];
|
|
329
|
+
}
|
|
330
|
+
|
|
21
331
|
/**
|
|
22
332
|
* 企业微信渠道常量定义
|
|
23
333
|
*/
|
|
@@ -82,10 +392,10 @@ const MEDIA_DOCUMENT_PLACEHOLDER = "<media:document>";
|
|
|
82
392
|
const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
|
|
83
393
|
/** MCP 配置拉取超时时间(毫秒) */
|
|
84
394
|
const MCP_CONFIG_FETCH_TIMEOUT_MS = 15000;
|
|
85
|
-
const __filename$1 = fileURLToPath(import.meta.url);
|
|
86
|
-
const __dirname$1 =
|
|
395
|
+
const __filename$1 = fileURLToPath$1(import.meta.url);
|
|
396
|
+
const __dirname$1 = path__default.dirname(__filename$1);
|
|
87
397
|
const PLUGIN_JSON_FILENAME = "openclaw.plugin.json";
|
|
88
|
-
|
|
398
|
+
path__default.resolve(path__default.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
|
|
89
399
|
// ============================================================================
|
|
90
400
|
// 默认值
|
|
91
401
|
// ============================================================================
|
|
@@ -93,6 +403,19 @@ path.resolve(path.join(__dirname$1, "../", PLUGIN_JSON_FILENAME));
|
|
|
93
403
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
94
404
|
/** 文本分块大小上限 */
|
|
95
405
|
const TEXT_CHUNK_LIMIT = 4000;
|
|
406
|
+
// ============================================================================
|
|
407
|
+
// 媒体上传相关常量
|
|
408
|
+
// ============================================================================
|
|
409
|
+
/** 图片大小上限(字节):10MB */
|
|
410
|
+
const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
411
|
+
/** 视频大小上限(字节):10MB */
|
|
412
|
+
const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
413
|
+
/** 语音大小上限(字节):2MB */
|
|
414
|
+
const VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
|
415
|
+
/** 文件大小上限(字节):20MB */
|
|
416
|
+
const FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
417
|
+
/** 文件绝对上限(字节):超过此值无法发送,等于 FILE_MAX_BYTES */
|
|
418
|
+
const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
|
|
96
419
|
|
|
97
420
|
/**
|
|
98
421
|
* 企业微信消息内容解析模块
|
|
@@ -234,12 +557,12 @@ async function sendWeComReply(params) {
|
|
|
234
557
|
}
|
|
235
558
|
const streamId = existingStreamId || generateReqId("stream");
|
|
236
559
|
if (!wsClient.isConnected) {
|
|
237
|
-
runtime.error?.(`[
|
|
560
|
+
runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
|
|
238
561
|
throw new Error("WSClient not connected");
|
|
239
562
|
}
|
|
240
563
|
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
|
|
241
564
|
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
|
|
242
|
-
runtime.log?.(`[
|
|
565
|
+
runtime.log?.(`[plugin -> server] streamId=${streamId}, finish=${finish}, text=${text}`);
|
|
243
566
|
return streamId;
|
|
244
567
|
}
|
|
245
568
|
|
|
@@ -280,7 +603,7 @@ async function downloadAndSaveImages(params) {
|
|
|
280
603
|
const mediaList = [];
|
|
281
604
|
for (const imageUrl of imageUrls) {
|
|
282
605
|
try {
|
|
283
|
-
runtime.log?.(`[
|
|
606
|
+
runtime.log?.(`[wecom] Downloading image: url=${imageUrl}`);
|
|
284
607
|
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
285
608
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
286
609
|
let imageBuffer;
|
|
@@ -293,26 +616,26 @@ async function downloadAndSaveImages(params) {
|
|
|
293
616
|
imageBuffer = result.buffer;
|
|
294
617
|
originalFilename = result.filename;
|
|
295
618
|
imageContentType = await detectImageContentType(imageBuffer);
|
|
296
|
-
runtime.log?.(`[
|
|
619
|
+
runtime.log?.(`[wecom] Image downloaded: size=${imageBuffer.length}, contentType=${imageContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
297
620
|
}
|
|
298
621
|
catch (sdkError) {
|
|
299
622
|
// 如果 SDK 方法失败,回退到原有方式(带超时保护)
|
|
300
|
-
runtime.log?.(`[
|
|
623
|
+
runtime.log?.(`[wecom] SDK download failed, fallback: ${String(sdkError)}`);
|
|
301
624
|
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
|
|
302
|
-
runtime.log?.(`[
|
|
625
|
+
runtime.log?.(`[wecom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
303
626
|
imageBuffer = fetched.buffer;
|
|
304
627
|
imageContentType = fetched.contentType ?? "application/octet-stream";
|
|
305
628
|
const isValidImage = await isImageBuffer(fetched.buffer);
|
|
306
629
|
if (!isValidImage) {
|
|
307
|
-
runtime.log?.(`[
|
|
630
|
+
runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
|
|
308
631
|
}
|
|
309
632
|
}
|
|
310
633
|
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
|
|
311
634
|
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
312
|
-
runtime.log?.(`[
|
|
635
|
+
runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
313
636
|
}
|
|
314
637
|
catch (err) {
|
|
315
|
-
runtime.error?.(`[
|
|
638
|
+
runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
|
|
316
639
|
}
|
|
317
640
|
}
|
|
318
641
|
return mediaList;
|
|
@@ -326,7 +649,7 @@ async function downloadAndSaveFiles(params) {
|
|
|
326
649
|
const mediaList = [];
|
|
327
650
|
for (const fileUrl of fileUrls) {
|
|
328
651
|
try {
|
|
329
|
-
runtime.log?.(`[
|
|
652
|
+
runtime.log?.(`[wecom] Downloading file: url=${fileUrl}`);
|
|
330
653
|
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
331
654
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
332
655
|
let fileBuffer;
|
|
@@ -341,27 +664,301 @@ async function downloadAndSaveFiles(params) {
|
|
|
341
664
|
// 检测文件类型
|
|
342
665
|
const type = await fileTypeFromBuffer(fileBuffer);
|
|
343
666
|
fileContentType = type?.mime ?? "application/octet-stream";
|
|
344
|
-
runtime.log?.(`[
|
|
667
|
+
runtime.log?.(`[wecom] File downloaded: size=${fileBuffer.length}, contentType=${fileContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
345
668
|
}
|
|
346
669
|
catch (sdkError) {
|
|
347
670
|
// 如果 SDK 方法失败,回退到 fetchRemoteMedia(带超时保护)
|
|
348
|
-
runtime.log?.(`[
|
|
671
|
+
runtime.log?.(`[wecom] SDK file download failed, fallback: ${String(sdkError)}`);
|
|
349
672
|
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
|
|
350
|
-
runtime.log?.(`[
|
|
673
|
+
runtime.log?.(`[wecom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
351
674
|
fileBuffer = fetched.buffer;
|
|
352
675
|
fileContentType = fetched.contentType ?? "application/octet-stream";
|
|
353
676
|
}
|
|
354
677
|
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
|
|
355
678
|
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
356
|
-
runtime.log?.(`[
|
|
679
|
+
runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
357
680
|
}
|
|
358
681
|
catch (err) {
|
|
359
|
-
runtime.error?.(`[
|
|
682
|
+
runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
|
|
360
683
|
}
|
|
361
684
|
}
|
|
362
685
|
return mediaList;
|
|
363
686
|
}
|
|
364
687
|
|
|
688
|
+
/**
|
|
689
|
+
* 企业微信出站媒体上传工具模块
|
|
690
|
+
*
|
|
691
|
+
* 负责:
|
|
692
|
+
* - 从 mediaUrl 加载文件 buffer(远程 URL 或本地路径均支持)
|
|
693
|
+
* - 检测 MIME 类型并映射为企微媒体类型
|
|
694
|
+
* - 文件大小检查与降级策略
|
|
695
|
+
*/
|
|
696
|
+
// ============================================================================
|
|
697
|
+
// MIME → 企微媒体类型映射
|
|
698
|
+
// ============================================================================
|
|
699
|
+
/**
|
|
700
|
+
* 根据 MIME 类型检测企微媒体类型
|
|
701
|
+
*
|
|
702
|
+
* @param mimeType - MIME 类型字符串
|
|
703
|
+
* @returns 企微媒体类型
|
|
704
|
+
*/
|
|
705
|
+
function detectWeComMediaType(mimeType) {
|
|
706
|
+
const mime = mimeType.toLowerCase();
|
|
707
|
+
// 图片类型
|
|
708
|
+
if (mime.startsWith("image/")) {
|
|
709
|
+
return "image";
|
|
710
|
+
}
|
|
711
|
+
// 视频类型
|
|
712
|
+
if (mime.startsWith("video/")) {
|
|
713
|
+
return "video";
|
|
714
|
+
}
|
|
715
|
+
// 语音类型
|
|
716
|
+
if (mime.startsWith("audio/") ||
|
|
717
|
+
mime === "application/ogg" // OGG 音频容器
|
|
718
|
+
) {
|
|
719
|
+
return "voice";
|
|
720
|
+
}
|
|
721
|
+
// 其他类型默认为文件
|
|
722
|
+
return "file";
|
|
723
|
+
}
|
|
724
|
+
// ============================================================================
|
|
725
|
+
// 媒体文件加载
|
|
726
|
+
// ============================================================================
|
|
727
|
+
/**
|
|
728
|
+
* 从 mediaUrl 加载媒体文件
|
|
729
|
+
*
|
|
730
|
+
* 支持远程 URL(http/https)和本地路径(file:// 或绝对路径),
|
|
731
|
+
* 利用 openclaw plugin-sdk 的 loadOutboundMediaFromUrl 统一处理。
|
|
732
|
+
*
|
|
733
|
+
* @param mediaUrl - 媒体文件的 URL 或本地路径
|
|
734
|
+
* @param mediaLocalRoots - 允许读取本地文件的安全白名单目录
|
|
735
|
+
* @returns 解析后的媒体文件信息
|
|
736
|
+
*/
|
|
737
|
+
async function resolveMediaFile(mediaUrl, mediaLocalRoots) {
|
|
738
|
+
// 使用兼容层加载媒体文件(优先 SDK,不可用时 fallback)
|
|
739
|
+
// 传入足够大的 maxBytes,由我们自己在后续步骤做大小检查
|
|
740
|
+
const result = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
741
|
+
maxBytes: ABSOLUTE_MAX_BYTES,
|
|
742
|
+
mediaLocalRoots,
|
|
743
|
+
});
|
|
744
|
+
if (!result.buffer || result.buffer.length === 0) {
|
|
745
|
+
throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
|
|
746
|
+
}
|
|
747
|
+
// 检测真实 MIME 类型
|
|
748
|
+
let contentType = result.contentType || "application/octet-stream";
|
|
749
|
+
// 如果没有返回准确的 contentType,尝试通过 buffer 魔术字节检测
|
|
750
|
+
if (contentType === "application/octet-stream" ||
|
|
751
|
+
contentType === "text/plain") {
|
|
752
|
+
const detected = await detectMime(result.buffer);
|
|
753
|
+
if (detected) {
|
|
754
|
+
contentType = detected;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// 提取文件名
|
|
758
|
+
const fileName = extractFileName(mediaUrl, result.fileName, contentType);
|
|
759
|
+
return {
|
|
760
|
+
buffer: result.buffer,
|
|
761
|
+
contentType,
|
|
762
|
+
fileName,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
// ============================================================================
|
|
766
|
+
// 文件大小检查与降级
|
|
767
|
+
// ============================================================================
|
|
768
|
+
/** 企微语音消息仅支持 AMR 格式 */
|
|
769
|
+
const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
|
|
770
|
+
/**
|
|
771
|
+
* 检查文件大小并执行降级策略
|
|
772
|
+
*
|
|
773
|
+
* 降级规则:
|
|
774
|
+
* - voice 非 AMR 格式 → 降级为 file(企微后台仅支持 AMR)
|
|
775
|
+
* - image 超过 10MB → 降级为 file
|
|
776
|
+
* - video 超过 10MB → 降级为 file
|
|
777
|
+
* - voice 超过 2MB → 降级为 file
|
|
778
|
+
* - file 超过 20MB → 拒绝发送
|
|
779
|
+
*
|
|
780
|
+
* @param fileSize - 文件大小(字节)
|
|
781
|
+
* @param detectedType - 检测到的企微媒体类型
|
|
782
|
+
* @param contentType - 文件的 MIME 类型(用于语音格式校验)
|
|
783
|
+
* @returns 大小检查结果
|
|
784
|
+
*/
|
|
785
|
+
function applyFileSizeLimits(fileSize, detectedType, contentType) {
|
|
786
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
787
|
+
// 先检查绝对上限(20MB)
|
|
788
|
+
if (fileSize > ABSOLUTE_MAX_BYTES) {
|
|
789
|
+
return {
|
|
790
|
+
finalType: detectedType,
|
|
791
|
+
shouldReject: true,
|
|
792
|
+
rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
|
|
793
|
+
downgraded: false,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
// 按类型检查大小限制
|
|
797
|
+
switch (detectedType) {
|
|
798
|
+
case "image":
|
|
799
|
+
if (fileSize > IMAGE_MAX_BYTES) {
|
|
800
|
+
return {
|
|
801
|
+
finalType: "file",
|
|
802
|
+
shouldReject: false,
|
|
803
|
+
downgraded: true,
|
|
804
|
+
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
break;
|
|
808
|
+
case "video":
|
|
809
|
+
if (fileSize > VIDEO_MAX_BYTES) {
|
|
810
|
+
return {
|
|
811
|
+
finalType: "file",
|
|
812
|
+
shouldReject: false,
|
|
813
|
+
downgraded: true,
|
|
814
|
+
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
break;
|
|
818
|
+
case "voice":
|
|
819
|
+
// 企微语音消息仅支持 AMR 格式,非 AMR 一律降级为文件
|
|
820
|
+
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
|
|
821
|
+
return {
|
|
822
|
+
finalType: "file",
|
|
823
|
+
shouldReject: false,
|
|
824
|
+
downgraded: true,
|
|
825
|
+
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
if (fileSize > VOICE_MAX_BYTES) {
|
|
829
|
+
return {
|
|
830
|
+
finalType: "file",
|
|
831
|
+
shouldReject: false,
|
|
832
|
+
downgraded: true,
|
|
833
|
+
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
// 无需降级
|
|
839
|
+
return {
|
|
840
|
+
finalType: detectedType,
|
|
841
|
+
shouldReject: false,
|
|
842
|
+
downgraded: false,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
// ============================================================================
|
|
846
|
+
// 辅助函数
|
|
847
|
+
// ============================================================================
|
|
848
|
+
/**
|
|
849
|
+
* 从 URL/路径中提取文件名
|
|
850
|
+
*/
|
|
851
|
+
function extractFileName(mediaUrl, providedFileName, contentType) {
|
|
852
|
+
// 优先使用提供的文件名
|
|
853
|
+
if (providedFileName) {
|
|
854
|
+
return providedFileName;
|
|
855
|
+
}
|
|
856
|
+
// 尝试从 URL 中提取
|
|
857
|
+
try {
|
|
858
|
+
const urlObj = new URL(mediaUrl, "file://");
|
|
859
|
+
const pathParts = urlObj.pathname.split("/");
|
|
860
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
861
|
+
if (lastPart && lastPart.includes(".")) {
|
|
862
|
+
return decodeURIComponent(lastPart);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
catch {
|
|
866
|
+
// 尝试作为普通路径处理
|
|
867
|
+
const parts = mediaUrl.split("/");
|
|
868
|
+
const lastPart = parts[parts.length - 1];
|
|
869
|
+
if (lastPart && lastPart.includes(".")) {
|
|
870
|
+
return lastPart;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// 使用 MIME 类型生成默认文件名
|
|
874
|
+
const ext = mimeToExtension(contentType || "application/octet-stream");
|
|
875
|
+
return `media_${Date.now()}${ext}`;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* MIME 类型转文件扩展名
|
|
879
|
+
*/
|
|
880
|
+
function mimeToExtension(mime) {
|
|
881
|
+
const map = {
|
|
882
|
+
"image/jpeg": ".jpg",
|
|
883
|
+
"image/png": ".png",
|
|
884
|
+
"image/gif": ".gif",
|
|
885
|
+
"image/webp": ".webp",
|
|
886
|
+
"image/bmp": ".bmp",
|
|
887
|
+
"image/svg+xml": ".svg",
|
|
888
|
+
"video/mp4": ".mp4",
|
|
889
|
+
"video/quicktime": ".mov",
|
|
890
|
+
"video/x-msvideo": ".avi",
|
|
891
|
+
"video/webm": ".webm",
|
|
892
|
+
"audio/mpeg": ".mp3",
|
|
893
|
+
"audio/ogg": ".ogg",
|
|
894
|
+
"audio/wav": ".wav",
|
|
895
|
+
"audio/amr": ".amr",
|
|
896
|
+
"audio/aac": ".aac",
|
|
897
|
+
"application/pdf": ".pdf",
|
|
898
|
+
"application/zip": ".zip",
|
|
899
|
+
"application/msword": ".doc",
|
|
900
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
901
|
+
"application/vnd.ms-excel": ".xls",
|
|
902
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
903
|
+
"text/plain": ".txt",
|
|
904
|
+
};
|
|
905
|
+
return map[mime] || ".bin";
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* 公共媒体上传+发送流程
|
|
909
|
+
*
|
|
910
|
+
* 统一处理:resolveMediaFile → detectType → sizeCheck → uploadMedia → sendMediaMessage
|
|
911
|
+
* 媒体消息统一走 aibot_send_msg 主动发送,避免多文件场景下 reqId 只能用一次的问题。
|
|
912
|
+
* channel.ts 的 sendMedia 和 monitor.ts 的 deliver 回调都使用此函数。
|
|
913
|
+
*/
|
|
914
|
+
async function uploadAndSendMedia(options) {
|
|
915
|
+
const { wsClient, mediaUrl, chatId, mediaLocalRoots, log, errorLog } = options;
|
|
916
|
+
try {
|
|
917
|
+
// 1. 加载媒体文件
|
|
918
|
+
log?.(`[wecom] Uploading media: url=${mediaUrl}`);
|
|
919
|
+
const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
|
|
920
|
+
// 2. 检测企微媒体类型
|
|
921
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
922
|
+
// 3. 文件大小检查与降级策略
|
|
923
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
924
|
+
if (sizeCheck.shouldReject) {
|
|
925
|
+
errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
|
|
926
|
+
return {
|
|
927
|
+
ok: false,
|
|
928
|
+
rejected: true,
|
|
929
|
+
rejectReason: sizeCheck.rejectReason,
|
|
930
|
+
finalType: sizeCheck.finalType,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
const finalType = sizeCheck.finalType;
|
|
934
|
+
// 4. 分片上传获取 media_id
|
|
935
|
+
const uploadResult = await wsClient.uploadMedia(media.buffer, {
|
|
936
|
+
type: finalType,
|
|
937
|
+
filename: media.fileName,
|
|
938
|
+
});
|
|
939
|
+
log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
|
|
940
|
+
// 5. 统一通过 aibot_send_msg 主动发送媒体消息
|
|
941
|
+
const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
|
|
942
|
+
const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
|
|
943
|
+
log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
|
|
944
|
+
return {
|
|
945
|
+
ok: true,
|
|
946
|
+
messageId,
|
|
947
|
+
finalType,
|
|
948
|
+
downgraded: sizeCheck.downgraded,
|
|
949
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
catch (err) {
|
|
953
|
+
const errMsg = String(err);
|
|
954
|
+
errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
|
|
955
|
+
return {
|
|
956
|
+
ok: false,
|
|
957
|
+
error: errMsg,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
365
962
|
/**
|
|
366
963
|
* 企业微信群组访问控制模块
|
|
367
964
|
*
|
|
@@ -594,13 +1191,13 @@ function resolveStateDirFromEnv(env = process.env) {
|
|
|
594
1191
|
return stateOverride;
|
|
595
1192
|
}
|
|
596
1193
|
if (env.VITEST || env.NODE_ENV === "test") {
|
|
597
|
-
return
|
|
1194
|
+
return path__default$1.join(os__default.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
|
|
598
1195
|
}
|
|
599
|
-
return
|
|
1196
|
+
return path__default$1.join(os__default.homedir(), ".openclaw");
|
|
600
1197
|
}
|
|
601
1198
|
function resolveReqIdFilePath(accountId) {
|
|
602
1199
|
const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
603
|
-
return
|
|
1200
|
+
return path__default$1.join(resolveStateDirFromEnv(), "wecom", `reqid-map-${safe}.json`);
|
|
604
1201
|
}
|
|
605
1202
|
// ============================================================================
|
|
606
1203
|
// 核心实现
|
|
@@ -1010,8 +1607,8 @@ async function saveMcpConfigToPluginJson(config, runtime) {
|
|
|
1010
1607
|
// const pluginJsonPath = PLUGIN_JSON_PATH;
|
|
1011
1608
|
// 获取绝对路径并写入到配置文件
|
|
1012
1609
|
// const absolutePath = path.resolve(pluginJsonPath);
|
|
1013
|
-
const wecomConfigDir =
|
|
1014
|
-
const wecomConfigPath =
|
|
1610
|
+
const wecomConfigDir = path__default.join(os__default$1.homedir(), ".openclaw", "wecomConfig");
|
|
1611
|
+
const wecomConfigPath = path__default.join(wecomConfigDir, "config.json");
|
|
1015
1612
|
// // 写入 wecom 配置文件
|
|
1016
1613
|
// try {
|
|
1017
1614
|
// const lockOptions = {
|
|
@@ -1105,6 +1702,79 @@ async function fetchAndSaveMcpConfig(wsClient, accountId, runtime) {
|
|
|
1105
1702
|
* - state-manager.ts : 全局状态管理(带 TTL 清理)
|
|
1106
1703
|
* - timeout.ts : 超时工具
|
|
1107
1704
|
*/
|
|
1705
|
+
/**
|
|
1706
|
+
* 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
|
|
1707
|
+
* 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
|
|
1708
|
+
*/
|
|
1709
|
+
function stripThinkTags(text) {
|
|
1710
|
+
return text;
|
|
1711
|
+
// return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
1712
|
+
}
|
|
1713
|
+
// ============================================================================
|
|
1714
|
+
// 媒体本地路径白名单扩展
|
|
1715
|
+
// ============================================================================
|
|
1716
|
+
/**
|
|
1717
|
+
* 解析 openclaw 状态目录(与 plugin-sdk 内部逻辑保持一致)
|
|
1718
|
+
*/
|
|
1719
|
+
function resolveStateDir() {
|
|
1720
|
+
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
1721
|
+
if (stateOverride)
|
|
1722
|
+
return stateOverride;
|
|
1723
|
+
return path$1.join(os$1.homedir(), ".openclaw");
|
|
1724
|
+
}
|
|
1725
|
+
/**
|
|
1726
|
+
* 在 getDefaultMediaLocalRoots() 基础上,将 stateDir 本身也加入白名单,
|
|
1727
|
+
* 并合并用户在 WeComConfig 中配置的自定义 mediaLocalRoots。
|
|
1728
|
+
*
|
|
1729
|
+
* getDefaultMediaLocalRoots() 仅包含 stateDir 下的子目录(media/agents/workspace/sandboxes),
|
|
1730
|
+
* 但 agent 生成的文件可能直接放在 stateDir 根目录下(如 ~/.openclaw-dev/1.png),
|
|
1731
|
+
* 因此需要将 stateDir 本身也加入白名单以避免 LocalMediaAccessError。
|
|
1732
|
+
*
|
|
1733
|
+
* 用户可在 openclaw.json 中配置:
|
|
1734
|
+
* {
|
|
1735
|
+
* "channels": {
|
|
1736
|
+
* "wecom": {
|
|
1737
|
+
* "mediaLocalRoots": ["~/Downloads", "~/Documents"]
|
|
1738
|
+
* }
|
|
1739
|
+
* }
|
|
1740
|
+
* }
|
|
1741
|
+
*/
|
|
1742
|
+
async function getExtendedMediaLocalRoots(config) {
|
|
1743
|
+
// 从兼容层获取默认白名单(内部已处理低版本 SDK 的 fallback)
|
|
1744
|
+
const defaults = await getDefaultMediaLocalRoots();
|
|
1745
|
+
const roots = [...defaults];
|
|
1746
|
+
const stateDir = path$1.resolve(resolveStateDir());
|
|
1747
|
+
if (!roots.includes(stateDir)) {
|
|
1748
|
+
roots.push(stateDir);
|
|
1749
|
+
}
|
|
1750
|
+
// 合并用户在 WeComConfig 中配置的自定义路径
|
|
1751
|
+
if (config?.mediaLocalRoots) {
|
|
1752
|
+
for (const r of config.mediaLocalRoots) {
|
|
1753
|
+
const resolved = path$1.resolve(r.replace(/^~(?=\/|$)/, os$1.homedir()));
|
|
1754
|
+
if (!roots.includes(resolved)) {
|
|
1755
|
+
roots.push(resolved);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
return roots;
|
|
1760
|
+
}
|
|
1761
|
+
// ============================================================================
|
|
1762
|
+
// 媒体发送错误提示
|
|
1763
|
+
// ============================================================================
|
|
1764
|
+
/**
|
|
1765
|
+
* 根据媒体发送结果生成纯文本错误摘要(用于替换 thinking 流式消息展示给用户)。
|
|
1766
|
+
*
|
|
1767
|
+
* 使用纯文本而非 markdown 格式,因为 replyStream 只支持纯文本。
|
|
1768
|
+
*/
|
|
1769
|
+
function buildMediaErrorSummary(mediaUrl, result) {
|
|
1770
|
+
if (result.error?.includes("LocalMediaAccessError")) {
|
|
1771
|
+
return `⚠️ 文件发送失败:没有权限访问路径 ${mediaUrl}\n请在 openclaw.json 的 mediaLocalRoots 中添加该路径的父目录后重启生效。`;
|
|
1772
|
+
}
|
|
1773
|
+
if (result.rejectReason) {
|
|
1774
|
+
return `⚠️ 文件发送失败:${result.rejectReason}`;
|
|
1775
|
+
}
|
|
1776
|
+
return `⚠️ 文件发送失败:无法处理文件 ${mediaUrl},请稍后再试。`;
|
|
1777
|
+
}
|
|
1108
1778
|
// ============================================================================
|
|
1109
1779
|
// 消息上下文构建
|
|
1110
1780
|
// ============================================================================
|
|
@@ -1166,15 +1836,11 @@ function buildMessageContext(frame, account, config, text, mediaList, quoteConte
|
|
|
1166
1836
|
ReplyToBody: quoteContent,
|
|
1167
1837
|
});
|
|
1168
1838
|
}
|
|
1169
|
-
// ============================================================================
|
|
1170
|
-
// 消息处理和回复
|
|
1171
|
-
// ============================================================================
|
|
1172
1839
|
/**
|
|
1173
1840
|
* 发送"思考中"消息
|
|
1174
1841
|
*/
|
|
1175
1842
|
async function sendThinkingReply(params) {
|
|
1176
1843
|
const { wsClient, frame, streamId, runtime } = params;
|
|
1177
|
-
runtime.log?.(`[WeCom] Sending thinking message`);
|
|
1178
1844
|
try {
|
|
1179
1845
|
await sendWeComReply({
|
|
1180
1846
|
wsClient,
|
|
@@ -1186,15 +1852,105 @@ async function sendThinkingReply(params) {
|
|
|
1186
1852
|
});
|
|
1187
1853
|
}
|
|
1188
1854
|
catch (err) {
|
|
1189
|
-
runtime.error?.(`[
|
|
1855
|
+
runtime.error?.(`[wecom] Failed to send thinking message: ${String(err)}`);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* 累积文本并判断是否有可见内容(去除 <think> 标签后)
|
|
1860
|
+
*/
|
|
1861
|
+
function accumulateText(state, text) {
|
|
1862
|
+
state.accumulatedText += text;
|
|
1863
|
+
if (!state.hasText && stripThinkTags(state.accumulatedText)) {
|
|
1864
|
+
state.hasText = true;
|
|
1190
1865
|
}
|
|
1191
1866
|
}
|
|
1867
|
+
/**
|
|
1868
|
+
* 上传并发送一批媒体文件(统一走主动发送通道)
|
|
1869
|
+
*
|
|
1870
|
+
* replyMedia(被动回复)无法覆盖 replyStream 发出的 thinking 流式消息,
|
|
1871
|
+
* 因此所有媒体统一走 aibot_send_msg 主动发送。
|
|
1872
|
+
*/
|
|
1873
|
+
async function sendMediaBatch(ctx, mediaUrls) {
|
|
1874
|
+
const { wsClient, frame, state, account, runtime } = ctx;
|
|
1875
|
+
const body = frame.body;
|
|
1876
|
+
const chatId = body.chatid || body.from.userid;
|
|
1877
|
+
const mediaLocalRoots = await getExtendedMediaLocalRoots(account.config);
|
|
1878
|
+
runtime.log?.(`[wecom][debug] mediaLocalRoots=${JSON.stringify(mediaLocalRoots)}, mediaUrls=${JSON.stringify(mediaUrls)}, hasText=${!!state.hasText}`);
|
|
1879
|
+
for (const mediaUrl of mediaUrls) {
|
|
1880
|
+
const result = await uploadAndSendMedia({
|
|
1881
|
+
wsClient,
|
|
1882
|
+
mediaUrl,
|
|
1883
|
+
chatId,
|
|
1884
|
+
mediaLocalRoots,
|
|
1885
|
+
log: (...args) => runtime.log?.(...args),
|
|
1886
|
+
errorLog: (...args) => runtime.error?.(...args),
|
|
1887
|
+
});
|
|
1888
|
+
if (result.ok) {
|
|
1889
|
+
state.hasMedia = true;
|
|
1890
|
+
}
|
|
1891
|
+
else {
|
|
1892
|
+
state.hasMediaFailed = true;
|
|
1893
|
+
runtime.error?.(`[wecom] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
|
|
1894
|
+
// 收集错误摘要,后续在 finishThinkingStream 中直接替换 thinking 流展示给用户
|
|
1895
|
+
const summary = buildMediaErrorSummary(mediaUrl, result);
|
|
1896
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
1897
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
1898
|
+
: summary;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* 关闭 thinking 流(发送 finish=true 的流式消息)
|
|
1904
|
+
*
|
|
1905
|
+
* thinking 是通过 replyStream 用 streamId 发的流式消息,
|
|
1906
|
+
* 只有同一 streamId 的 replyStream(finish=true) 才能关闭它。
|
|
1907
|
+
*
|
|
1908
|
+
* ⚠️ 注意:企微会忽略空格等不可见内容,必须用有可见字符的文案才能真正
|
|
1909
|
+
* 替换掉 thinking 动画,否则 thinking 会一直残留。
|
|
1910
|
+
*
|
|
1911
|
+
* 关闭策略(按优先级):
|
|
1912
|
+
* 1. 有可见文本 → 用完整文本关闭
|
|
1913
|
+
* 2. 有媒体成功发送(通过 deliver 回调) → 用友好提示"文件已发送"
|
|
1914
|
+
* 3. 媒体发送失败 → 直接用错误摘要替换 thinking
|
|
1915
|
+
* 4. 其他 → 用通用"处理完成"提示
|
|
1916
|
+
* (agent 可能已通过内置 message 工具直接发送了文件,
|
|
1917
|
+
* 该路径走 outbound.sendMedia 完全绕过 deliver 回调,
|
|
1918
|
+
* 所以 state 中无记录,但文件已实际送达)
|
|
1919
|
+
*/
|
|
1920
|
+
async function finishThinkingStream(ctx) {
|
|
1921
|
+
const { wsClient, frame, state, runtime } = ctx;
|
|
1922
|
+
const visibleText = stripThinkTags(state.accumulatedText);
|
|
1923
|
+
let finishText;
|
|
1924
|
+
if (visibleText) {
|
|
1925
|
+
// 有可见文本:用完整文本关闭流(覆盖 thinking 为真实内容)
|
|
1926
|
+
finishText = state.accumulatedText;
|
|
1927
|
+
}
|
|
1928
|
+
else if (state.hasMedia) {
|
|
1929
|
+
// 媒体成功发送:用友好提示告知用户
|
|
1930
|
+
finishText = "📎 文件已发送,请查收。";
|
|
1931
|
+
}
|
|
1932
|
+
else if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
1933
|
+
// 媒体发送失败:直接用错误摘要替换 thinking 流(不再额外发 sendMessage)
|
|
1934
|
+
finishText = state.mediaErrorSummary;
|
|
1935
|
+
}
|
|
1936
|
+
else {
|
|
1937
|
+
// 核心无可见文本且 deliver 中未处理过媒体。
|
|
1938
|
+
//
|
|
1939
|
+
// 不使用错误提示,因为 agent 可能已通过内置 message 工具直接调用
|
|
1940
|
+
// outbound.sendMedia 成功发送了文件——该路径完全绕过 monitor 的 deliver
|
|
1941
|
+
// 回调,所以 state.deliverCalled / state.hasMedia 均为 false,但文件
|
|
1942
|
+
// 实际已送达用户。此时显示 "未生成回复" 会误导用户。
|
|
1943
|
+
finishText = "✅ 处理完成。";
|
|
1944
|
+
}
|
|
1945
|
+
await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
|
|
1946
|
+
}
|
|
1192
1947
|
/**
|
|
1193
1948
|
* 路由消息到核心处理流程并处理回复
|
|
1194
1949
|
*/
|
|
1195
1950
|
async function routeAndDispatchMessage(params) {
|
|
1196
|
-
const { ctxPayload, config, wsClient, frame, state, runtime, onCleanup } = params;
|
|
1951
|
+
const { ctxPayload, config, account, wsClient, frame, state, runtime, onCleanup } = params;
|
|
1197
1952
|
const core = getWeComRuntime();
|
|
1953
|
+
const ctx = { wsClient, frame, state, account, runtime };
|
|
1198
1954
|
// 防止 onCleanup 被多次调用(onError 回调与 catch 块可能重复触发)
|
|
1199
1955
|
let cleanedUp = false;
|
|
1200
1956
|
const safeCleanup = () => {
|
|
@@ -1209,37 +1965,54 @@ async function routeAndDispatchMessage(params) {
|
|
|
1209
1965
|
cfg: config,
|
|
1210
1966
|
dispatcherOptions: {
|
|
1211
1967
|
deliver: async (payload, info) => {
|
|
1212
|
-
state.
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1968
|
+
state.deliverCalled = true;
|
|
1969
|
+
runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, text=${payload.text ?? ''}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
|
|
1970
|
+
// 累积文本
|
|
1971
|
+
if (payload.text) {
|
|
1972
|
+
accumulateText(state, payload.text);
|
|
1973
|
+
}
|
|
1974
|
+
// 发送媒体(统一走主动发送)
|
|
1975
|
+
const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
1976
|
+
if (mediaUrls.length > 0) {
|
|
1977
|
+
try {
|
|
1978
|
+
await sendMediaBatch(ctx, mediaUrls);
|
|
1979
|
+
}
|
|
1980
|
+
catch (mediaErr) {
|
|
1981
|
+
// sendMediaBatch 内部异常(如 getDefaultMediaLocalRoots 不可用等)
|
|
1982
|
+
// 必须标记 state,否则 finishThinkingStream 会显示"处理完成"误导用户
|
|
1983
|
+
state.hasMediaFailed = true;
|
|
1984
|
+
const errMsg = String(mediaErr);
|
|
1985
|
+
const summary = `⚠️ 文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
|
|
1986
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
1987
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
1988
|
+
: summary;
|
|
1989
|
+
runtime.error?.(`[wecom] sendMediaBatch threw: ${errMsg}`);
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
// 中间帧:有可见文本时流式更新
|
|
1993
|
+
if (info.kind !== "final" && state.hasText && state.accumulatedText) {
|
|
1994
|
+
await sendWeComReply({ wsClient, frame, text: state.accumulatedText, runtime, finish: false, streamId: state.streamId });
|
|
1222
1995
|
}
|
|
1223
1996
|
},
|
|
1224
1997
|
onError: (err, info) => {
|
|
1225
|
-
runtime.error?.(`[
|
|
1226
|
-
// 仅记录错误,不立即 cleanup,让外层 try/catch 统一处理最终回复和 cleanup
|
|
1998
|
+
runtime.error?.(`[wecom] ${info.kind} reply failed: ${String(err)}`);
|
|
1227
1999
|
},
|
|
1228
2000
|
},
|
|
1229
2001
|
});
|
|
1230
|
-
//
|
|
1231
|
-
await
|
|
1232
|
-
wsClient,
|
|
1233
|
-
frame,
|
|
1234
|
-
text: state.accumulatedText,
|
|
1235
|
-
runtime,
|
|
1236
|
-
finish: true,
|
|
1237
|
-
streamId: state.streamId,
|
|
1238
|
-
});
|
|
2002
|
+
// 关闭 thinking 流
|
|
2003
|
+
await finishThinkingStream(ctx);
|
|
1239
2004
|
safeCleanup();
|
|
1240
2005
|
}
|
|
1241
2006
|
catch (err) {
|
|
1242
|
-
runtime.error?.(`[
|
|
2007
|
+
runtime.error?.(`[wecom][plugin] Failed to process message: ${String(err)}`);
|
|
2008
|
+
// 即使 dispatch 抛异常,也需要关闭 thinking 流,
|
|
2009
|
+
// 避免 deliver 已成功发送媒体但后续出错时 thinking 消息残留或被错误文案覆盖
|
|
2010
|
+
try {
|
|
2011
|
+
await finishThinkingStream(ctx);
|
|
2012
|
+
}
|
|
2013
|
+
catch (finishErr) {
|
|
2014
|
+
runtime.error?.(`[wecom] Failed to finish thinking stream after dispatch error: ${String(finishErr)}`);
|
|
2015
|
+
}
|
|
1243
2016
|
safeCleanup();
|
|
1244
2017
|
}
|
|
1245
2018
|
}
|
|
@@ -1274,14 +2047,13 @@ async function processWeComMessage(params) {
|
|
|
1274
2047
|
// 如果文本为空但存在引用消息,使用引用消息内容
|
|
1275
2048
|
if (!text && quoteContent) {
|
|
1276
2049
|
text = quoteContent;
|
|
1277
|
-
runtime.log?.("[
|
|
2050
|
+
runtime.log?.("[wecom][plugin] Using quote content as message body (user only mentioned bot)");
|
|
1278
2051
|
}
|
|
1279
2052
|
// 如果既没有文本也没有图片也没有文件也没有引用内容,则跳过
|
|
1280
2053
|
if (!text && imageUrls.length === 0 && fileUrls.length === 0) {
|
|
1281
|
-
runtime.log?.("[
|
|
2054
|
+
runtime.log?.("[wecom][plugin] Skipping empty message (no text, image, file or quote)");
|
|
1282
2055
|
return;
|
|
1283
2056
|
}
|
|
1284
|
-
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)` : ""}`);
|
|
1285
2057
|
// Step 2: 群组策略检查(仅群聊)
|
|
1286
2058
|
if (chatType === "group") {
|
|
1287
2059
|
const groupPolicyResult = checkGroupPolicy({
|
|
@@ -1342,10 +2114,12 @@ async function processWeComMessage(params) {
|
|
|
1342
2114
|
}
|
|
1343
2115
|
// Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
|
|
1344
2116
|
const ctxPayload = buildMessageContext(frame, account, config, text, mediaList, quoteContent);
|
|
2117
|
+
runtime.log?.(`[plugin -> openclaw] body=${text}, mediaPaths=${JSON.stringify(mediaList.map(m => m.path))}${quoteContent ? `, quote=${quoteContent}` : ''}`);
|
|
1345
2118
|
try {
|
|
1346
2119
|
await withTimeout(routeAndDispatchMessage({
|
|
1347
2120
|
ctxPayload,
|
|
1348
2121
|
config,
|
|
2122
|
+
account,
|
|
1349
2123
|
wsClient,
|
|
1350
2124
|
frame,
|
|
1351
2125
|
state,
|
|
@@ -1354,7 +2128,23 @@ async function processWeComMessage(params) {
|
|
|
1354
2128
|
}), MESSAGE_PROCESS_TIMEOUT_MS, `Message processing timed out (msgId=${messageId})`);
|
|
1355
2129
|
}
|
|
1356
2130
|
catch (err) {
|
|
1357
|
-
runtime.error?.(`[
|
|
2131
|
+
runtime.error?.(`[wecom][plugin] Message processing failed or timed out: ${String(err)}`);
|
|
2132
|
+
// 确保 thinking 流被关闭,防止异常/超时时 thinking 消息一直残留
|
|
2133
|
+
try {
|
|
2134
|
+
if (shouldSendThinking) {
|
|
2135
|
+
await sendWeComReply({
|
|
2136
|
+
wsClient,
|
|
2137
|
+
frame,
|
|
2138
|
+
text: "处理消息时出现异常,请稍后重试。",
|
|
2139
|
+
runtime,
|
|
2140
|
+
finish: true,
|
|
2141
|
+
streamId: state.streamId,
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
catch (finishErr) {
|
|
2146
|
+
runtime.error?.(`[wecom] Failed to finish thinking stream on error: ${String(finishErr)}`);
|
|
2147
|
+
}
|
|
1358
2148
|
cleanupState();
|
|
1359
2149
|
}
|
|
1360
2150
|
}
|
|
@@ -1644,7 +2434,6 @@ async function sendWeComMessage({ to, content, accountId, }) {
|
|
|
1644
2434
|
// 从 to 中提取 chatId(格式是 "${CHANNEL_ID}:chatId" 或直接是 chatId)
|
|
1645
2435
|
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
1646
2436
|
const chatId = to.replace(channelPrefix, "");
|
|
1647
|
-
console.log(`[WeCom] sendWeComMessage: ${JSON.stringify({ to, content, accountId })}`);
|
|
1648
2437
|
// 获取 WSClient 实例
|
|
1649
2438
|
const wsClient = getWeComWebSocket(resolvedAccountId);
|
|
1650
2439
|
if (!wsClient) {
|
|
@@ -1656,7 +2445,6 @@ async function sendWeComMessage({ to, content, accountId, }) {
|
|
|
1656
2445
|
markdown: { content },
|
|
1657
2446
|
});
|
|
1658
2447
|
const messageId = result?.headers?.req_id ?? `wecom-${Date.now()}`;
|
|
1659
|
-
console.log(`[WeCom] Sent message to ${chatId}, messageId=${messageId}`);
|
|
1660
2448
|
return {
|
|
1661
2449
|
channel: CHANNEL_ID,
|
|
1662
2450
|
messageId,
|
|
@@ -1689,7 +2477,7 @@ const wecomPlugin = {
|
|
|
1689
2477
|
// content: " pairing approved",
|
|
1690
2478
|
// accountId: cfg.accountId,
|
|
1691
2479
|
// });
|
|
1692
|
-
|
|
2480
|
+
// Pairing approved for user
|
|
1693
2481
|
},
|
|
1694
2482
|
},
|
|
1695
2483
|
onboarding: wecomOnboardingAdapter,
|
|
@@ -1816,14 +2604,51 @@ const wecomPlugin = {
|
|
|
1816
2604
|
deliveryMode: "gateway",
|
|
1817
2605
|
chunker: (text, limit) => getWeComRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
1818
2606
|
textChunkLimit: TEXT_CHUNK_LIMIT,
|
|
1819
|
-
sendText: async ({ to, text, accountId
|
|
1820
|
-
console.log(`[WeCom] sendText: ${JSON.stringify({ to, text, accountId, ...rest })}`);
|
|
2607
|
+
sendText: async ({ to, text, accountId }) => {
|
|
1821
2608
|
return sendWeComMessage({ to, content: text, accountId: accountId ?? undefined });
|
|
1822
2609
|
},
|
|
1823
|
-
sendMedia: async ({ to, text, mediaUrl,
|
|
1824
|
-
|
|
1825
|
-
const
|
|
1826
|
-
|
|
2610
|
+
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId }) => {
|
|
2611
|
+
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
2612
|
+
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
2613
|
+
const chatId = to.replace(channelPrefix, "");
|
|
2614
|
+
// 获取 WSClient 实例
|
|
2615
|
+
const wsClient = getWeComWebSocket(resolvedAccountId);
|
|
2616
|
+
if (!wsClient) {
|
|
2617
|
+
throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
|
|
2618
|
+
}
|
|
2619
|
+
// 如果没有 mediaUrl,fallback 为纯文本
|
|
2620
|
+
if (!mediaUrl) {
|
|
2621
|
+
return sendWeComMessage({ to, content: text || "", accountId: resolvedAccountId });
|
|
2622
|
+
}
|
|
2623
|
+
const result = await uploadAndSendMedia({
|
|
2624
|
+
wsClient,
|
|
2625
|
+
mediaUrl,
|
|
2626
|
+
chatId,
|
|
2627
|
+
mediaLocalRoots,
|
|
2628
|
+
});
|
|
2629
|
+
if (result.rejected) {
|
|
2630
|
+
return sendWeComMessage({ to, content: `⚠️ ${result.rejectReason}`, accountId: resolvedAccountId });
|
|
2631
|
+
}
|
|
2632
|
+
if (!result.ok) {
|
|
2633
|
+
// 上传/发送失败,fallback 为文本 + URL
|
|
2634
|
+
const fallbackContent = text
|
|
2635
|
+
? `${text}\n📎 ${mediaUrl}`
|
|
2636
|
+
: `📎 ${mediaUrl}`;
|
|
2637
|
+
return sendWeComMessage({ to, content: fallbackContent, accountId: resolvedAccountId });
|
|
2638
|
+
}
|
|
2639
|
+
// 如有伴随文本,额外发送一条 markdown
|
|
2640
|
+
if (text) {
|
|
2641
|
+
await sendWeComMessage({ to, content: text, accountId: resolvedAccountId });
|
|
2642
|
+
}
|
|
2643
|
+
// 如果有降级说明,额外发送提示
|
|
2644
|
+
if (result.downgradeNote) {
|
|
2645
|
+
await sendWeComMessage({ to, content: `ℹ️ ${result.downgradeNote}`, accountId: resolvedAccountId });
|
|
2646
|
+
}
|
|
2647
|
+
return {
|
|
2648
|
+
channel: CHANNEL_ID,
|
|
2649
|
+
messageId: result.messageId,
|
|
2650
|
+
chatId,
|
|
2651
|
+
};
|
|
1827
2652
|
},
|
|
1828
2653
|
},
|
|
1829
2654
|
status: {
|
|
@@ -1932,6 +2757,33 @@ const plugin = {
|
|
|
1932
2757
|
register(api) {
|
|
1933
2758
|
setWeComRuntime(api.runtime);
|
|
1934
2759
|
api.registerChannel({ plugin: wecomPlugin });
|
|
2760
|
+
// 注入媒体发送指令和文件大小限制提示词
|
|
2761
|
+
api.on("before_prompt_build", () => {
|
|
2762
|
+
return {
|
|
2763
|
+
appendSystemContext: [
|
|
2764
|
+
"【发送文件/图片/视频/语音】",
|
|
2765
|
+
"当你需要向用户发送文件、图片、视频或语音时,必须在回复中单独一行使用 MEDIA: 指令,后面跟文件的本地路径。",
|
|
2766
|
+
"格式:MEDIA: /文件的绝对路径",
|
|
2767
|
+
"文件优先存放到 ~/.openclaw 目录下,确保路径可访问。",
|
|
2768
|
+
"示例:",
|
|
2769
|
+
" MEDIA: ~/.openclaw/output.png",
|
|
2770
|
+
" MEDIA: ~/.openclaw/report.pdf",
|
|
2771
|
+
"系统会自动识别文件类型并发送给用户。",
|
|
2772
|
+
"",
|
|
2773
|
+
"注意事项:",
|
|
2774
|
+
"- MEDIA: 必须在行首,后面紧跟文件路径(不是 URL)",
|
|
2775
|
+
"- 如果路径中包含空格,可以用反引号包裹:MEDIA: `/path/to/my file.png`",
|
|
2776
|
+
"- 每个文件单独一行 MEDIA: 指令",
|
|
2777
|
+
"- 可以在 MEDIA: 指令前后附带文字说明",
|
|
2778
|
+
"",
|
|
2779
|
+
"【文件大小限制】",
|
|
2780
|
+
"- 图片不超过 10MB,视频不超过 10MB,语音不超过 2MB(仅支持 AMR 格式),文件不超过 20MB",
|
|
2781
|
+
"- 语音消息仅支持 AMR 格式(.amr),如需发送语音请确保文件为 AMR 格式",
|
|
2782
|
+
"- 超过大小限制的图片/视频/语音会被自动转为文件格式发送",
|
|
2783
|
+
"- 如果文件超过 20MB,将无法发送,请提前告知用户并尝试缩减文件大小",
|
|
2784
|
+
].join("\n"),
|
|
2785
|
+
};
|
|
2786
|
+
});
|
|
1935
2787
|
},
|
|
1936
2788
|
};
|
|
1937
2789
|
|