@lanmers/wecom-openclaw-plugin 1.0.12
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/README.md +220 -0
- package/dist/index.cjs.js +3591 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.esm.js +3565 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/const.d.ts +64 -0
- package/dist/src/dm-policy.d.ts +29 -0
- package/dist/src/group-policy.d.ts +29 -0
- package/dist/src/interface.d.ts +154 -0
- package/dist/src/mcp/index.d.ts +6 -0
- package/dist/src/mcp/schema.d.ts +11 -0
- package/dist/src/mcp/tool.d.ts +55 -0
- package/dist/src/mcp/transport.d.ts +61 -0
- package/dist/src/mcp-config.d.ts +29 -0
- package/dist/src/media-handler.d.ts +36 -0
- package/dist/src/media-uploader.d.ts +131 -0
- package/dist/src/message-parser.d.ts +72 -0
- package/dist/src/message-sender.d.ts +23 -0
- package/dist/src/monitor.d.ts +27 -0
- package/dist/src/onboarding.d.ts +5 -0
- package/dist/src/openclaw-compat.d.ts +48 -0
- package/dist/src/reqid-store.d.ts +31 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/state-manager.d.ts +76 -0
- package/dist/src/timeout.d.ts +20 -0
- package/dist/src/utils.d.ts +96 -0
- package/dist/src/version.d.ts +2 -0
- package/openclaw.plugin.json +14 -0
- package/package.json +73 -0
- package/skills/wecom-contact-lookup/SKILL.md +162 -0
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -0
- package/skills/wecom-doc-manager/SKILL.md +64 -0
- package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
- package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
- package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
- package/skills/wecom-edit-todo/SKILL.md +249 -0
- package/skills/wecom-get-todo-detail/SKILL.md +143 -0
- package/skills/wecom-get-todo-list/SKILL.md +127 -0
- package/skills/wecom-meeting-create/SKILL.md +158 -0
- package/skills/wecom-meeting-create/references/example-full.md +30 -0
- package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
- package/skills/wecom-meeting-create/references/example-security.md +22 -0
- package/skills/wecom-meeting-manage/SKILL.md +136 -0
- package/skills/wecom-meeting-query/SKILL.md +330 -0
- package/skills/wecom-preflight/SKILL.md +141 -0
- package/skills/wecom-schedule/SKILL.md +159 -0
- package/skills/wecom-schedule/references/api-check-availability.md +56 -0
- package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
- package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
- package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
- package/skills/wecom-schedule/references/ref-reminders.md +24 -0
- package/skills/wecom-smartsheet-data/SKILL.md +71 -0
- package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
- package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
|
@@ -0,0 +1,3591 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var pluginSdk = require('openclaw/plugin-sdk');
|
|
6
|
+
var os$1 = require('os');
|
|
7
|
+
var path$1 = require('path');
|
|
8
|
+
var aibotNodeSdk = require('@wecom/aibot-node-sdk');
|
|
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');
|
|
13
|
+
var fileType = require('file-type');
|
|
14
|
+
var node_fs = require('node:fs');
|
|
15
|
+
|
|
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
|
+
|
|
40
|
+
let runtime = null;
|
|
41
|
+
function setWeComRuntime(r) {
|
|
42
|
+
runtime = r;
|
|
43
|
+
}
|
|
44
|
+
function getWeComRuntime() {
|
|
45
|
+
if (!runtime) {
|
|
46
|
+
throw new Error("WeCom runtime not initialized - plugin not registered");
|
|
47
|
+
}
|
|
48
|
+
return runtime;
|
|
49
|
+
}
|
|
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
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 企业微信渠道常量定义
|
|
357
|
+
*/
|
|
358
|
+
/**
|
|
359
|
+
* 企业微信渠道 ID
|
|
360
|
+
*/
|
|
361
|
+
const CHANNEL_ID = "wecom";
|
|
362
|
+
/**
|
|
363
|
+
* 企业微信 WebSocket 命令枚举
|
|
364
|
+
*/
|
|
365
|
+
var WeComCommand;
|
|
366
|
+
(function (WeComCommand) {
|
|
367
|
+
/** 认证订阅 */
|
|
368
|
+
WeComCommand["SUBSCRIBE"] = "aibot_subscribe";
|
|
369
|
+
/** 心跳 */
|
|
370
|
+
WeComCommand["PING"] = "ping";
|
|
371
|
+
/** 企业微信推送消息 */
|
|
372
|
+
WeComCommand["AIBOT_CALLBACK"] = "aibot_callback";
|
|
373
|
+
/** clawdbot 响应消息 */
|
|
374
|
+
WeComCommand["AIBOT_RESPONSE"] = "aibot_response";
|
|
375
|
+
})(WeComCommand || (WeComCommand = {}));
|
|
376
|
+
// ============================================================================
|
|
377
|
+
// 超时和重试配置
|
|
378
|
+
// ============================================================================
|
|
379
|
+
/** 图片下载超时时间(毫秒) */
|
|
380
|
+
const IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
381
|
+
/** 文件下载超时时间(毫秒) */
|
|
382
|
+
const FILE_DOWNLOAD_TIMEOUT_MS = 60000;
|
|
383
|
+
/** 消息发送超时时间(毫秒) */
|
|
384
|
+
const REPLY_SEND_TIMEOUT_MS = 15000;
|
|
385
|
+
/** 消息处理总超时时间(毫秒) */
|
|
386
|
+
const MESSAGE_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
|
|
387
|
+
/** WebSocket 心跳间隔(毫秒) */
|
|
388
|
+
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
389
|
+
/** WebSocket 最大重连次数 */
|
|
390
|
+
const WS_MAX_RECONNECT_ATTEMPTS = 100;
|
|
391
|
+
// ============================================================================
|
|
392
|
+
// 消息状态管理配置
|
|
393
|
+
// ============================================================================
|
|
394
|
+
/** messageStates Map 条目的最大 TTL(毫秒),防止内存泄漏 */
|
|
395
|
+
const MESSAGE_STATE_TTL_MS = 10 * 60 * 1000;
|
|
396
|
+
/** messageStates Map 清理间隔(毫秒) */
|
|
397
|
+
const MESSAGE_STATE_CLEANUP_INTERVAL_MS = 60000;
|
|
398
|
+
/** messageStates Map 最大条目数 */
|
|
399
|
+
const MESSAGE_STATE_MAX_SIZE = 500;
|
|
400
|
+
// ============================================================================
|
|
401
|
+
// 消息模板
|
|
402
|
+
// ============================================================================
|
|
403
|
+
/** "思考中"流式消息占位内容 */
|
|
404
|
+
const THINKING_MESSAGE = "<think></think>";
|
|
405
|
+
/** 仅包含图片时的消息占位符 */
|
|
406
|
+
const MEDIA_IMAGE_PLACEHOLDER = "<media:image>";
|
|
407
|
+
/** 仅包含文件时的消息占位符 */
|
|
408
|
+
const MEDIA_DOCUMENT_PLACEHOLDER = "<media:document>";
|
|
409
|
+
// ============================================================================
|
|
410
|
+
// 默认值
|
|
411
|
+
// ============================================================================
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// MCP 配置
|
|
414
|
+
// ============================================================================
|
|
415
|
+
/** 获取 MCP 配置的 WebSocket 命令 */
|
|
416
|
+
const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
|
|
417
|
+
/** MCP 配置拉取超时时间(毫秒) */
|
|
418
|
+
const MCP_CONFIG_FETCH_TIMEOUT_MS = 15000;
|
|
419
|
+
// ============================================================================
|
|
420
|
+
// 默认值
|
|
421
|
+
// ============================================================================
|
|
422
|
+
/** 默认媒体大小上限(MB) */
|
|
423
|
+
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
424
|
+
/** 文本分块大小上限 */
|
|
425
|
+
const TEXT_CHUNK_LIMIT = 4000;
|
|
426
|
+
// ============================================================================
|
|
427
|
+
// 媒体上传相关常量
|
|
428
|
+
// ============================================================================
|
|
429
|
+
/** 图片大小上限(字节):10MB */
|
|
430
|
+
const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
431
|
+
/** 视频大小上限(字节):10MB */
|
|
432
|
+
const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
|
|
433
|
+
/** 语音大小上限(字节):2MB */
|
|
434
|
+
const VOICE_MAX_BYTES = 2 * 1024 * 1024;
|
|
435
|
+
/** 文件大小上限(字节):20MB */
|
|
436
|
+
const FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
437
|
+
/** 文件绝对上限(字节):超过此值无法发送,等于 FILE_MAX_BYTES */
|
|
438
|
+
const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 企业微信消息内容解析模块
|
|
442
|
+
*
|
|
443
|
+
* 负责从 WsFrame 中提取文本、图片、引用等内容
|
|
444
|
+
*/
|
|
445
|
+
// ============================================================================
|
|
446
|
+
// 解析函数
|
|
447
|
+
// ============================================================================
|
|
448
|
+
/**
|
|
449
|
+
* 解析消息内容(支持单条消息、图文混排和引用消息)
|
|
450
|
+
* @returns 提取的文本数组、图片URL数组和引用消息内容
|
|
451
|
+
*/
|
|
452
|
+
function parseMessageContent(body) {
|
|
453
|
+
const textParts = [];
|
|
454
|
+
const imageUrls = [];
|
|
455
|
+
const imageAesKeys = new Map();
|
|
456
|
+
const fileUrls = [];
|
|
457
|
+
const fileAesKeys = new Map();
|
|
458
|
+
let quoteContent;
|
|
459
|
+
// 处理图文混排消息
|
|
460
|
+
if (body.msgtype === "mixed" && body.mixed?.msg_item) {
|
|
461
|
+
for (const item of body.mixed.msg_item) {
|
|
462
|
+
if (item.msgtype === "text" && item.text?.content) {
|
|
463
|
+
textParts.push(item.text.content);
|
|
464
|
+
}
|
|
465
|
+
else if (item.msgtype === "image" && item.image?.url) {
|
|
466
|
+
imageUrls.push(item.image.url);
|
|
467
|
+
if (item.image.aeskey) {
|
|
468
|
+
imageAesKeys.set(item.image.url, item.image.aeskey);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
// 处理单条消息
|
|
475
|
+
if (body.text?.content) {
|
|
476
|
+
textParts.push(body.text.content);
|
|
477
|
+
}
|
|
478
|
+
// 处理语音消息(语音转文字后的文本内容)
|
|
479
|
+
if (body.msgtype === "voice" && body.voice?.content) {
|
|
480
|
+
textParts.push(body.voice.content);
|
|
481
|
+
}
|
|
482
|
+
if (body.image?.url) {
|
|
483
|
+
imageUrls.push(body.image.url);
|
|
484
|
+
if (body.image.aeskey) {
|
|
485
|
+
imageAesKeys.set(body.image.url, body.image.aeskey);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// 处理文件消息
|
|
489
|
+
if (body.msgtype === "file" && body.file?.url) {
|
|
490
|
+
fileUrls.push(body.file.url);
|
|
491
|
+
if (body.file.aeskey) {
|
|
492
|
+
fileAesKeys.set(body.file.url, body.file.aeskey);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// 处理引用消息
|
|
497
|
+
if (body.quote) {
|
|
498
|
+
if (body.quote.msgtype === "text" && body.quote.text?.content) {
|
|
499
|
+
quoteContent = body.quote.text.content;
|
|
500
|
+
}
|
|
501
|
+
else if (body.quote.msgtype === "voice" && body.quote.voice?.content) {
|
|
502
|
+
quoteContent = body.quote.voice.content;
|
|
503
|
+
}
|
|
504
|
+
else if (body.quote.msgtype === "image" && body.quote.image?.url) {
|
|
505
|
+
// 引用的图片消息:将图片 URL 加入下载列表
|
|
506
|
+
imageUrls.push(body.quote.image.url);
|
|
507
|
+
if (body.quote.image.aeskey) {
|
|
508
|
+
imageAesKeys.set(body.quote.image.url, body.quote.image.aeskey);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
else if (body.quote.msgtype === "file" && body.quote.file?.url) {
|
|
512
|
+
// 引用的文件消息:将文件 URL 加入下载列表
|
|
513
|
+
fileUrls.push(body.quote.file.url);
|
|
514
|
+
if (body.quote.file.aeskey) {
|
|
515
|
+
fileAesKeys.set(body.quote.file.url, body.quote.file.aeskey);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 超时控制工具模块
|
|
524
|
+
*
|
|
525
|
+
* 为异步操作提供统一的超时保护机制
|
|
526
|
+
*/
|
|
527
|
+
/**
|
|
528
|
+
* 为 Promise 添加超时保护
|
|
529
|
+
*
|
|
530
|
+
* @param promise - 原始 Promise
|
|
531
|
+
* @param timeoutMs - 超时时间(毫秒)
|
|
532
|
+
* @param message - 超时错误消息
|
|
533
|
+
* @returns 带超时保护的 Promise
|
|
534
|
+
*/
|
|
535
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
536
|
+
if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
|
|
537
|
+
return promise;
|
|
538
|
+
}
|
|
539
|
+
let timeoutId;
|
|
540
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
541
|
+
timeoutId = setTimeout(() => {
|
|
542
|
+
reject(new TimeoutError(message ?? `Operation timed out after ${timeoutMs}ms`));
|
|
543
|
+
}, timeoutMs);
|
|
544
|
+
});
|
|
545
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
546
|
+
clearTimeout(timeoutId);
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* 超时错误类型
|
|
551
|
+
*/
|
|
552
|
+
class TimeoutError extends Error {
|
|
553
|
+
constructor(message) {
|
|
554
|
+
super(message);
|
|
555
|
+
this.name = "TimeoutError";
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* 企业微信消息发送模块
|
|
561
|
+
*
|
|
562
|
+
* 负责通过 WSClient 发送回复消息,包含超时保护
|
|
563
|
+
*/
|
|
564
|
+
// ============================================================================
|
|
565
|
+
// 消息发送
|
|
566
|
+
// ============================================================================
|
|
567
|
+
/**
|
|
568
|
+
* 发送企业微信回复消息
|
|
569
|
+
* 供 monitor 内部和 channel outbound 使用
|
|
570
|
+
*
|
|
571
|
+
* @returns messageId (streamId)
|
|
572
|
+
*/
|
|
573
|
+
async function sendWeComReply(params) {
|
|
574
|
+
const { wsClient, frame, text, runtime, finish = true, streamId: existingStreamId } = params;
|
|
575
|
+
if (!text) {
|
|
576
|
+
return "";
|
|
577
|
+
}
|
|
578
|
+
const streamId = existingStreamId || aibotNodeSdk.generateReqId("stream");
|
|
579
|
+
if (!wsClient.isConnected) {
|
|
580
|
+
runtime.error?.(`[wecom] WSClient not connected, cannot send reply`);
|
|
581
|
+
throw new Error("WSClient not connected");
|
|
582
|
+
}
|
|
583
|
+
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
|
|
584
|
+
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
|
|
585
|
+
runtime.log?.(`[plugin -> server] streamId=${streamId}, finish=${finish}`);
|
|
586
|
+
return streamId;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* 企业微信媒体(图片)下载和保存模块
|
|
591
|
+
*
|
|
592
|
+
* 负责下载、检测格式、保存图片到本地,包含超时保护
|
|
593
|
+
*/
|
|
594
|
+
// ============================================================================
|
|
595
|
+
// 图片格式检测辅助函数(基于 file-type 包)
|
|
596
|
+
// ============================================================================
|
|
597
|
+
/**
|
|
598
|
+
* 检查 Buffer 是否为有效的图片格式
|
|
599
|
+
*/
|
|
600
|
+
async function isImageBuffer(data) {
|
|
601
|
+
const type = await fileType.fileTypeFromBuffer(data);
|
|
602
|
+
return type?.mime.startsWith("image/") ?? false;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* 检测 Buffer 的图片内容类型
|
|
606
|
+
*/
|
|
607
|
+
async function detectImageContentType(data) {
|
|
608
|
+
const type = await fileType.fileTypeFromBuffer(data);
|
|
609
|
+
if (type?.mime.startsWith("image/")) {
|
|
610
|
+
return type.mime;
|
|
611
|
+
}
|
|
612
|
+
return "application/octet-stream";
|
|
613
|
+
}
|
|
614
|
+
// ============================================================================
|
|
615
|
+
// 图片下载和保存
|
|
616
|
+
// ============================================================================
|
|
617
|
+
/**
|
|
618
|
+
* 下载并保存所有图片到本地,每张图片的下载带超时保护
|
|
619
|
+
*/
|
|
620
|
+
async function downloadAndSaveImages(params) {
|
|
621
|
+
const { imageUrls, config, runtime, wsClient } = params;
|
|
622
|
+
const core = getWeComRuntime();
|
|
623
|
+
const mediaList = [];
|
|
624
|
+
for (const imageUrl of imageUrls) {
|
|
625
|
+
try {
|
|
626
|
+
runtime.log?.(`[wecom] Downloading image: url=${imageUrl}`);
|
|
627
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
628
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
629
|
+
let imageBuffer;
|
|
630
|
+
let imageContentType;
|
|
631
|
+
let originalFilename;
|
|
632
|
+
const imageAesKey = params.imageAesKeys?.get(imageUrl);
|
|
633
|
+
try {
|
|
634
|
+
// 优先使用 SDK 的 downloadFile 方法下载(带超时保护)
|
|
635
|
+
const result = await withTimeout(wsClient.downloadFile(imageUrl, imageAesKey), IMAGE_DOWNLOAD_TIMEOUT_MS, `Image download timed out: ${imageUrl}`);
|
|
636
|
+
imageBuffer = result.buffer;
|
|
637
|
+
originalFilename = result.filename;
|
|
638
|
+
imageContentType = await detectImageContentType(imageBuffer);
|
|
639
|
+
runtime.log?.(`[wecom] Image downloaded: size=${imageBuffer.length}, contentType=${imageContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
640
|
+
}
|
|
641
|
+
catch (sdkError) {
|
|
642
|
+
// 如果 SDK 方法失败,回退到原有方式(带超时保护)
|
|
643
|
+
runtime.log?.(`[wecom] SDK download failed, fallback: ${String(sdkError)}`);
|
|
644
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
|
|
645
|
+
runtime.log?.(`[wecom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
646
|
+
imageBuffer = fetched.buffer;
|
|
647
|
+
imageContentType = fetched.contentType ?? "application/octet-stream";
|
|
648
|
+
const isValidImage = await isImageBuffer(fetched.buffer);
|
|
649
|
+
if (!isValidImage) {
|
|
650
|
+
runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
|
|
654
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
655
|
+
runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return mediaList;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* 下载并保存所有文件到本地,每个文件的下载带超时保护
|
|
665
|
+
*/
|
|
666
|
+
async function downloadAndSaveFiles(params) {
|
|
667
|
+
const { fileUrls, config, runtime, wsClient } = params;
|
|
668
|
+
const core = getWeComRuntime();
|
|
669
|
+
const mediaList = [];
|
|
670
|
+
for (const fileUrl of fileUrls) {
|
|
671
|
+
try {
|
|
672
|
+
runtime.log?.(`[wecom] Downloading file: url=${fileUrl}`);
|
|
673
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
674
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
675
|
+
let fileBuffer;
|
|
676
|
+
let fileContentType;
|
|
677
|
+
let originalFilename;
|
|
678
|
+
const fileAesKey = params.fileAesKeys?.get(fileUrl);
|
|
679
|
+
try {
|
|
680
|
+
// 使用 SDK 的 downloadFile 方法下载(带超时保护)
|
|
681
|
+
const result = await withTimeout(wsClient.downloadFile(fileUrl, fileAesKey), FILE_DOWNLOAD_TIMEOUT_MS, `File download timed out: ${fileUrl}`);
|
|
682
|
+
fileBuffer = result.buffer;
|
|
683
|
+
originalFilename = result.filename;
|
|
684
|
+
// 检测文件类型
|
|
685
|
+
const type = await fileType.fileTypeFromBuffer(fileBuffer);
|
|
686
|
+
fileContentType = type?.mime ?? "application/octet-stream";
|
|
687
|
+
runtime.log?.(`[wecom] File downloaded: size=${fileBuffer.length}, contentType=${fileContentType}, filename=${originalFilename ?? '(none)'}`);
|
|
688
|
+
}
|
|
689
|
+
catch (sdkError) {
|
|
690
|
+
// 如果 SDK 方法失败,回退到 fetchRemoteMedia(带超时保护)
|
|
691
|
+
runtime.log?.(`[wecom] SDK file download failed, fallback: ${String(sdkError)}`);
|
|
692
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
|
|
693
|
+
runtime.log?.(`[wecom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
694
|
+
fileBuffer = fetched.buffer;
|
|
695
|
+
fileContentType = fetched.contentType ?? "application/octet-stream";
|
|
696
|
+
}
|
|
697
|
+
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
|
|
698
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
699
|
+
runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return mediaList;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 企业微信出站媒体上传工具模块
|
|
710
|
+
*
|
|
711
|
+
* 负责:
|
|
712
|
+
* - 从 mediaUrl 加载文件 buffer(远程 URL 或本地路径均支持)
|
|
713
|
+
* - 检测 MIME 类型并映射为企微媒体类型
|
|
714
|
+
* - 文件大小检查与降级策略
|
|
715
|
+
*/
|
|
716
|
+
// ============================================================================
|
|
717
|
+
// MIME → 企微媒体类型映射
|
|
718
|
+
// ============================================================================
|
|
719
|
+
/**
|
|
720
|
+
* 根据 MIME 类型检测企微媒体类型
|
|
721
|
+
*
|
|
722
|
+
* @param mimeType - MIME 类型字符串
|
|
723
|
+
* @returns 企微媒体类型
|
|
724
|
+
*/
|
|
725
|
+
function detectWeComMediaType(mimeType) {
|
|
726
|
+
const mime = mimeType.toLowerCase();
|
|
727
|
+
// 图片类型
|
|
728
|
+
if (mime.startsWith("image/")) {
|
|
729
|
+
return "image";
|
|
730
|
+
}
|
|
731
|
+
// 视频类型
|
|
732
|
+
if (mime.startsWith("video/")) {
|
|
733
|
+
return "video";
|
|
734
|
+
}
|
|
735
|
+
// 语音类型
|
|
736
|
+
if (mime.startsWith("audio/") ||
|
|
737
|
+
mime === "application/ogg" // OGG 音频容器
|
|
738
|
+
) {
|
|
739
|
+
return "voice";
|
|
740
|
+
}
|
|
741
|
+
// 其他类型默认为文件
|
|
742
|
+
return "file";
|
|
743
|
+
}
|
|
744
|
+
// ============================================================================
|
|
745
|
+
// 媒体文件加载
|
|
746
|
+
// ============================================================================
|
|
747
|
+
/**
|
|
748
|
+
* 从 mediaUrl 加载媒体文件
|
|
749
|
+
*
|
|
750
|
+
* 支持远程 URL(http/https)和本地路径(file:// 或绝对路径),
|
|
751
|
+
* 利用 openclaw plugin-sdk 的 loadOutboundMediaFromUrl 统一处理。
|
|
752
|
+
*
|
|
753
|
+
* @param mediaUrl - 媒体文件的 URL 或本地路径
|
|
754
|
+
* @param mediaLocalRoots - 允许读取本地文件的安全白名单目录
|
|
755
|
+
* @returns 解析后的媒体文件信息
|
|
756
|
+
*/
|
|
757
|
+
async function resolveMediaFile(mediaUrl, mediaLocalRoots) {
|
|
758
|
+
// 使用兼容层加载媒体文件(优先 SDK,不可用时 fallback)
|
|
759
|
+
// 传入足够大的 maxBytes,由我们自己在后续步骤做大小检查
|
|
760
|
+
const result = await loadOutboundMediaFromUrl(mediaUrl, {
|
|
761
|
+
maxBytes: ABSOLUTE_MAX_BYTES,
|
|
762
|
+
mediaLocalRoots,
|
|
763
|
+
});
|
|
764
|
+
if (!result.buffer || result.buffer.length === 0) {
|
|
765
|
+
throw new Error(`Failed to load media from ${mediaUrl}: empty buffer`);
|
|
766
|
+
}
|
|
767
|
+
// 检测真实 MIME 类型
|
|
768
|
+
let contentType = result.contentType || "application/octet-stream";
|
|
769
|
+
// 如果没有返回准确的 contentType,尝试通过 buffer 魔术字节检测
|
|
770
|
+
if (contentType === "application/octet-stream" ||
|
|
771
|
+
contentType === "text/plain") {
|
|
772
|
+
const detected = await detectMime(result.buffer);
|
|
773
|
+
if (detected) {
|
|
774
|
+
contentType = detected;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// 提取文件名
|
|
778
|
+
const fileName = extractFileName(mediaUrl, result.fileName, contentType);
|
|
779
|
+
return {
|
|
780
|
+
buffer: result.buffer,
|
|
781
|
+
contentType,
|
|
782
|
+
fileName,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
// ============================================================================
|
|
786
|
+
// 文件大小检查与降级
|
|
787
|
+
// ============================================================================
|
|
788
|
+
/** 企微语音消息仅支持 AMR 格式 */
|
|
789
|
+
const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
|
|
790
|
+
/**
|
|
791
|
+
* 检查文件大小并执行降级策略
|
|
792
|
+
*
|
|
793
|
+
* 降级规则:
|
|
794
|
+
* - voice 非 AMR 格式 → 降级为 file(企微后台仅支持 AMR)
|
|
795
|
+
* - image 超过 10MB → 降级为 file
|
|
796
|
+
* - video 超过 10MB → 降级为 file
|
|
797
|
+
* - voice 超过 2MB → 降级为 file
|
|
798
|
+
* - file 超过 20MB → 拒绝发送
|
|
799
|
+
*
|
|
800
|
+
* @param fileSize - 文件大小(字节)
|
|
801
|
+
* @param detectedType - 检测到的企微媒体类型
|
|
802
|
+
* @param contentType - 文件的 MIME 类型(用于语音格式校验)
|
|
803
|
+
* @returns 大小检查结果
|
|
804
|
+
*/
|
|
805
|
+
function applyFileSizeLimits(fileSize, detectedType, contentType) {
|
|
806
|
+
const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
|
|
807
|
+
// 先检查绝对上限(20MB)
|
|
808
|
+
if (fileSize > ABSOLUTE_MAX_BYTES) {
|
|
809
|
+
return {
|
|
810
|
+
finalType: detectedType,
|
|
811
|
+
shouldReject: true,
|
|
812
|
+
rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。请尝试压缩文件或减小文件大小。`,
|
|
813
|
+
downgraded: false,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
// 按类型检查大小限制
|
|
817
|
+
switch (detectedType) {
|
|
818
|
+
case "image":
|
|
819
|
+
if (fileSize > IMAGE_MAX_BYTES) {
|
|
820
|
+
return {
|
|
821
|
+
finalType: "file",
|
|
822
|
+
shouldReject: false,
|
|
823
|
+
downgraded: true,
|
|
824
|
+
downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
break;
|
|
828
|
+
case "video":
|
|
829
|
+
if (fileSize > VIDEO_MAX_BYTES) {
|
|
830
|
+
return {
|
|
831
|
+
finalType: "file",
|
|
832
|
+
shouldReject: false,
|
|
833
|
+
downgraded: true,
|
|
834
|
+
downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
break;
|
|
838
|
+
case "voice":
|
|
839
|
+
// 企微语音消息仅支持 AMR 格式,非 AMR 一律降级为文件
|
|
840
|
+
if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
|
|
841
|
+
return {
|
|
842
|
+
finalType: "file",
|
|
843
|
+
shouldReject: false,
|
|
844
|
+
downgraded: true,
|
|
845
|
+
downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
if (fileSize > VOICE_MAX_BYTES) {
|
|
849
|
+
return {
|
|
850
|
+
finalType: "file",
|
|
851
|
+
shouldReject: false,
|
|
852
|
+
downgraded: true,
|
|
853
|
+
downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
// 无需降级
|
|
859
|
+
return {
|
|
860
|
+
finalType: detectedType,
|
|
861
|
+
shouldReject: false,
|
|
862
|
+
downgraded: false,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
// ============================================================================
|
|
866
|
+
// 辅助函数
|
|
867
|
+
// ============================================================================
|
|
868
|
+
/**
|
|
869
|
+
* 从 URL/路径中提取文件名
|
|
870
|
+
*/
|
|
871
|
+
function extractFileName(mediaUrl, providedFileName, contentType) {
|
|
872
|
+
// 优先使用提供的文件名
|
|
873
|
+
if (providedFileName) {
|
|
874
|
+
return providedFileName;
|
|
875
|
+
}
|
|
876
|
+
// 尝试从 URL 中提取
|
|
877
|
+
try {
|
|
878
|
+
const urlObj = new URL(mediaUrl, "file://");
|
|
879
|
+
const pathParts = urlObj.pathname.split("/");
|
|
880
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
881
|
+
if (lastPart && lastPart.includes(".")) {
|
|
882
|
+
return decodeURIComponent(lastPart);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
catch {
|
|
886
|
+
// 尝试作为普通路径处理
|
|
887
|
+
const parts = mediaUrl.split("/");
|
|
888
|
+
const lastPart = parts[parts.length - 1];
|
|
889
|
+
if (lastPart && lastPart.includes(".")) {
|
|
890
|
+
return lastPart;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// 使用 MIME 类型生成默认文件名
|
|
894
|
+
const ext = mimeToExtension(contentType || "application/octet-stream");
|
|
895
|
+
return `media_${Date.now()}${ext}`;
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* MIME 类型转文件扩展名
|
|
899
|
+
*/
|
|
900
|
+
function mimeToExtension(mime) {
|
|
901
|
+
const map = {
|
|
902
|
+
"image/jpeg": ".jpg",
|
|
903
|
+
"image/png": ".png",
|
|
904
|
+
"image/gif": ".gif",
|
|
905
|
+
"image/webp": ".webp",
|
|
906
|
+
"image/bmp": ".bmp",
|
|
907
|
+
"image/svg+xml": ".svg",
|
|
908
|
+
"video/mp4": ".mp4",
|
|
909
|
+
"video/quicktime": ".mov",
|
|
910
|
+
"video/x-msvideo": ".avi",
|
|
911
|
+
"video/webm": ".webm",
|
|
912
|
+
"audio/mpeg": ".mp3",
|
|
913
|
+
"audio/ogg": ".ogg",
|
|
914
|
+
"audio/wav": ".wav",
|
|
915
|
+
"audio/amr": ".amr",
|
|
916
|
+
"audio/aac": ".aac",
|
|
917
|
+
"application/pdf": ".pdf",
|
|
918
|
+
"application/zip": ".zip",
|
|
919
|
+
"application/msword": ".doc",
|
|
920
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
921
|
+
"application/vnd.ms-excel": ".xls",
|
|
922
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
923
|
+
"text/plain": ".txt",
|
|
924
|
+
};
|
|
925
|
+
return map[mime] || ".bin";
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* 公共媒体上传+发送流程
|
|
929
|
+
*
|
|
930
|
+
* 统一处理:resolveMediaFile → detectType → sizeCheck → uploadMedia → sendMediaMessage
|
|
931
|
+
* 媒体消息统一走 aibot_send_msg 主动发送,避免多文件场景下 reqId 只能用一次的问题。
|
|
932
|
+
* channel.ts 的 sendMedia 和 monitor.ts 的 deliver 回调都使用此函数。
|
|
933
|
+
*/
|
|
934
|
+
async function uploadAndSendMedia(options) {
|
|
935
|
+
const { wsClient, mediaUrl, chatId, mediaLocalRoots, log, errorLog } = options;
|
|
936
|
+
try {
|
|
937
|
+
// 1. 加载媒体文件
|
|
938
|
+
log?.(`[wecom] Uploading media: url=${mediaUrl}`);
|
|
939
|
+
const media = await resolveMediaFile(mediaUrl, mediaLocalRoots);
|
|
940
|
+
// 2. 检测企微媒体类型
|
|
941
|
+
const detectedType = detectWeComMediaType(media.contentType);
|
|
942
|
+
// 3. 文件大小检查与降级策略
|
|
943
|
+
const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
|
|
944
|
+
if (sizeCheck.shouldReject) {
|
|
945
|
+
errorLog?.(`[wecom] Media rejected: ${sizeCheck.rejectReason}`);
|
|
946
|
+
return {
|
|
947
|
+
ok: false,
|
|
948
|
+
rejected: true,
|
|
949
|
+
rejectReason: sizeCheck.rejectReason,
|
|
950
|
+
finalType: sizeCheck.finalType,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
const finalType = sizeCheck.finalType;
|
|
954
|
+
// 4. 分片上传获取 media_id
|
|
955
|
+
const uploadResult = await wsClient.uploadMedia(media.buffer, {
|
|
956
|
+
type: finalType,
|
|
957
|
+
filename: media.fileName,
|
|
958
|
+
});
|
|
959
|
+
log?.(`[wecom] Media uploaded: media_id=${uploadResult.media_id}, type=${finalType}`);
|
|
960
|
+
// 5. 统一通过 aibot_send_msg 主动发送媒体消息
|
|
961
|
+
const result = await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id);
|
|
962
|
+
const messageId = result?.headers?.req_id ?? `wecom-media-${Date.now()}`;
|
|
963
|
+
log?.(`[wecom] Media sent via sendMediaMessage: chatId=${chatId}, type=${finalType}`);
|
|
964
|
+
return {
|
|
965
|
+
ok: true,
|
|
966
|
+
messageId,
|
|
967
|
+
finalType,
|
|
968
|
+
downgraded: sizeCheck.downgraded,
|
|
969
|
+
downgradeNote: sizeCheck.downgradeNote,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
catch (err) {
|
|
973
|
+
const errMsg = String(err);
|
|
974
|
+
errorLog?.(`[wecom] Failed to upload/send media: url=${mediaUrl}, error=${errMsg}`);
|
|
975
|
+
return {
|
|
976
|
+
ok: false,
|
|
977
|
+
error: errMsg,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* 企业微信群组访问控制模块
|
|
984
|
+
*
|
|
985
|
+
* 负责群组策略检查(groupPolicy、群组白名单、群内发送者白名单)
|
|
986
|
+
*/
|
|
987
|
+
// ============================================================================
|
|
988
|
+
// 内部辅助函数
|
|
989
|
+
// ============================================================================
|
|
990
|
+
/**
|
|
991
|
+
* 解析企业微信群组配置
|
|
992
|
+
*/
|
|
993
|
+
function resolveWeComGroupConfig(params) {
|
|
994
|
+
const groups = params.cfg?.groups ?? {};
|
|
995
|
+
const wildcard = groups["*"];
|
|
996
|
+
const groupId = params.groupId?.trim();
|
|
997
|
+
if (!groupId) {
|
|
998
|
+
return undefined;
|
|
999
|
+
}
|
|
1000
|
+
const direct = groups[groupId];
|
|
1001
|
+
if (direct) {
|
|
1002
|
+
return direct;
|
|
1003
|
+
}
|
|
1004
|
+
const lowered = groupId.toLowerCase();
|
|
1005
|
+
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
|
|
1006
|
+
if (matchKey) {
|
|
1007
|
+
return groups[matchKey];
|
|
1008
|
+
}
|
|
1009
|
+
return wildcard;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* 检查群组是否在允许列表中
|
|
1013
|
+
*/
|
|
1014
|
+
function isWeComGroupAllowed(params) {
|
|
1015
|
+
const { groupPolicy } = params;
|
|
1016
|
+
if (groupPolicy === "disabled") {
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
if (groupPolicy === "open") {
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
// allowlist 模式:检查群组是否在允许列表中
|
|
1023
|
+
const normalizedAllowFrom = params.allowFrom.map((entry) => String(entry).replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim());
|
|
1024
|
+
if (normalizedAllowFrom.includes("*")) {
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
const normalizedGroupId = params.groupId.trim();
|
|
1028
|
+
return normalizedAllowFrom.some((entry) => entry === normalizedGroupId || entry.toLowerCase() === normalizedGroupId.toLowerCase());
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* 检查群组内发送者是否在允许列表中
|
|
1032
|
+
*/
|
|
1033
|
+
function isGroupSenderAllowed(params) {
|
|
1034
|
+
const { senderId, groupId, wecomConfig } = params;
|
|
1035
|
+
const groupConfig = resolveWeComGroupConfig({
|
|
1036
|
+
cfg: wecomConfig,
|
|
1037
|
+
groupId,
|
|
1038
|
+
});
|
|
1039
|
+
const perGroupSenderAllowFrom = (groupConfig?.allowFrom ?? []).map((v) => String(v));
|
|
1040
|
+
if (perGroupSenderAllowFrom.length === 0) {
|
|
1041
|
+
return true;
|
|
1042
|
+
}
|
|
1043
|
+
if (perGroupSenderAllowFrom.includes("*")) {
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
return perGroupSenderAllowFrom.some((entry) => {
|
|
1047
|
+
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
|
|
1048
|
+
return normalized === senderId || normalized === `user:${senderId}`;
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
// ============================================================================
|
|
1052
|
+
// 公开 API
|
|
1053
|
+
// ============================================================================
|
|
1054
|
+
/**
|
|
1055
|
+
* 检查群组策略访问控制
|
|
1056
|
+
* @returns 检查结果,包含是否允许继续处理
|
|
1057
|
+
*/
|
|
1058
|
+
function checkGroupPolicy(params) {
|
|
1059
|
+
const { chatId, senderId, account, config, runtime } = params;
|
|
1060
|
+
const wecomConfig = (config.channels?.[CHANNEL_ID] ?? {});
|
|
1061
|
+
const defaultGroupPolicy = config.channels?.[CHANNEL_ID]?.groupPolicy;
|
|
1062
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
|
|
1063
|
+
// const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
|
1064
|
+
// providerConfigPresent: config.channels?.[CHANNEL_ID] !== undefined,
|
|
1065
|
+
// groupPolicy: wecomConfig.groupPolicy,
|
|
1066
|
+
// defaultGroupPolicy,
|
|
1067
|
+
// });
|
|
1068
|
+
// warnMissingProviderGroupPolicyFallbackOnce({
|
|
1069
|
+
// providerMissingFallbackApplied,
|
|
1070
|
+
// providerKey: CHANNEL_ID,
|
|
1071
|
+
// accountId: account.accountId,
|
|
1072
|
+
// log: (msg) => runtime.log?.(msg),
|
|
1073
|
+
// });
|
|
1074
|
+
const groupAllowFrom = wecomConfig.groupAllowFrom ?? [];
|
|
1075
|
+
const groupAllowed = isWeComGroupAllowed({
|
|
1076
|
+
groupPolicy,
|
|
1077
|
+
allowFrom: groupAllowFrom,
|
|
1078
|
+
groupId: chatId,
|
|
1079
|
+
});
|
|
1080
|
+
if (!groupAllowed) {
|
|
1081
|
+
runtime.log?.(`[WeCom] Group ${chatId} not allowed (groupPolicy=${groupPolicy})`);
|
|
1082
|
+
return { allowed: false };
|
|
1083
|
+
}
|
|
1084
|
+
const senderAllowed = isGroupSenderAllowed({
|
|
1085
|
+
senderId,
|
|
1086
|
+
groupId: chatId,
|
|
1087
|
+
wecomConfig,
|
|
1088
|
+
});
|
|
1089
|
+
if (!senderAllowed) {
|
|
1090
|
+
runtime.log?.(`[WeCom] Sender ${senderId} not in group ${chatId} sender allowlist`);
|
|
1091
|
+
return { allowed: false };
|
|
1092
|
+
}
|
|
1093
|
+
return { allowed: true };
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* 检查发送者是否在允许列表中(通用)
|
|
1097
|
+
*/
|
|
1098
|
+
function isSenderAllowed(senderId, allowFrom) {
|
|
1099
|
+
if (allowFrom.includes("*")) {
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1102
|
+
return allowFrom.some((entry) => {
|
|
1103
|
+
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
|
|
1104
|
+
return normalized === senderId || normalized === `user:${senderId}`;
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* 企业微信 DM(私聊)访问控制模块
|
|
1110
|
+
*
|
|
1111
|
+
* 负责私聊策略检查、配对流程
|
|
1112
|
+
*/
|
|
1113
|
+
// ============================================================================
|
|
1114
|
+
// 公开 API
|
|
1115
|
+
// ============================================================================
|
|
1116
|
+
/**
|
|
1117
|
+
* 检查 DM Policy 访问控制
|
|
1118
|
+
* @returns 检查结果,包含是否允许继续处理
|
|
1119
|
+
*/
|
|
1120
|
+
async function checkDmPolicy(params) {
|
|
1121
|
+
const { senderId, isGroup, account, wsClient, frame, runtime } = params;
|
|
1122
|
+
const core = getWeComRuntime();
|
|
1123
|
+
// 群聊消息不检查 DM Policy
|
|
1124
|
+
if (isGroup) {
|
|
1125
|
+
return { allowed: true };
|
|
1126
|
+
}
|
|
1127
|
+
const dmPolicy = account.config.dmPolicy ?? "open";
|
|
1128
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
|
1129
|
+
// 如果 dmPolicy 是 disabled,直接拒绝
|
|
1130
|
+
if (dmPolicy === "disabled") {
|
|
1131
|
+
runtime.log?.(`[WeCom] Blocked DM from ${senderId} (dmPolicy=disabled)`);
|
|
1132
|
+
return { allowed: false };
|
|
1133
|
+
}
|
|
1134
|
+
// 如果是 open 模式,允许所有人
|
|
1135
|
+
if (dmPolicy === "open") {
|
|
1136
|
+
return { allowed: true };
|
|
1137
|
+
}
|
|
1138
|
+
// OpenClaw <= 2026.2.19 signature: readAllowFromStore(channel, env?, accountId?)
|
|
1139
|
+
const oldStoreAllowFrom = await core.channel.pairing.readAllowFromStore('wecom', undefined, account.accountId).catch(() => []);
|
|
1140
|
+
// Compatibility fallback for newer OpenClaw implementations.
|
|
1141
|
+
const newStoreAllowFrom = await core.channel.pairing
|
|
1142
|
+
.readAllowFromStore({ channel: CHANNEL_ID, accountId: account.accountId })
|
|
1143
|
+
.catch(() => []);
|
|
1144
|
+
// 检查发送者是否在允许列表中
|
|
1145
|
+
const storeAllowFrom = [...oldStoreAllowFrom, ...newStoreAllowFrom];
|
|
1146
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
1147
|
+
const senderAllowedResult = isSenderAllowed(senderId, effectiveAllowFrom);
|
|
1148
|
+
if (senderAllowedResult) {
|
|
1149
|
+
return { allowed: true };
|
|
1150
|
+
}
|
|
1151
|
+
// 处理未授权用户
|
|
1152
|
+
if (dmPolicy === "pairing") {
|
|
1153
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
1154
|
+
channel: CHANNEL_ID,
|
|
1155
|
+
id: senderId,
|
|
1156
|
+
accountId: account.accountId,
|
|
1157
|
+
meta: { name: senderId },
|
|
1158
|
+
});
|
|
1159
|
+
if (created) {
|
|
1160
|
+
runtime.log?.(`[WeCom] Pairing request created for sender=${senderId}`);
|
|
1161
|
+
try {
|
|
1162
|
+
await sendWeComReply({
|
|
1163
|
+
wsClient,
|
|
1164
|
+
frame,
|
|
1165
|
+
text: core.channel.pairing.buildPairingReply({
|
|
1166
|
+
channel: CHANNEL_ID,
|
|
1167
|
+
idLine: `您的企业微信用户ID: ${senderId}`,
|
|
1168
|
+
code,
|
|
1169
|
+
}),
|
|
1170
|
+
runtime,
|
|
1171
|
+
finish: true,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
catch (err) {
|
|
1175
|
+
runtime.error?.(`[WeCom] Failed to send pairing reply to ${senderId}: ${String(err)}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
else {
|
|
1179
|
+
runtime.log?.(`[WeCom] Pairing request already exists for sender=${senderId}`);
|
|
1180
|
+
}
|
|
1181
|
+
return { allowed: false, pairingSent: created };
|
|
1182
|
+
}
|
|
1183
|
+
// allowlist 模式:直接拒绝未授权用户
|
|
1184
|
+
runtime.log?.(`[WeCom] Blocked unauthorized sender ${senderId} (dmPolicy=${dmPolicy})`);
|
|
1185
|
+
return { allowed: false };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ============================================================================
|
|
1189
|
+
// 常量
|
|
1190
|
+
// ============================================================================
|
|
1191
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天
|
|
1192
|
+
const DEFAULT_MEMORY_MAX_SIZE = 200;
|
|
1193
|
+
const DEFAULT_FILE_MAX_ENTRIES = 500;
|
|
1194
|
+
const DEFAULT_FLUSH_DEBOUNCE_MS = 1000;
|
|
1195
|
+
const DEFAULT_LOCK_OPTIONS = {
|
|
1196
|
+
stale: 60000,
|
|
1197
|
+
retries: {
|
|
1198
|
+
retries: 6,
|
|
1199
|
+
factor: 1.35,
|
|
1200
|
+
minTimeout: 8,
|
|
1201
|
+
maxTimeout: 180,
|
|
1202
|
+
randomize: true,
|
|
1203
|
+
},
|
|
1204
|
+
};
|
|
1205
|
+
// ============================================================================
|
|
1206
|
+
// 状态目录解析
|
|
1207
|
+
// ============================================================================
|
|
1208
|
+
function resolveStateDirFromEnv(env = process.env) {
|
|
1209
|
+
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
1210
|
+
if (stateOverride) {
|
|
1211
|
+
return stateOverride;
|
|
1212
|
+
}
|
|
1213
|
+
if (env.VITEST || env.NODE_ENV === "test") {
|
|
1214
|
+
return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
|
|
1215
|
+
}
|
|
1216
|
+
return path.join(os.homedir(), ".openclaw");
|
|
1217
|
+
}
|
|
1218
|
+
function resolveReqIdFilePath(accountId) {
|
|
1219
|
+
const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1220
|
+
return path.join(resolveStateDirFromEnv(), "wecom", `reqid-map-${safe}.json`);
|
|
1221
|
+
}
|
|
1222
|
+
// ============================================================================
|
|
1223
|
+
// 核心实现
|
|
1224
|
+
// ============================================================================
|
|
1225
|
+
function createPersistentReqIdStore(accountId, options) {
|
|
1226
|
+
const ttlMs = DEFAULT_TTL_MS;
|
|
1227
|
+
const memoryMaxSize = DEFAULT_MEMORY_MAX_SIZE;
|
|
1228
|
+
const fileMaxEntries = DEFAULT_FILE_MAX_ENTRIES;
|
|
1229
|
+
const flushDebounceMs = DEFAULT_FLUSH_DEBOUNCE_MS;
|
|
1230
|
+
const filePath = resolveReqIdFilePath(accountId);
|
|
1231
|
+
// 内存层:chatId → ReqIdEntry
|
|
1232
|
+
const memory = new Map();
|
|
1233
|
+
// 防抖写入相关
|
|
1234
|
+
let dirty = false;
|
|
1235
|
+
let flushTimer = null;
|
|
1236
|
+
// ========== 内部辅助函数 ==========
|
|
1237
|
+
/** 检查条目是否过期 */
|
|
1238
|
+
function isExpired(entry, now) {
|
|
1239
|
+
return now - entry.ts >= ttlMs;
|
|
1240
|
+
}
|
|
1241
|
+
/** 验证磁盘条目的合法性 */
|
|
1242
|
+
function isValidEntry(entry) {
|
|
1243
|
+
return (typeof entry === "object" &&
|
|
1244
|
+
entry !== null &&
|
|
1245
|
+
typeof entry.reqId === "string" &&
|
|
1246
|
+
typeof entry.ts === "number" &&
|
|
1247
|
+
Number.isFinite(entry.ts));
|
|
1248
|
+
}
|
|
1249
|
+
/** 清理磁盘数据中的无效值,返回干净的 Record */
|
|
1250
|
+
function sanitizeData(value) {
|
|
1251
|
+
if (!value || typeof value !== "object") {
|
|
1252
|
+
return {};
|
|
1253
|
+
}
|
|
1254
|
+
const out = {};
|
|
1255
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1256
|
+
if (isValidEntry(entry)) {
|
|
1257
|
+
out[key] = entry;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return out;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* 内存容量控制:淘汰最旧的条目。
|
|
1264
|
+
* 利用 Map 的插入顺序 + touch(先 delete 再 set) 实现类 LRU 效果。
|
|
1265
|
+
*/
|
|
1266
|
+
function pruneMemory() {
|
|
1267
|
+
if (memory.size <= memoryMaxSize)
|
|
1268
|
+
return;
|
|
1269
|
+
const sorted = [...memory.entries()].sort((a, b) => a[1].ts - b[1].ts);
|
|
1270
|
+
const toRemove = sorted.slice(0, memory.size - memoryMaxSize);
|
|
1271
|
+
for (const [key] of toRemove) {
|
|
1272
|
+
memory.delete(key);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/** 磁盘数据容量控制:先清过期,再按时间淘汰超量 */
|
|
1276
|
+
function pruneFileData(data, now) {
|
|
1277
|
+
{
|
|
1278
|
+
for (const [key, entry] of Object.entries(data)) {
|
|
1279
|
+
if (now - entry.ts >= ttlMs) {
|
|
1280
|
+
delete data[key];
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
const keys = Object.keys(data);
|
|
1285
|
+
if (keys.length <= fileMaxEntries)
|
|
1286
|
+
return;
|
|
1287
|
+
keys
|
|
1288
|
+
.sort((a, b) => data[a].ts - data[b].ts)
|
|
1289
|
+
.slice(0, keys.length - fileMaxEntries)
|
|
1290
|
+
.forEach((key) => delete data[key]);
|
|
1291
|
+
}
|
|
1292
|
+
/** 防抖写入磁盘 */
|
|
1293
|
+
function scheduleDiskFlush() {
|
|
1294
|
+
dirty = true;
|
|
1295
|
+
if (flushTimer)
|
|
1296
|
+
return;
|
|
1297
|
+
flushTimer = setTimeout(async () => {
|
|
1298
|
+
flushTimer = null;
|
|
1299
|
+
if (!dirty)
|
|
1300
|
+
return;
|
|
1301
|
+
await flushToDisk();
|
|
1302
|
+
}, flushDebounceMs);
|
|
1303
|
+
}
|
|
1304
|
+
/** 立即写入磁盘(带文件锁,参考 createPersistentDedupe 的 checkAndRecordInner) */
|
|
1305
|
+
async function flushToDisk() {
|
|
1306
|
+
dirty = false;
|
|
1307
|
+
const now = Date.now();
|
|
1308
|
+
try {
|
|
1309
|
+
await pluginSdk.withFileLock(filePath, DEFAULT_LOCK_OPTIONS, async () => {
|
|
1310
|
+
// 读取现有磁盘数据并合并
|
|
1311
|
+
const { value } = await pluginSdk.readJsonFileWithFallback(filePath, {});
|
|
1312
|
+
const data = sanitizeData(value);
|
|
1313
|
+
// 将内存中未过期的数据合并到磁盘数据(内存优先)
|
|
1314
|
+
for (const [chatId, entry] of memory) {
|
|
1315
|
+
if (!isExpired(entry, now)) {
|
|
1316
|
+
data[chatId] = entry;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
// 清理过期和超量
|
|
1320
|
+
pruneFileData(data, now);
|
|
1321
|
+
// 原子写入
|
|
1322
|
+
await pluginSdk.writeJsonFileAtomically(filePath, data);
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
catch (error) {
|
|
1326
|
+
// 磁盘写入失败不影响内存使用,降级到纯内存模式
|
|
1327
|
+
// console.error(`[WeCom] reqid-store: flush to disk failed: ${String(error)}`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
// ========== 公开 API ==========
|
|
1331
|
+
function set(chatId, reqId) {
|
|
1332
|
+
const entry = { reqId, ts: Date.now() };
|
|
1333
|
+
// touch:先删再设,保持 Map 插入顺序(类 LRU)
|
|
1334
|
+
memory.delete(chatId);
|
|
1335
|
+
memory.set(chatId, entry);
|
|
1336
|
+
pruneMemory();
|
|
1337
|
+
scheduleDiskFlush();
|
|
1338
|
+
}
|
|
1339
|
+
async function get(chatId) {
|
|
1340
|
+
const now = Date.now();
|
|
1341
|
+
// 1. 先查内存
|
|
1342
|
+
const memEntry = memory.get(chatId);
|
|
1343
|
+
if (memEntry && !isExpired(memEntry, now)) {
|
|
1344
|
+
return memEntry.reqId;
|
|
1345
|
+
}
|
|
1346
|
+
if (memEntry) {
|
|
1347
|
+
memory.delete(chatId); // 过期则删除
|
|
1348
|
+
}
|
|
1349
|
+
// 2. 内存 miss,回查磁盘并回填内存
|
|
1350
|
+
try {
|
|
1351
|
+
const { value } = await pluginSdk.readJsonFileWithFallback(filePath, {});
|
|
1352
|
+
const data = sanitizeData(value);
|
|
1353
|
+
const diskEntry = data[chatId];
|
|
1354
|
+
if (diskEntry && !isExpired(diskEntry, now)) {
|
|
1355
|
+
// 回填内存
|
|
1356
|
+
memory.set(chatId, diskEntry);
|
|
1357
|
+
return diskEntry.reqId;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
// 磁盘读取失败,降级返回 undefined
|
|
1362
|
+
}
|
|
1363
|
+
return undefined;
|
|
1364
|
+
}
|
|
1365
|
+
function getSync(chatId) {
|
|
1366
|
+
const now = Date.now();
|
|
1367
|
+
const entry = memory.get(chatId);
|
|
1368
|
+
if (entry && !isExpired(entry, now)) {
|
|
1369
|
+
return entry.reqId;
|
|
1370
|
+
}
|
|
1371
|
+
if (entry) {
|
|
1372
|
+
memory.delete(chatId);
|
|
1373
|
+
}
|
|
1374
|
+
return undefined;
|
|
1375
|
+
}
|
|
1376
|
+
function del(chatId) {
|
|
1377
|
+
memory.delete(chatId);
|
|
1378
|
+
scheduleDiskFlush();
|
|
1379
|
+
}
|
|
1380
|
+
async function warmup(onError) {
|
|
1381
|
+
const now = Date.now();
|
|
1382
|
+
try {
|
|
1383
|
+
const { value } = await pluginSdk.readJsonFileWithFallback(filePath, {});
|
|
1384
|
+
const data = sanitizeData(value);
|
|
1385
|
+
let loaded = 0;
|
|
1386
|
+
for (const [chatId, entry] of Object.entries(data)) {
|
|
1387
|
+
if (!isExpired(entry, now)) {
|
|
1388
|
+
memory.set(chatId, entry);
|
|
1389
|
+
loaded++;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
pruneMemory();
|
|
1393
|
+
return loaded;
|
|
1394
|
+
}
|
|
1395
|
+
catch (error) {
|
|
1396
|
+
onError?.(error);
|
|
1397
|
+
return 0;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
async function flush() {
|
|
1401
|
+
if (flushTimer) {
|
|
1402
|
+
clearTimeout(flushTimer);
|
|
1403
|
+
flushTimer = null;
|
|
1404
|
+
}
|
|
1405
|
+
await flushToDisk();
|
|
1406
|
+
}
|
|
1407
|
+
function clearMemory() {
|
|
1408
|
+
memory.clear();
|
|
1409
|
+
}
|
|
1410
|
+
function memorySize() {
|
|
1411
|
+
return memory.size;
|
|
1412
|
+
}
|
|
1413
|
+
return {
|
|
1414
|
+
set,
|
|
1415
|
+
get,
|
|
1416
|
+
getSync,
|
|
1417
|
+
delete: del,
|
|
1418
|
+
warmup,
|
|
1419
|
+
flush,
|
|
1420
|
+
clearMemory,
|
|
1421
|
+
memorySize,
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* 企业微信全局状态管理模块
|
|
1427
|
+
*
|
|
1428
|
+
* 负责管理 WSClient 实例、消息状态(带 TTL 清理)、ReqId 存储
|
|
1429
|
+
* 解决全局 Map 的内存泄漏问题
|
|
1430
|
+
*/
|
|
1431
|
+
// ============================================================================
|
|
1432
|
+
// WSClient 实例管理
|
|
1433
|
+
// ============================================================================
|
|
1434
|
+
/** WSClient 实例管理 */
|
|
1435
|
+
const wsClientInstances = new Map();
|
|
1436
|
+
/**
|
|
1437
|
+
* 获取指定账户的 WSClient 实例
|
|
1438
|
+
*/
|
|
1439
|
+
function getWeComWebSocket(accountId) {
|
|
1440
|
+
return wsClientInstances.get(accountId) ?? null;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* 设置指定账户的 WSClient 实例
|
|
1444
|
+
*/
|
|
1445
|
+
function setWeComWebSocket(accountId, client) {
|
|
1446
|
+
wsClientInstances.set(accountId, client);
|
|
1447
|
+
}
|
|
1448
|
+
/** 消息状态管理 */
|
|
1449
|
+
const messageStates = new Map();
|
|
1450
|
+
/** 定期清理定时器 */
|
|
1451
|
+
let cleanupTimer = null;
|
|
1452
|
+
/**
|
|
1453
|
+
* 启动消息状态定期清理(自动 TTL 清理 + 容量限制)
|
|
1454
|
+
*/
|
|
1455
|
+
function startMessageStateCleanup() {
|
|
1456
|
+
if (cleanupTimer)
|
|
1457
|
+
return;
|
|
1458
|
+
cleanupTimer = setInterval(() => {
|
|
1459
|
+
pruneMessageStates();
|
|
1460
|
+
}, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
|
|
1461
|
+
// 允许进程退出时不阻塞
|
|
1462
|
+
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
1463
|
+
cleanupTimer.unref();
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* 停止消息状态定期清理
|
|
1468
|
+
*/
|
|
1469
|
+
function stopMessageStateCleanup() {
|
|
1470
|
+
if (cleanupTimer) {
|
|
1471
|
+
clearInterval(cleanupTimer);
|
|
1472
|
+
cleanupTimer = null;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* 清理过期和超量的消息状态条目
|
|
1477
|
+
*/
|
|
1478
|
+
function pruneMessageStates() {
|
|
1479
|
+
const now = Date.now();
|
|
1480
|
+
// 1. 清理过期条目
|
|
1481
|
+
for (const [key, entry] of messageStates) {
|
|
1482
|
+
if (now - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
|
|
1483
|
+
messageStates.delete(key);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
// 2. 容量限制:如果仍超过最大条目数,按时间淘汰最旧的
|
|
1487
|
+
if (messageStates.size > MESSAGE_STATE_MAX_SIZE) {
|
|
1488
|
+
const sorted = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
1489
|
+
const toRemove = sorted.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE);
|
|
1490
|
+
for (const [key] of toRemove) {
|
|
1491
|
+
messageStates.delete(key);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* 设置消息状态
|
|
1497
|
+
*/
|
|
1498
|
+
function setMessageState(messageId, state) {
|
|
1499
|
+
messageStates.set(messageId, {
|
|
1500
|
+
state,
|
|
1501
|
+
createdAt: Date.now(),
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* 删除消息状态
|
|
1506
|
+
*/
|
|
1507
|
+
function deleteMessageState(messageId) {
|
|
1508
|
+
messageStates.delete(messageId);
|
|
1509
|
+
}
|
|
1510
|
+
// ============================================================================
|
|
1511
|
+
// ReqId 持久化存储管理(按 accountId 隔离)
|
|
1512
|
+
// ============================================================================
|
|
1513
|
+
/**
|
|
1514
|
+
* ReqId 持久化存储管理
|
|
1515
|
+
* 参考 createPersistentDedupe 模式:内存 + 磁盘双层、文件锁、原子写入、TTL 过期、防抖写入
|
|
1516
|
+
* 重启后可从磁盘恢复,确保主动推送消息时能获取到 reqId
|
|
1517
|
+
*/
|
|
1518
|
+
const reqIdStores = new Map();
|
|
1519
|
+
function getOrCreateReqIdStore(accountId) {
|
|
1520
|
+
let store = reqIdStores.get(accountId);
|
|
1521
|
+
if (!store) {
|
|
1522
|
+
store = createPersistentReqIdStore(accountId);
|
|
1523
|
+
reqIdStores.set(accountId, store);
|
|
1524
|
+
}
|
|
1525
|
+
return store;
|
|
1526
|
+
}
|
|
1527
|
+
// ============================================================================
|
|
1528
|
+
// ReqId 操作函数
|
|
1529
|
+
// ============================================================================
|
|
1530
|
+
/**
|
|
1531
|
+
* 设置 chatId 对应的 reqId(写入内存 + 防抖写磁盘)
|
|
1532
|
+
*/
|
|
1533
|
+
function setReqIdForChat(chatId, reqId, accountId = "default") {
|
|
1534
|
+
getOrCreateReqIdStore(accountId).set(chatId, reqId);
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* 启动时预热 reqId 缓存(从磁盘加载到内存)
|
|
1538
|
+
*/
|
|
1539
|
+
async function warmupReqIdStore(accountId = "default", log) {
|
|
1540
|
+
const store = getOrCreateReqIdStore(accountId);
|
|
1541
|
+
return store.warmup((error) => {
|
|
1542
|
+
log?.(`[WeCom] reqid-store warmup error: ${String(error)}`);
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
// ============================================================================
|
|
1546
|
+
// 全局 cleanup(断开连接时释放所有资源)
|
|
1547
|
+
// ============================================================================
|
|
1548
|
+
/**
|
|
1549
|
+
* 清理指定账户的所有资源
|
|
1550
|
+
*/
|
|
1551
|
+
async function cleanupAccount(accountId) {
|
|
1552
|
+
// 1. 断开 WSClient
|
|
1553
|
+
const wsClient = wsClientInstances.get(accountId);
|
|
1554
|
+
if (wsClient) {
|
|
1555
|
+
try {
|
|
1556
|
+
wsClient.disconnect();
|
|
1557
|
+
}
|
|
1558
|
+
catch {
|
|
1559
|
+
// 忽略断开连接时的错误
|
|
1560
|
+
}
|
|
1561
|
+
wsClientInstances.delete(accountId);
|
|
1562
|
+
}
|
|
1563
|
+
// 2. flush reqId 存储到磁盘
|
|
1564
|
+
const store = reqIdStores.get(accountId);
|
|
1565
|
+
if (store) {
|
|
1566
|
+
try {
|
|
1567
|
+
await store.flush();
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
// 忽略 flush 错误
|
|
1571
|
+
}
|
|
1572
|
+
// 注意:不删除 store,因为重连后可能还需要
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* 企业微信 WebSocket 监控器主模块
|
|
1578
|
+
*
|
|
1579
|
+
* 负责:
|
|
1580
|
+
* - 建立和管理 WebSocket 连接
|
|
1581
|
+
* - 协调消息处理流程(解析→策略检查→下载图片→路由回复)
|
|
1582
|
+
* - 资源生命周期管理
|
|
1583
|
+
*
|
|
1584
|
+
* 子模块:
|
|
1585
|
+
* - message-parser.ts : 消息内容解析
|
|
1586
|
+
* - message-sender.ts : 消息发送(带超时保护)
|
|
1587
|
+
* - media-handler.ts : 图片下载和保存(带超时保护)
|
|
1588
|
+
* - group-policy.ts : 群组访问控制
|
|
1589
|
+
* - dm-policy.ts : 私聊访问控制
|
|
1590
|
+
* - state-manager.ts : 全局状态管理(带 TTL 清理)
|
|
1591
|
+
* - timeout.ts : 超时工具
|
|
1592
|
+
*/
|
|
1593
|
+
/**
|
|
1594
|
+
* 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
|
|
1595
|
+
* 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
|
|
1596
|
+
*/
|
|
1597
|
+
function stripThinkTags(text) {
|
|
1598
|
+
return text;
|
|
1599
|
+
// return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
1600
|
+
}
|
|
1601
|
+
// ============================================================================
|
|
1602
|
+
// 媒体本地路径白名单扩展
|
|
1603
|
+
// ============================================================================
|
|
1604
|
+
/**
|
|
1605
|
+
* 解析 openclaw 状态目录(与 plugin-sdk 内部逻辑保持一致)
|
|
1606
|
+
*/
|
|
1607
|
+
function resolveStateDir() {
|
|
1608
|
+
const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
1609
|
+
if (stateOverride)
|
|
1610
|
+
return stateOverride;
|
|
1611
|
+
return path__namespace$1.join(os__namespace$1.homedir(), ".openclaw");
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* 在 getDefaultMediaLocalRoots() 基础上,将 stateDir 本身也加入白名单,
|
|
1615
|
+
* 并合并用户在 WeComConfig 中配置的自定义 mediaLocalRoots。
|
|
1616
|
+
*
|
|
1617
|
+
* getDefaultMediaLocalRoots() 仅包含 stateDir 下的子目录(media/agents/workspace/sandboxes),
|
|
1618
|
+
* 但 agent 生成的文件可能直接放在 stateDir 根目录下(如 ~/.openclaw-dev/1.png),
|
|
1619
|
+
* 因此需要将 stateDir 本身也加入白名单以避免 LocalMediaAccessError。
|
|
1620
|
+
*
|
|
1621
|
+
* 用户可在 openclaw.json 中配置:
|
|
1622
|
+
* {
|
|
1623
|
+
* "channels": {
|
|
1624
|
+
* "wecom": {
|
|
1625
|
+
* "mediaLocalRoots": ["~/Downloads", "~/Documents"]
|
|
1626
|
+
* }
|
|
1627
|
+
* }
|
|
1628
|
+
* }
|
|
1629
|
+
*/
|
|
1630
|
+
async function getExtendedMediaLocalRoots(config) {
|
|
1631
|
+
// 从兼容层获取默认白名单(内部已处理低版本 SDK 的 fallback)
|
|
1632
|
+
const defaults = await getDefaultMediaLocalRoots();
|
|
1633
|
+
const roots = [...defaults];
|
|
1634
|
+
const stateDir = path__namespace$1.resolve(resolveStateDir());
|
|
1635
|
+
if (!roots.includes(stateDir)) {
|
|
1636
|
+
roots.push(stateDir);
|
|
1637
|
+
}
|
|
1638
|
+
// 合并用户在 WeComConfig 中配置的自定义路径
|
|
1639
|
+
if (config?.mediaLocalRoots) {
|
|
1640
|
+
for (const r of config.mediaLocalRoots) {
|
|
1641
|
+
const resolved = path__namespace$1.resolve(r.replace(/^~(?=\/|$)/, os__namespace$1.homedir()));
|
|
1642
|
+
if (!roots.includes(resolved)) {
|
|
1643
|
+
roots.push(resolved);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return roots;
|
|
1648
|
+
}
|
|
1649
|
+
// ============================================================================
|
|
1650
|
+
// 媒体发送错误提示
|
|
1651
|
+
// ============================================================================
|
|
1652
|
+
/**
|
|
1653
|
+
* 根据媒体发送结果生成纯文本错误摘要(用于替换 thinking 流式消息展示给用户)。
|
|
1654
|
+
*
|
|
1655
|
+
* 使用纯文本而非 markdown 格式,因为 replyStream 只支持纯文本。
|
|
1656
|
+
*/
|
|
1657
|
+
function buildMediaErrorSummary(mediaUrl, result) {
|
|
1658
|
+
if (result.error?.includes("LocalMediaAccessError")) {
|
|
1659
|
+
return `⚠️ 文件发送失败:没有权限访问路径 ${mediaUrl}\n请在 openclaw.json 的 mediaLocalRoots 中添加该路径的父目录后重启生效。`;
|
|
1660
|
+
}
|
|
1661
|
+
if (result.rejectReason) {
|
|
1662
|
+
return `⚠️ 文件发送失败:${result.rejectReason}`;
|
|
1663
|
+
}
|
|
1664
|
+
return `⚠️ 文件发送失败:无法处理文件 ${mediaUrl},请稍后再试。`;
|
|
1665
|
+
}
|
|
1666
|
+
// ============================================================================
|
|
1667
|
+
// 消息上下文构建
|
|
1668
|
+
// ============================================================================
|
|
1669
|
+
/**
|
|
1670
|
+
* 构建消息上下文
|
|
1671
|
+
*/
|
|
1672
|
+
function buildMessageContext(frame, account, config, text, mediaList, quoteContent) {
|
|
1673
|
+
const core = getWeComRuntime();
|
|
1674
|
+
const body = frame.body;
|
|
1675
|
+
const chatId = body.chatid || body.from.userid;
|
|
1676
|
+
const chatType = body.chattype === "group" ? "group" : "direct";
|
|
1677
|
+
// 解析路由信息
|
|
1678
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
1679
|
+
cfg: config,
|
|
1680
|
+
channel: CHANNEL_ID,
|
|
1681
|
+
accountId: account.accountId,
|
|
1682
|
+
peer: {
|
|
1683
|
+
kind: chatType,
|
|
1684
|
+
id: chatId,
|
|
1685
|
+
},
|
|
1686
|
+
});
|
|
1687
|
+
// 构建会话标签
|
|
1688
|
+
const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${body.from.userid}`;
|
|
1689
|
+
// 当只有媒体没有文本时,使用占位符标识媒体类型
|
|
1690
|
+
const hasImages = mediaList.some((m) => m.contentType?.startsWith("image/"));
|
|
1691
|
+
const messageBody = text || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
|
|
1692
|
+
// 构建多媒体数组
|
|
1693
|
+
const mediaPaths = mediaList.length > 0 ? mediaList.map((m) => m.path) : undefined;
|
|
1694
|
+
const mediaTypes = mediaList.length > 0
|
|
1695
|
+
? mediaList.map((m) => m.contentType).filter(Boolean)
|
|
1696
|
+
: undefined;
|
|
1697
|
+
// 构建标准消息上下文
|
|
1698
|
+
return core.channel.reply.finalizeInboundContext({
|
|
1699
|
+
Body: messageBody,
|
|
1700
|
+
RawBody: messageBody,
|
|
1701
|
+
CommandBody: messageBody,
|
|
1702
|
+
MessageSid: body.msgid,
|
|
1703
|
+
From: chatType === "group" ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${body.from.userid}`,
|
|
1704
|
+
To: `${CHANNEL_ID}:${chatId}`,
|
|
1705
|
+
SenderId: body.from.userid,
|
|
1706
|
+
SessionKey: route.sessionKey,
|
|
1707
|
+
AccountId: account.accountId,
|
|
1708
|
+
ChatType: chatType,
|
|
1709
|
+
ConversationLabel: fromLabel,
|
|
1710
|
+
Timestamp: Date.now(),
|
|
1711
|
+
Provider: CHANNEL_ID,
|
|
1712
|
+
Surface: CHANNEL_ID,
|
|
1713
|
+
OriginatingChannel: CHANNEL_ID,
|
|
1714
|
+
OriginatingTo: `${CHANNEL_ID}:${chatId}`,
|
|
1715
|
+
CommandAuthorized: true,
|
|
1716
|
+
ResponseUrl: body.response_url,
|
|
1717
|
+
ReqId: frame.headers.req_id,
|
|
1718
|
+
WeComFrame: frame,
|
|
1719
|
+
MediaPath: mediaList[0]?.path,
|
|
1720
|
+
MediaType: mediaList[0]?.contentType,
|
|
1721
|
+
MediaPaths: mediaPaths,
|
|
1722
|
+
MediaTypes: mediaTypes,
|
|
1723
|
+
MediaUrls: mediaPaths,
|
|
1724
|
+
ReplyToBody: quoteContent,
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* 发送"思考中"消息
|
|
1729
|
+
*/
|
|
1730
|
+
async function sendThinkingReply(params) {
|
|
1731
|
+
const { wsClient, frame, streamId, runtime } = params;
|
|
1732
|
+
try {
|
|
1733
|
+
await sendWeComReply({
|
|
1734
|
+
wsClient,
|
|
1735
|
+
frame,
|
|
1736
|
+
text: THINKING_MESSAGE,
|
|
1737
|
+
runtime,
|
|
1738
|
+
finish: false,
|
|
1739
|
+
streamId,
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
catch (err) {
|
|
1743
|
+
runtime.error?.(`[wecom] Failed to send thinking message: ${String(err)}`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* 累积文本并判断是否有可见内容(去除 <think> 标签后)
|
|
1748
|
+
*/
|
|
1749
|
+
function accumulateText(state, text) {
|
|
1750
|
+
state.accumulatedText += text;
|
|
1751
|
+
if (!state.hasText && stripThinkTags(state.accumulatedText)) {
|
|
1752
|
+
state.hasText = true;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* 上传并发送一批媒体文件(统一走主动发送通道)
|
|
1757
|
+
*
|
|
1758
|
+
* replyMedia(被动回复)无法覆盖 replyStream 发出的 thinking 流式消息,
|
|
1759
|
+
* 因此所有媒体统一走 aibot_send_msg 主动发送。
|
|
1760
|
+
*/
|
|
1761
|
+
async function sendMediaBatch(ctx, mediaUrls) {
|
|
1762
|
+
const { wsClient, frame, state, account, runtime } = ctx;
|
|
1763
|
+
const body = frame.body;
|
|
1764
|
+
const chatId = body.chatid || body.from.userid;
|
|
1765
|
+
const mediaLocalRoots = await getExtendedMediaLocalRoots(account.config);
|
|
1766
|
+
runtime.log?.(`[wecom][debug] mediaLocalRoots=${JSON.stringify(mediaLocalRoots)}, mediaUrls=${JSON.stringify(mediaUrls)}, hasText=${!!state.hasText}`);
|
|
1767
|
+
for (const mediaUrl of mediaUrls) {
|
|
1768
|
+
const result = await uploadAndSendMedia({
|
|
1769
|
+
wsClient,
|
|
1770
|
+
mediaUrl,
|
|
1771
|
+
chatId,
|
|
1772
|
+
mediaLocalRoots,
|
|
1773
|
+
log: (...args) => runtime.log?.(...args),
|
|
1774
|
+
errorLog: (...args) => runtime.error?.(...args),
|
|
1775
|
+
});
|
|
1776
|
+
if (result.ok) {
|
|
1777
|
+
state.hasMedia = true;
|
|
1778
|
+
}
|
|
1779
|
+
else {
|
|
1780
|
+
state.hasMediaFailed = true;
|
|
1781
|
+
runtime.error?.(`[wecom] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
|
|
1782
|
+
// 收集错误摘要,后续在 finishThinkingStream 中直接替换 thinking 流展示给用户
|
|
1783
|
+
const summary = buildMediaErrorSummary(mediaUrl, result);
|
|
1784
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
1785
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
1786
|
+
: summary;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* 关闭 thinking 流(发送 finish=true 的流式消息)
|
|
1792
|
+
*
|
|
1793
|
+
* thinking 是通过 replyStream 用 streamId 发的流式消息,
|
|
1794
|
+
* 只有同一 streamId 的 replyStream(finish=true) 才能关闭它。
|
|
1795
|
+
*
|
|
1796
|
+
* ⚠️ 注意:企微会忽略空格等不可见内容,必须用有可见字符的文案才能真正
|
|
1797
|
+
* 替换掉 thinking 动画,否则 thinking 会一直残留。
|
|
1798
|
+
*
|
|
1799
|
+
* 关闭策略(按优先级):
|
|
1800
|
+
* 1. 有可见文本 → 用完整文本关闭
|
|
1801
|
+
* 2. 有媒体成功发送(通过 deliver 回调) → 用友好提示"文件已发送"
|
|
1802
|
+
* 3. 媒体发送失败 → 直接用错误摘要替换 thinking
|
|
1803
|
+
* 4. 其他 → 用通用"处理完成"提示
|
|
1804
|
+
* (agent 可能已通过内置 message 工具直接发送了文件,
|
|
1805
|
+
* 该路径走 outbound.sendMedia 完全绕过 deliver 回调,
|
|
1806
|
+
* 所以 state 中无记录,但文件已实际送达)
|
|
1807
|
+
*/
|
|
1808
|
+
async function finishThinkingStream(ctx) {
|
|
1809
|
+
const { wsClient, frame, state, runtime } = ctx;
|
|
1810
|
+
const visibleText = stripThinkTags(state.accumulatedText);
|
|
1811
|
+
let finishText;
|
|
1812
|
+
if (visibleText) {
|
|
1813
|
+
// 有可见文本:用完整文本关闭流(覆盖 thinking 为真实内容)
|
|
1814
|
+
finishText = state.accumulatedText;
|
|
1815
|
+
}
|
|
1816
|
+
else if (state.hasMedia) {
|
|
1817
|
+
// 媒体成功发送:用友好提示告知用户
|
|
1818
|
+
finishText = "📎 文件已发送,请查收。";
|
|
1819
|
+
}
|
|
1820
|
+
else if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
1821
|
+
// 媒体发送失败:直接用错误摘要替换 thinking 流(不再额外发 sendMessage)
|
|
1822
|
+
finishText = state.mediaErrorSummary;
|
|
1823
|
+
}
|
|
1824
|
+
else {
|
|
1825
|
+
// 核心无可见文本且 deliver 中未处理过媒体。
|
|
1826
|
+
//
|
|
1827
|
+
// 不使用错误提示,因为 agent 可能已通过内置 message 工具直接调用
|
|
1828
|
+
// outbound.sendMedia 成功发送了文件——该路径完全绕过 monitor 的 deliver
|
|
1829
|
+
// 回调,所以 state.deliverCalled / state.hasMedia 均为 false,但文件
|
|
1830
|
+
// 实际已送达用户。此时显示 "未生成回复" 会误导用户。
|
|
1831
|
+
finishText = "✅ 处理完成。";
|
|
1832
|
+
}
|
|
1833
|
+
await sendWeComReply({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* 路由消息到核心处理流程并处理回复
|
|
1837
|
+
*/
|
|
1838
|
+
async function routeAndDispatchMessage(params) {
|
|
1839
|
+
const { ctxPayload, config, account, wsClient, frame, state, runtime, onCleanup } = params;
|
|
1840
|
+
const core = getWeComRuntime();
|
|
1841
|
+
const ctx = { wsClient, frame, state, account, runtime };
|
|
1842
|
+
// 防止 onCleanup 被多次调用(onError 回调与 catch 块可能重复触发)
|
|
1843
|
+
let cleanedUp = false;
|
|
1844
|
+
const safeCleanup = () => {
|
|
1845
|
+
if (!cleanedUp) {
|
|
1846
|
+
cleanedUp = true;
|
|
1847
|
+
onCleanup();
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
try {
|
|
1851
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1852
|
+
ctx: ctxPayload,
|
|
1853
|
+
cfg: config,
|
|
1854
|
+
dispatcherOptions: {
|
|
1855
|
+
deliver: async (payload, info) => {
|
|
1856
|
+
state.deliverCalled = true;
|
|
1857
|
+
// runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, text=${payload.text ?? ''}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
|
|
1858
|
+
// 累积文本
|
|
1859
|
+
if (payload.text) {
|
|
1860
|
+
accumulateText(state, payload.text);
|
|
1861
|
+
}
|
|
1862
|
+
// 发送媒体(统一走主动发送)
|
|
1863
|
+
const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
|
|
1864
|
+
if (mediaUrls.length > 0) {
|
|
1865
|
+
try {
|
|
1866
|
+
await sendMediaBatch(ctx, mediaUrls);
|
|
1867
|
+
}
|
|
1868
|
+
catch (mediaErr) {
|
|
1869
|
+
// sendMediaBatch 内部异常(如 getDefaultMediaLocalRoots 不可用等)
|
|
1870
|
+
// 必须标记 state,否则 finishThinkingStream 会显示"处理完成"误导用户
|
|
1871
|
+
state.hasMediaFailed = true;
|
|
1872
|
+
const errMsg = String(mediaErr);
|
|
1873
|
+
const summary = `⚠️ 文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
|
|
1874
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
1875
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
1876
|
+
: summary;
|
|
1877
|
+
runtime.error?.(`[wecom] sendMediaBatch threw: ${errMsg}`);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
// 中间帧:有可见文本时流式更新
|
|
1881
|
+
if (info.kind !== "final" && state.hasText && state.accumulatedText) {
|
|
1882
|
+
await sendWeComReply({ wsClient, frame, text: state.accumulatedText, runtime, finish: false, streamId: state.streamId });
|
|
1883
|
+
}
|
|
1884
|
+
},
|
|
1885
|
+
onError: (err, info) => {
|
|
1886
|
+
runtime.error?.(`[wecom] ${info.kind} reply failed: ${String(err)}`);
|
|
1887
|
+
},
|
|
1888
|
+
},
|
|
1889
|
+
});
|
|
1890
|
+
// 关闭 thinking 流
|
|
1891
|
+
await finishThinkingStream(ctx);
|
|
1892
|
+
safeCleanup();
|
|
1893
|
+
}
|
|
1894
|
+
catch (err) {
|
|
1895
|
+
runtime.error?.(`[wecom][plugin] Failed to process message: ${String(err)}`);
|
|
1896
|
+
// 即使 dispatch 抛异常,也需要关闭 thinking 流,
|
|
1897
|
+
// 避免 deliver 已成功发送媒体但后续出错时 thinking 消息残留或被错误文案覆盖
|
|
1898
|
+
try {
|
|
1899
|
+
await finishThinkingStream(ctx);
|
|
1900
|
+
}
|
|
1901
|
+
catch (finishErr) {
|
|
1902
|
+
runtime.error?.(`[wecom] Failed to finish thinking stream after dispatch error: ${String(finishErr)}`);
|
|
1903
|
+
}
|
|
1904
|
+
safeCleanup();
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* 处理企业微信消息(主函数)
|
|
1909
|
+
*
|
|
1910
|
+
* 处理流程:
|
|
1911
|
+
* 1. 解析消息内容(文本、图片、引用)
|
|
1912
|
+
* 2. 群组策略检查(仅群聊)
|
|
1913
|
+
* 3. DM Policy 访问控制检查(仅私聊)
|
|
1914
|
+
* 4. 下载并保存图片
|
|
1915
|
+
* 5. 初始化消息状态
|
|
1916
|
+
* 6. 发送"思考中"消息
|
|
1917
|
+
* 7. 路由消息到核心处理流程
|
|
1918
|
+
*
|
|
1919
|
+
* 整体带超时保护,防止单条消息处理阻塞过久
|
|
1920
|
+
*/
|
|
1921
|
+
async function processWeComMessage(params) {
|
|
1922
|
+
const { frame, account, config, runtime, wsClient } = params;
|
|
1923
|
+
const body = frame.body;
|
|
1924
|
+
const chatId = body.chatid || body.from.userid;
|
|
1925
|
+
const chatType = body.chattype === "group" ? "group" : "direct";
|
|
1926
|
+
const messageId = body.msgid;
|
|
1927
|
+
const reqId = frame.headers.req_id;
|
|
1928
|
+
// Step 1: 解析消息内容
|
|
1929
|
+
const { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent } = parseMessageContent(body);
|
|
1930
|
+
let text = textParts.join("\n").trim();
|
|
1931
|
+
// // 群聊中移除 @机器人 的提及标记
|
|
1932
|
+
// if (body.chattype === "group") {
|
|
1933
|
+
// text = text.replace(/@\S+/g, "").trim();
|
|
1934
|
+
// }
|
|
1935
|
+
// 如果文本为空但存在引用消息,使用引用消息内容
|
|
1936
|
+
if (!text && quoteContent) {
|
|
1937
|
+
text = quoteContent;
|
|
1938
|
+
runtime.log?.("[wecom][plugin] Using quote content as message body (user only mentioned bot)");
|
|
1939
|
+
}
|
|
1940
|
+
// 如果既没有文本也没有图片也没有文件也没有引用内容,则跳过
|
|
1941
|
+
if (!text && imageUrls.length === 0 && fileUrls.length === 0) {
|
|
1942
|
+
runtime.log?.("[wecom][plugin] Skipping empty message (no text, image, file or quote)");
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
// Step 2: 群组策略检查(仅群聊)
|
|
1946
|
+
if (chatType === "group") {
|
|
1947
|
+
const groupPolicyResult = checkGroupPolicy({
|
|
1948
|
+
chatId,
|
|
1949
|
+
senderId: body.from.userid,
|
|
1950
|
+
account,
|
|
1951
|
+
config,
|
|
1952
|
+
runtime,
|
|
1953
|
+
});
|
|
1954
|
+
if (!groupPolicyResult.allowed) {
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
// Step 3: DM Policy 访问控制检查(仅私聊)
|
|
1959
|
+
const dmPolicyResult = await checkDmPolicy({
|
|
1960
|
+
senderId: body.from.userid,
|
|
1961
|
+
isGroup: chatType === "group",
|
|
1962
|
+
account,
|
|
1963
|
+
wsClient,
|
|
1964
|
+
frame,
|
|
1965
|
+
runtime,
|
|
1966
|
+
});
|
|
1967
|
+
if (!dmPolicyResult.allowed) {
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
// Step 4: 下载并保存图片和文件
|
|
1971
|
+
const [imageMediaList, fileMediaList] = await Promise.all([
|
|
1972
|
+
downloadAndSaveImages({
|
|
1973
|
+
imageUrls,
|
|
1974
|
+
imageAesKeys,
|
|
1975
|
+
account,
|
|
1976
|
+
config,
|
|
1977
|
+
runtime,
|
|
1978
|
+
wsClient,
|
|
1979
|
+
}),
|
|
1980
|
+
downloadAndSaveFiles({
|
|
1981
|
+
fileUrls,
|
|
1982
|
+
fileAesKeys,
|
|
1983
|
+
account,
|
|
1984
|
+
config,
|
|
1985
|
+
runtime,
|
|
1986
|
+
wsClient,
|
|
1987
|
+
}),
|
|
1988
|
+
]);
|
|
1989
|
+
const mediaList = [...imageMediaList, ...fileMediaList];
|
|
1990
|
+
// Step 5: 初始化消息状态
|
|
1991
|
+
setReqIdForChat(chatId, reqId, account.accountId);
|
|
1992
|
+
const streamId = aibotNodeSdk.generateReqId("stream");
|
|
1993
|
+
const state = { accumulatedText: "", streamId };
|
|
1994
|
+
setMessageState(messageId, state);
|
|
1995
|
+
const cleanupState = () => {
|
|
1996
|
+
deleteMessageState(messageId);
|
|
1997
|
+
};
|
|
1998
|
+
// Step 6: 发送"思考中"消息
|
|
1999
|
+
const shouldSendThinking = account.sendThinkingMessage ?? true;
|
|
2000
|
+
if (shouldSendThinking) {
|
|
2001
|
+
await sendThinkingReply({ wsClient, frame, streamId, runtime });
|
|
2002
|
+
}
|
|
2003
|
+
// Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
|
|
2004
|
+
const ctxPayload = buildMessageContext(frame, account, config, text, mediaList, quoteContent);
|
|
2005
|
+
// runtime.log?.(`[plugin -> openclaw] body=${text}, mediaPaths=${JSON.stringify(mediaList.map(m => m.path))}${quoteContent ? `, quote=${quoteContent}` : ''}`);
|
|
2006
|
+
try {
|
|
2007
|
+
await withTimeout(routeAndDispatchMessage({
|
|
2008
|
+
ctxPayload,
|
|
2009
|
+
config,
|
|
2010
|
+
account,
|
|
2011
|
+
wsClient,
|
|
2012
|
+
frame,
|
|
2013
|
+
state,
|
|
2014
|
+
runtime,
|
|
2015
|
+
onCleanup: cleanupState,
|
|
2016
|
+
}), MESSAGE_PROCESS_TIMEOUT_MS, `Message processing timed out (msgId=${messageId})`);
|
|
2017
|
+
}
|
|
2018
|
+
catch (err) {
|
|
2019
|
+
runtime.error?.(`[wecom][plugin] Message processing failed or timed out: ${String(err)}`);
|
|
2020
|
+
// 确保 thinking 流被关闭,防止异常/超时时 thinking 消息一直残留
|
|
2021
|
+
try {
|
|
2022
|
+
if (shouldSendThinking) {
|
|
2023
|
+
await sendWeComReply({
|
|
2024
|
+
wsClient,
|
|
2025
|
+
frame,
|
|
2026
|
+
text: "处理消息时出现异常,请稍后重试。",
|
|
2027
|
+
runtime,
|
|
2028
|
+
finish: true,
|
|
2029
|
+
streamId: state.streamId,
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
catch (finishErr) {
|
|
2034
|
+
runtime.error?.(`[wecom] Failed to finish thinking stream on error: ${String(finishErr)}`);
|
|
2035
|
+
}
|
|
2036
|
+
cleanupState();
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
// ============================================================================
|
|
2040
|
+
// 创建 SDK Logger 适配器
|
|
2041
|
+
// ============================================================================
|
|
2042
|
+
/**
|
|
2043
|
+
* 创建适配 RuntimeEnv 的 Logger
|
|
2044
|
+
*/
|
|
2045
|
+
function createSdkLogger(runtime, accountId) {
|
|
2046
|
+
return {
|
|
2047
|
+
debug: (message, ...args) => {
|
|
2048
|
+
runtime.log?.(`[${accountId}] ${message}`, ...args);
|
|
2049
|
+
},
|
|
2050
|
+
info: (message, ...args) => {
|
|
2051
|
+
runtime.log?.(`[${accountId}] ${message}`, ...args);
|
|
2052
|
+
},
|
|
2053
|
+
warn: (message, ...args) => {
|
|
2054
|
+
runtime.log?.(`[${accountId}] WARN: ${message}`, ...args);
|
|
2055
|
+
},
|
|
2056
|
+
error: (message, ...args) => {
|
|
2057
|
+
runtime.error?.(`[${accountId}] ${message}`, ...args);
|
|
2058
|
+
},
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
// ============================================================================
|
|
2062
|
+
// 主函数
|
|
2063
|
+
// ============================================================================
|
|
2064
|
+
/**
|
|
2065
|
+
* 监听企业微信 WebSocket 连接
|
|
2066
|
+
* 使用 aibot-node-sdk 简化连接管理
|
|
2067
|
+
*/
|
|
2068
|
+
async function monitorWeComProvider(options) {
|
|
2069
|
+
const { account, config, runtime, abortSignal } = options;
|
|
2070
|
+
runtime.log?.(`[${account.accountId}] Initializing WSClient with SDK...`);
|
|
2071
|
+
// 启动消息状态定期清理
|
|
2072
|
+
startMessageStateCleanup();
|
|
2073
|
+
return new Promise((resolve, reject) => {
|
|
2074
|
+
const logger = createSdkLogger(runtime, account.accountId);
|
|
2075
|
+
const wsClient = new aibotNodeSdk.WSClient({
|
|
2076
|
+
botId: account.botId,
|
|
2077
|
+
secret: account.secret,
|
|
2078
|
+
wsUrl: account.websocketUrl,
|
|
2079
|
+
logger,
|
|
2080
|
+
heartbeatInterval: WS_HEARTBEAT_INTERVAL_MS,
|
|
2081
|
+
maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
|
|
2082
|
+
});
|
|
2083
|
+
// 清理函数:确保所有资源被释放
|
|
2084
|
+
const cleanup = async () => {
|
|
2085
|
+
stopMessageStateCleanup();
|
|
2086
|
+
await cleanupAccount(account.accountId);
|
|
2087
|
+
};
|
|
2088
|
+
// 处理中止信号
|
|
2089
|
+
if (abortSignal) {
|
|
2090
|
+
abortSignal.addEventListener("abort", async () => {
|
|
2091
|
+
runtime.log?.(`[${account.accountId}] Connection aborted`);
|
|
2092
|
+
await cleanup();
|
|
2093
|
+
resolve();
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
// 监听连接事件
|
|
2097
|
+
wsClient.on("connected", () => {
|
|
2098
|
+
runtime.log?.(`[${account.accountId}] WebSocket connected`);
|
|
2099
|
+
});
|
|
2100
|
+
// 监听认证成功事件
|
|
2101
|
+
wsClient.on("authenticated", () => {
|
|
2102
|
+
runtime.log?.(`[${account.accountId}] Authentication successful`);
|
|
2103
|
+
setWeComWebSocket(account.accountId, wsClient);
|
|
2104
|
+
});
|
|
2105
|
+
// 监听断开事件
|
|
2106
|
+
wsClient.on("disconnected", (reason) => {
|
|
2107
|
+
runtime.log?.(`[${account.accountId}] WebSocket disconnected: ${reason}`);
|
|
2108
|
+
});
|
|
2109
|
+
// 监听重连事件
|
|
2110
|
+
wsClient.on("reconnecting", (attempt) => {
|
|
2111
|
+
runtime.log?.(`[${account.accountId}] Reconnecting attempt ${attempt}...`);
|
|
2112
|
+
});
|
|
2113
|
+
// 监听错误事件
|
|
2114
|
+
wsClient.on("error", (error) => {
|
|
2115
|
+
runtime.error?.(`[${account.accountId}] WebSocket error: ${error.message}`);
|
|
2116
|
+
// 认证失败时拒绝 Promise
|
|
2117
|
+
if (error.message.includes("Authentication failed")) {
|
|
2118
|
+
cleanup().finally(() => reject(error));
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
// 监听所有消息
|
|
2122
|
+
wsClient.on("message", async (frame) => {
|
|
2123
|
+
try {
|
|
2124
|
+
await processWeComMessage({
|
|
2125
|
+
frame,
|
|
2126
|
+
account,
|
|
2127
|
+
config,
|
|
2128
|
+
runtime,
|
|
2129
|
+
wsClient,
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
catch (err) {
|
|
2133
|
+
runtime.error?.(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
// 启动前预热 reqId 缓存,确保完成后再建立连接,避免 getSync 在预热完成前返回 undefined
|
|
2137
|
+
warmupReqIdStore(account.accountId, (...args) => runtime.log?.(...args))
|
|
2138
|
+
.then((count) => {
|
|
2139
|
+
runtime.log?.(`[${account.accountId}] Warmed up ${count} reqId entries from disk`);
|
|
2140
|
+
})
|
|
2141
|
+
.catch((err) => {
|
|
2142
|
+
runtime.error?.(`[${account.accountId}] Failed to warmup reqId store: ${String(err)}`);
|
|
2143
|
+
})
|
|
2144
|
+
.finally(() => {
|
|
2145
|
+
// 无论预热成功或失败,都建立连接
|
|
2146
|
+
wsClient.connect();
|
|
2147
|
+
});
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/**
|
|
2152
|
+
* 企业微信公共工具函数
|
|
2153
|
+
*/
|
|
2154
|
+
const DefaultWsUrl = "wss://openws.work.weixin.qq.com";
|
|
2155
|
+
/**
|
|
2156
|
+
* 迁移旧版配置到新版 accounts 数组格式
|
|
2157
|
+
*/
|
|
2158
|
+
function migrateToAccounts(wecomConfig) {
|
|
2159
|
+
// 如果已经有 accounts 数组,无需迁移
|
|
2160
|
+
if (wecomConfig.accounts && wecomConfig.accounts.length > 0) {
|
|
2161
|
+
return wecomConfig;
|
|
2162
|
+
}
|
|
2163
|
+
// 如果有旧的 botId/secret 配置,迁移到 accounts
|
|
2164
|
+
if (wecomConfig.botId || wecomConfig.secret) {
|
|
2165
|
+
const accountName = wecomConfig.name || "default";
|
|
2166
|
+
return {
|
|
2167
|
+
...wecomConfig,
|
|
2168
|
+
accounts: [
|
|
2169
|
+
{
|
|
2170
|
+
name: accountName,
|
|
2171
|
+
botId: wecomConfig.botId || "",
|
|
2172
|
+
secret: wecomConfig.secret || "",
|
|
2173
|
+
enabled: wecomConfig.enabled ?? true,
|
|
2174
|
+
websocketUrl: wecomConfig.websocketUrl,
|
|
2175
|
+
},
|
|
2176
|
+
],
|
|
2177
|
+
// 保留旧字段用于向后兼容(但优先使用 accounts)
|
|
2178
|
+
botId: undefined,
|
|
2179
|
+
secret: undefined,
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
return wecomConfig;
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* 从 accounts 数组中解析所有已启用的账户
|
|
2186
|
+
*/
|
|
2187
|
+
function resolveAccountsFromConfig(wecomConfig) {
|
|
2188
|
+
const accounts = wecomConfig.accounts ?? [];
|
|
2189
|
+
if (accounts.length === 0) {
|
|
2190
|
+
return [];
|
|
2191
|
+
}
|
|
2192
|
+
return accounts.map((account) => ({
|
|
2193
|
+
accountId: account.name,
|
|
2194
|
+
name: account.name,
|
|
2195
|
+
enabled: account.enabled ?? true,
|
|
2196
|
+
websocketUrl: account.websocketUrl || wecomConfig.websocketUrl || DefaultWsUrl,
|
|
2197
|
+
botId: account.botId ?? "",
|
|
2198
|
+
secret: account.secret ?? "",
|
|
2199
|
+
sendThinkingMessage: wecomConfig.sendThinkingMessage ?? true,
|
|
2200
|
+
config: wecomConfig,
|
|
2201
|
+
}));
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* 解析企业微信账户配置(返回所有已启用的账户)
|
|
2205
|
+
*/
|
|
2206
|
+
function resolveWeComAccounts(cfg) {
|
|
2207
|
+
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
2208
|
+
// 迁移旧配置到新格式
|
|
2209
|
+
const migratedConfig = migrateToAccounts(wecomConfig);
|
|
2210
|
+
return resolveAccountsFromConfig(migratedConfig);
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* 根据 name 查找单个账户配置
|
|
2214
|
+
*/
|
|
2215
|
+
function resolveWeComAccountByName(cfg, name) {
|
|
2216
|
+
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
2217
|
+
const migratedConfig = migrateToAccounts(wecomConfig);
|
|
2218
|
+
const accounts = migratedConfig.accounts ?? [];
|
|
2219
|
+
const account = accounts.find((a) => a.name === name);
|
|
2220
|
+
if (!account) {
|
|
2221
|
+
return null;
|
|
2222
|
+
}
|
|
2223
|
+
return {
|
|
2224
|
+
accountId: account.name,
|
|
2225
|
+
name: account.name,
|
|
2226
|
+
enabled: account.enabled ?? true,
|
|
2227
|
+
websocketUrl: account.websocketUrl || migratedConfig.websocketUrl || DefaultWsUrl,
|
|
2228
|
+
botId: account.botId ?? "",
|
|
2229
|
+
secret: account.secret ?? "",
|
|
2230
|
+
sendThinkingMessage: migratedConfig.sendThinkingMessage ?? true,
|
|
2231
|
+
config: migratedConfig,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* 获取所有账户 ID(从 accounts 数组中提取)
|
|
2236
|
+
*/
|
|
2237
|
+
function listWeComAccountIds(cfg) {
|
|
2238
|
+
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
2239
|
+
const migratedConfig = migrateToAccounts(wecomConfig);
|
|
2240
|
+
const accounts = migratedConfig.accounts ?? [];
|
|
2241
|
+
if (accounts.length > 0) {
|
|
2242
|
+
return accounts.map((a) => a.name);
|
|
2243
|
+
}
|
|
2244
|
+
// 兼容旧配置
|
|
2245
|
+
if (wecomConfig.botId || wecomConfig.secret) {
|
|
2246
|
+
return [wecomConfig.name || pluginSdk.DEFAULT_ACCOUNT_ID];
|
|
2247
|
+
}
|
|
2248
|
+
return [];
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* 添加或更新单个账户配置
|
|
2252
|
+
*/
|
|
2253
|
+
function setWeComAccountByName(cfg, name, accountConfig) {
|
|
2254
|
+
const existing = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
2255
|
+
const migratedConfig = migrateToAccounts(existing);
|
|
2256
|
+
const accounts = migratedConfig.accounts ?? [];
|
|
2257
|
+
const existingIndex = accounts.findIndex((a) => a.name === name);
|
|
2258
|
+
const newAccount = {
|
|
2259
|
+
name,
|
|
2260
|
+
botId: accountConfig.botId ?? "",
|
|
2261
|
+
secret: accountConfig.secret ?? "",
|
|
2262
|
+
enabled: accountConfig.enabled ?? true,
|
|
2263
|
+
websocketUrl: accountConfig.websocketUrl,
|
|
2264
|
+
};
|
|
2265
|
+
let newAccounts;
|
|
2266
|
+
if (existingIndex >= 0) {
|
|
2267
|
+
// 更新现有账户
|
|
2268
|
+
newAccounts = [...accounts];
|
|
2269
|
+
newAccounts[existingIndex] = { ...newAccounts[existingIndex], ...newAccount };
|
|
2270
|
+
}
|
|
2271
|
+
else {
|
|
2272
|
+
// 添加新账户
|
|
2273
|
+
newAccounts = [...accounts, newAccount];
|
|
2274
|
+
}
|
|
2275
|
+
return {
|
|
2276
|
+
...cfg,
|
|
2277
|
+
channels: {
|
|
2278
|
+
...cfg.channels,
|
|
2279
|
+
[CHANNEL_ID]: {
|
|
2280
|
+
...migratedConfig,
|
|
2281
|
+
accounts: newAccounts,
|
|
2282
|
+
// 保留全局配置
|
|
2283
|
+
dmPolicy: existing.dmPolicy,
|
|
2284
|
+
allowFrom: existing.allowFrom,
|
|
2285
|
+
groupPolicy: existing.groupPolicy,
|
|
2286
|
+
groupAllowFrom: existing.groupAllowFrom,
|
|
2287
|
+
groups: existing.groups,
|
|
2288
|
+
sendThinkingMessage: existing.sendThinkingMessage,
|
|
2289
|
+
mediaLocalRoots: existing.mediaLocalRoots,
|
|
2290
|
+
},
|
|
2291
|
+
},
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* 根据 name 删除账户
|
|
2296
|
+
*/
|
|
2297
|
+
function deleteWeComAccount(cfg, name) {
|
|
2298
|
+
const existing = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
2299
|
+
const migratedConfig = migrateToAccounts(existing);
|
|
2300
|
+
const accounts = migratedConfig.accounts ?? [];
|
|
2301
|
+
const newAccounts = accounts.filter((a) => a.name !== name);
|
|
2302
|
+
// 如果删除后没有账户了,保留空数组
|
|
2303
|
+
return {
|
|
2304
|
+
...cfg,
|
|
2305
|
+
channels: {
|
|
2306
|
+
...cfg.channels,
|
|
2307
|
+
[CHANNEL_ID]: {
|
|
2308
|
+
...migratedConfig,
|
|
2309
|
+
accounts: newAccounts,
|
|
2310
|
+
},
|
|
2311
|
+
},
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* 设置账户启用状态
|
|
2316
|
+
*/
|
|
2317
|
+
function setWeComAccountEnabled(cfg, name, enabled) {
|
|
2318
|
+
return setWeComAccountByName(cfg, name, { enabled });
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
/**
|
|
2322
|
+
* 企业微信 onboarding adapter for CLI setup wizard.
|
|
2323
|
+
*/
|
|
2324
|
+
const channel = CHANNEL_ID;
|
|
2325
|
+
/**
|
|
2326
|
+
* 企业微信设置帮助说明
|
|
2327
|
+
*/
|
|
2328
|
+
async function noteWeComSetupHelp(prompter) {
|
|
2329
|
+
await prompter.note([
|
|
2330
|
+
"企业微信机器人需要以下配置信息:",
|
|
2331
|
+
"1. 账户名称:用于区分不同账户(必填)",
|
|
2332
|
+
"2. Bot ID:企业微信机器人id",
|
|
2333
|
+
"3. Secret:企业微信机器人密钥",
|
|
2334
|
+
].join("\n"), "企业微信设置");
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* 提示输入账户名称
|
|
2338
|
+
*/
|
|
2339
|
+
async function promptAccountName(prompter, existingNames) {
|
|
2340
|
+
// 生成默认名称
|
|
2341
|
+
let defaultName = "bot1";
|
|
2342
|
+
let counter = 1;
|
|
2343
|
+
while (existingNames.includes(defaultName)) {
|
|
2344
|
+
counter++;
|
|
2345
|
+
defaultName = `bot${counter}`;
|
|
2346
|
+
}
|
|
2347
|
+
return String(await prompter.text({
|
|
2348
|
+
message: "账户名称(用于区分不同机器人)",
|
|
2349
|
+
initialValue: defaultName,
|
|
2350
|
+
validate: (value) => {
|
|
2351
|
+
const name = value?.trim();
|
|
2352
|
+
if (!name)
|
|
2353
|
+
return "Required";
|
|
2354
|
+
if (existingNames.includes(name)) {
|
|
2355
|
+
return `名称 "${name}" 已存在,请使用其他名称`;
|
|
2356
|
+
}
|
|
2357
|
+
return undefined;
|
|
2358
|
+
},
|
|
2359
|
+
})).trim();
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* 提示输入 Bot ID
|
|
2363
|
+
*/
|
|
2364
|
+
async function promptBotId(prompter, account) {
|
|
2365
|
+
return String(await prompter.text({
|
|
2366
|
+
message: "企业微信机器人 Bot ID",
|
|
2367
|
+
initialValue: account?.botId ?? "",
|
|
2368
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
2369
|
+
})).trim();
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2372
|
+
* 提示输入 Secret
|
|
2373
|
+
*/
|
|
2374
|
+
async function promptSecret(prompter, account) {
|
|
2375
|
+
return String(await prompter.text({
|
|
2376
|
+
message: "企业微信机器人 Secret",
|
|
2377
|
+
initialValue: account?.secret ?? "",
|
|
2378
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
2379
|
+
})).trim();
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* 设置企业微信 dmPolicy
|
|
2383
|
+
*/
|
|
2384
|
+
function setWeComDmPolicy(cfg, dmPolicy, accountId) {
|
|
2385
|
+
const account = accountId
|
|
2386
|
+
? resolveWeComAccountByName(cfg, accountId)
|
|
2387
|
+
: resolveWeComAccounts(cfg)[0];
|
|
2388
|
+
if (!account)
|
|
2389
|
+
return cfg;
|
|
2390
|
+
const existingAllowFrom = account.config.allowFrom ?? [];
|
|
2391
|
+
dmPolicy === "open"
|
|
2392
|
+
? pluginSdk.addWildcardAllowFrom(existingAllowFrom.map((x) => String(x)))
|
|
2393
|
+
: existingAllowFrom.map((x) => String(x));
|
|
2394
|
+
return setWeComAccountByName(cfg, account.accountId, {
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
const dmPolicy = {
|
|
2398
|
+
label: "企业微信",
|
|
2399
|
+
channel,
|
|
2400
|
+
policyKey: `channels.${CHANNEL_ID}.dmPolicy`,
|
|
2401
|
+
allowFromKey: `channels.${CHANNEL_ID}.allowFrom`,
|
|
2402
|
+
getCurrent: (cfg) => {
|
|
2403
|
+
const accounts = resolveWeComAccounts(cfg);
|
|
2404
|
+
if (accounts.length === 0)
|
|
2405
|
+
return "open";
|
|
2406
|
+
return accounts[0].config.dmPolicy ?? "open";
|
|
2407
|
+
},
|
|
2408
|
+
setPolicy: (cfg, policy, accountId) => {
|
|
2409
|
+
return setWeComDmPolicy(cfg, policy, accountId);
|
|
2410
|
+
},
|
|
2411
|
+
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
|
2412
|
+
const account = accountId
|
|
2413
|
+
? resolveWeComAccountByName(cfg, accountId)
|
|
2414
|
+
: resolveWeComAccounts(cfg)[0];
|
|
2415
|
+
if (!account) {
|
|
2416
|
+
return cfg;
|
|
2417
|
+
}
|
|
2418
|
+
const existingAllowFrom = account.config.allowFrom ?? [];
|
|
2419
|
+
const entry = await prompter.text({
|
|
2420
|
+
message: "企业微信允许来源(用户ID或群组ID,每行一个,推荐用于安全控制)",
|
|
2421
|
+
placeholder: "user123 或 group456",
|
|
2422
|
+
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
2423
|
+
});
|
|
2424
|
+
String(entry ?? "")
|
|
2425
|
+
.split(/[\n,;]+/g)
|
|
2426
|
+
.map((s) => s.trim())
|
|
2427
|
+
.filter(Boolean);
|
|
2428
|
+
return setWeComAccountByName(cfg, account.accountId, { });
|
|
2429
|
+
},
|
|
2430
|
+
};
|
|
2431
|
+
const wecomOnboardingAdapter = {
|
|
2432
|
+
channel,
|
|
2433
|
+
getStatus: async ({ cfg }) => {
|
|
2434
|
+
const accounts = resolveWeComAccounts(cfg);
|
|
2435
|
+
const configured = accounts.some((a) => a.botId?.trim() && a.secret?.trim());
|
|
2436
|
+
const accountNames = accounts.map((a) => a.name).join(", ");
|
|
2437
|
+
return {
|
|
2438
|
+
channel,
|
|
2439
|
+
configured,
|
|
2440
|
+
statusLines: [
|
|
2441
|
+
`企业微信: ${configured ? `${accountNames || "已配置"}` : "需要 Bot ID 和 Secret"}`,
|
|
2442
|
+
],
|
|
2443
|
+
selectionHint: configured ? accountNames || "已配置" : "需要设置",
|
|
2444
|
+
};
|
|
2445
|
+
},
|
|
2446
|
+
configure: async ({ cfg, prompter, forceAllowFrom, accountId }) => {
|
|
2447
|
+
// 如果指定了 accountId,则更新该账户;否则创建新账户或更新第一个账户
|
|
2448
|
+
let targetAccountId = accountId;
|
|
2449
|
+
const existingAccounts = resolveWeComAccounts(cfg);
|
|
2450
|
+
const existingNames = existingAccounts.map((a) => a.name);
|
|
2451
|
+
if (!targetAccountId) {
|
|
2452
|
+
// 没有指定账户,提示输入新账户名称
|
|
2453
|
+
const accountName = await promptAccountName(prompter, existingNames);
|
|
2454
|
+
targetAccountId = accountName;
|
|
2455
|
+
}
|
|
2456
|
+
// 获取现有账户配置(如果有)
|
|
2457
|
+
const existingAccount = resolveWeComAccountByName(cfg, targetAccountId);
|
|
2458
|
+
if (!existingAccount?.botId?.trim() || !existingAccount?.secret?.trim()) {
|
|
2459
|
+
await noteWeComSetupHelp(prompter);
|
|
2460
|
+
}
|
|
2461
|
+
// 提示输入必要的配置信息:Bot ID 和 Secret
|
|
2462
|
+
const botId = await promptBotId(prompter, existingAccount);
|
|
2463
|
+
const secret = await promptSecret(prompter, existingAccount);
|
|
2464
|
+
// 使用 setWeComAccountByName 更新或添加账户
|
|
2465
|
+
const cfgWithAccount = setWeComAccountByName(cfg, targetAccountId, {
|
|
2466
|
+
botId,
|
|
2467
|
+
secret,
|
|
2468
|
+
enabled: true,
|
|
2469
|
+
});
|
|
2470
|
+
// 保留全局策略配置
|
|
2471
|
+
if (existingAccounts.length > 0 && existingAccounts[0].config.dmPolicy) ;
|
|
2472
|
+
return { cfg: cfgWithAccount, accountId: targetAccountId };
|
|
2473
|
+
},
|
|
2474
|
+
dmPolicy,
|
|
2475
|
+
disable: (cfg, accountId) => {
|
|
2476
|
+
if (accountId) {
|
|
2477
|
+
return setWeComAccountByName(cfg, accountId, { enabled: false });
|
|
2478
|
+
}
|
|
2479
|
+
// 没有指定账户,禁用所有账户
|
|
2480
|
+
const accounts = resolveWeComAccounts(cfg);
|
|
2481
|
+
let result = cfg;
|
|
2482
|
+
for (const account of accounts) {
|
|
2483
|
+
result = setWeComAccountByName(result, account.accountId, { enabled: false });
|
|
2484
|
+
}
|
|
2485
|
+
return result;
|
|
2486
|
+
},
|
|
2487
|
+
};
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* 使用 SDK 的 sendMessage 主动发送企业微信消息
|
|
2491
|
+
* 无需依赖 reqId,直接向指定会话推送消息
|
|
2492
|
+
*/
|
|
2493
|
+
async function sendWeComMessage({ to, content, accountId, }) {
|
|
2494
|
+
const resolvedAccountId = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2495
|
+
// 从 to 中提取 chatId(格式是 "${CHANNEL_ID}:chatId" 或直接是 chatId)
|
|
2496
|
+
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
2497
|
+
const chatId = to.replace(channelPrefix, "");
|
|
2498
|
+
// 获取 WSClient 实例
|
|
2499
|
+
const wsClient = getWeComWebSocket(resolvedAccountId);
|
|
2500
|
+
if (!wsClient) {
|
|
2501
|
+
throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
|
|
2502
|
+
}
|
|
2503
|
+
// 使用 SDK 的 sendMessage 主动发送 markdown 消息
|
|
2504
|
+
const result = await wsClient.sendMessage(chatId, {
|
|
2505
|
+
msgtype: 'markdown',
|
|
2506
|
+
markdown: { content },
|
|
2507
|
+
});
|
|
2508
|
+
const messageId = result?.headers?.req_id ?? `wecom-${Date.now()}`;
|
|
2509
|
+
return {
|
|
2510
|
+
channel: CHANNEL_ID,
|
|
2511
|
+
messageId,
|
|
2512
|
+
chatId,
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
// 企业微信频道元数据
|
|
2516
|
+
const meta = {
|
|
2517
|
+
id: CHANNEL_ID,
|
|
2518
|
+
label: "企业微信",
|
|
2519
|
+
selectionLabel: "企业微信 (WeCom)",
|
|
2520
|
+
detailLabel: "企业微信智能机器人",
|
|
2521
|
+
docsPath: `/channels/${CHANNEL_ID}`,
|
|
2522
|
+
docsLabel: CHANNEL_ID,
|
|
2523
|
+
blurb: "企业微信智能机器人接入插件",
|
|
2524
|
+
systemImage: "message.fill",
|
|
2525
|
+
};
|
|
2526
|
+
const wecomPlugin = {
|
|
2527
|
+
id: CHANNEL_ID,
|
|
2528
|
+
meta: {
|
|
2529
|
+
...meta,
|
|
2530
|
+
quickstartAllowFrom: true,
|
|
2531
|
+
},
|
|
2532
|
+
pairing: {
|
|
2533
|
+
idLabel: "wecomUserId",
|
|
2534
|
+
normalizeAllowEntry: (entry) => entry.replace(new RegExp(`^(${CHANNEL_ID}|user):`, "i"), "").trim(),
|
|
2535
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
2536
|
+
// sendWeComMessage({
|
|
2537
|
+
// to: id,
|
|
2538
|
+
// content: " pairing approved",
|
|
2539
|
+
// accountId: cfg.accountId,
|
|
2540
|
+
// });
|
|
2541
|
+
// Pairing approved for user
|
|
2542
|
+
},
|
|
2543
|
+
},
|
|
2544
|
+
onboarding: wecomOnboardingAdapter,
|
|
2545
|
+
capabilities: {
|
|
2546
|
+
chatTypes: ["direct", "group"],
|
|
2547
|
+
reactions: false,
|
|
2548
|
+
threads: false,
|
|
2549
|
+
media: true,
|
|
2550
|
+
nativeCommands: false,
|
|
2551
|
+
blockStreaming: true,
|
|
2552
|
+
},
|
|
2553
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
2554
|
+
config: {
|
|
2555
|
+
// 列出所有账户 ID
|
|
2556
|
+
listAccountIds: (cfg) => listWeComAccountIds(cfg),
|
|
2557
|
+
// 解析账户配置(根据 accountId 查找)
|
|
2558
|
+
resolveAccount: (cfg, accountId) => {
|
|
2559
|
+
const id = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2560
|
+
return resolveWeComAccountByName(cfg, id);
|
|
2561
|
+
},
|
|
2562
|
+
// 获取默认账户 ID(第一个启用的账户)
|
|
2563
|
+
defaultAccountId: (cfg) => {
|
|
2564
|
+
const accounts = resolveWeComAccounts(cfg);
|
|
2565
|
+
const enabled = accounts.filter((a) => a.enabled);
|
|
2566
|
+
return enabled.length > 0 ? enabled[0].accountId : pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2567
|
+
},
|
|
2568
|
+
// 设置账户启用状态
|
|
2569
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
2570
|
+
const id = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2571
|
+
return setWeComAccountEnabled(cfg, id, enabled);
|
|
2572
|
+
},
|
|
2573
|
+
// 删除账户
|
|
2574
|
+
deleteAccount: ({ cfg, accountId }) => {
|
|
2575
|
+
const id = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2576
|
+
return deleteWeComAccount(cfg, id);
|
|
2577
|
+
},
|
|
2578
|
+
// 检查是否已配置
|
|
2579
|
+
isConfigured: (account) => Boolean(account.botId?.trim() && account.secret?.trim()),
|
|
2580
|
+
// 描述账户信息
|
|
2581
|
+
describeAccount: (account) => ({
|
|
2582
|
+
accountId: account.accountId,
|
|
2583
|
+
name: account.name,
|
|
2584
|
+
enabled: account.enabled,
|
|
2585
|
+
configured: Boolean(account.botId?.trim() && account.secret?.trim()),
|
|
2586
|
+
botId: account.botId,
|
|
2587
|
+
websocketUrl: account.websocketUrl,
|
|
2588
|
+
}),
|
|
2589
|
+
// 解析允许来源列表
|
|
2590
|
+
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
2591
|
+
const id = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2592
|
+
const account = resolveWeComAccountByName(cfg, id);
|
|
2593
|
+
return (account?.config.allowFrom ?? []).map((entry) => String(entry));
|
|
2594
|
+
},
|
|
2595
|
+
// 格式化允许来源列表
|
|
2596
|
+
formatAllowFrom: ({ allowFrom }) => allowFrom
|
|
2597
|
+
.map((entry) => String(entry).trim())
|
|
2598
|
+
.filter(Boolean),
|
|
2599
|
+
},
|
|
2600
|
+
security: {
|
|
2601
|
+
resolveDmPolicy: ({ account }) => {
|
|
2602
|
+
const basePath = `channels.${CHANNEL_ID}.`;
|
|
2603
|
+
return {
|
|
2604
|
+
policy: account?.config.dmPolicy ?? "open",
|
|
2605
|
+
allowFrom: account?.config.allowFrom ?? [],
|
|
2606
|
+
policyPath: `${basePath}dmPolicy`,
|
|
2607
|
+
allowFromPath: basePath,
|
|
2608
|
+
approveHint: pluginSdk.formatPairingApproveHint(CHANNEL_ID),
|
|
2609
|
+
normalizeEntry: (raw) => raw.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim(),
|
|
2610
|
+
};
|
|
2611
|
+
},
|
|
2612
|
+
collectWarnings: ({ account, cfg }) => {
|
|
2613
|
+
const warnings = [];
|
|
2614
|
+
if (!account)
|
|
2615
|
+
return warnings;
|
|
2616
|
+
// DM 策略警告
|
|
2617
|
+
const dmPolicy = account.config.dmPolicy ?? "open";
|
|
2618
|
+
if (dmPolicy === "open") {
|
|
2619
|
+
const hasWildcard = (account.config.allowFrom ?? []).some((entry) => String(entry).trim() === "*");
|
|
2620
|
+
if (!hasWildcard) {
|
|
2621
|
+
warnings.push(`- 企业微信私信:dmPolicy="open" 但 allowFrom 未包含 "*"。任何人都可以发消息,但允许列表为空可能导致意外行为。建议设置 channels.${CHANNEL_ID}.allowFrom=["*"] 或使用 dmPolicy="pairing"。`);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
// 群组策略警告
|
|
2625
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
2626
|
+
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
|
|
2627
|
+
if (groupPolicy === "open") {
|
|
2628
|
+
warnings.push(`- 企业微信群组:groupPolicy="open" 允许所有群组中的成员触发。设置 channels.${CHANNEL_ID}.groupPolicy="allowlist" + channels.${CHANNEL_ID}.groupAllowFrom 来限制群组。`);
|
|
2629
|
+
}
|
|
2630
|
+
return warnings;
|
|
2631
|
+
},
|
|
2632
|
+
},
|
|
2633
|
+
messaging: {
|
|
2634
|
+
normalizeTarget: (target) => {
|
|
2635
|
+
const trimmed = target.trim();
|
|
2636
|
+
if (!trimmed)
|
|
2637
|
+
return undefined;
|
|
2638
|
+
return trimmed;
|
|
2639
|
+
},
|
|
2640
|
+
targetResolver: {
|
|
2641
|
+
looksLikeId: (id) => {
|
|
2642
|
+
const trimmed = id?.trim();
|
|
2643
|
+
return Boolean(trimmed);
|
|
2644
|
+
},
|
|
2645
|
+
hint: "<userId|groupId>",
|
|
2646
|
+
},
|
|
2647
|
+
},
|
|
2648
|
+
directory: {
|
|
2649
|
+
self: async () => null,
|
|
2650
|
+
listPeers: async () => [],
|
|
2651
|
+
listGroups: async () => [],
|
|
2652
|
+
},
|
|
2653
|
+
outbound: {
|
|
2654
|
+
deliveryMode: "gateway",
|
|
2655
|
+
chunker: (text, limit) => getWeComRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
2656
|
+
textChunkLimit: TEXT_CHUNK_LIMIT,
|
|
2657
|
+
sendText: async ({ to, text, accountId }) => {
|
|
2658
|
+
return sendWeComMessage({ to, content: text, accountId: accountId ?? undefined });
|
|
2659
|
+
},
|
|
2660
|
+
sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId }) => {
|
|
2661
|
+
const resolvedAccountId = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2662
|
+
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
2663
|
+
const chatId = to.replace(channelPrefix, "");
|
|
2664
|
+
// 获取 WSClient 实例
|
|
2665
|
+
const wsClient = getWeComWebSocket(resolvedAccountId);
|
|
2666
|
+
if (!wsClient) {
|
|
2667
|
+
throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
|
|
2668
|
+
}
|
|
2669
|
+
// 如果没有 mediaUrl,fallback 为纯文本
|
|
2670
|
+
if (!mediaUrl) {
|
|
2671
|
+
return sendWeComMessage({ to, content: text || "", accountId: resolvedAccountId });
|
|
2672
|
+
}
|
|
2673
|
+
const result = await uploadAndSendMedia({
|
|
2674
|
+
wsClient,
|
|
2675
|
+
mediaUrl,
|
|
2676
|
+
chatId,
|
|
2677
|
+
mediaLocalRoots,
|
|
2678
|
+
});
|
|
2679
|
+
if (result.rejected) {
|
|
2680
|
+
return sendWeComMessage({ to, content: `⚠️ ${result.rejectReason}`, accountId: resolvedAccountId });
|
|
2681
|
+
}
|
|
2682
|
+
if (!result.ok) {
|
|
2683
|
+
// 上传/发送失败,fallback 为文本 + URL
|
|
2684
|
+
const fallbackContent = text
|
|
2685
|
+
? `${text}\n📎 ${mediaUrl}`
|
|
2686
|
+
: `📎 ${mediaUrl}`;
|
|
2687
|
+
return sendWeComMessage({ to, content: fallbackContent, accountId: resolvedAccountId });
|
|
2688
|
+
}
|
|
2689
|
+
// 如有伴随文本,额外发送一条 markdown
|
|
2690
|
+
if (text) {
|
|
2691
|
+
await sendWeComMessage({ to, content: text, accountId: resolvedAccountId });
|
|
2692
|
+
}
|
|
2693
|
+
// 如果有降级说明,额外发送提示
|
|
2694
|
+
if (result.downgradeNote) {
|
|
2695
|
+
await sendWeComMessage({ to, content: `ℹ️ ${result.downgradeNote}`, accountId: resolvedAccountId });
|
|
2696
|
+
}
|
|
2697
|
+
return {
|
|
2698
|
+
channel: CHANNEL_ID,
|
|
2699
|
+
messageId: result.messageId,
|
|
2700
|
+
chatId,
|
|
2701
|
+
};
|
|
2702
|
+
},
|
|
2703
|
+
},
|
|
2704
|
+
status: {
|
|
2705
|
+
defaultRuntime: {
|
|
2706
|
+
accountId: pluginSdk.DEFAULT_ACCOUNT_ID,
|
|
2707
|
+
running: false,
|
|
2708
|
+
lastStartAt: null,
|
|
2709
|
+
lastStopAt: null,
|
|
2710
|
+
lastError: null,
|
|
2711
|
+
},
|
|
2712
|
+
collectStatusIssues: (accounts) => accounts.flatMap((entry) => {
|
|
2713
|
+
const accountId = String(entry.accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID);
|
|
2714
|
+
const enabled = entry.enabled !== false;
|
|
2715
|
+
const configured = entry.configured === true;
|
|
2716
|
+
if (!enabled) {
|
|
2717
|
+
return [];
|
|
2718
|
+
}
|
|
2719
|
+
const issues = [];
|
|
2720
|
+
if (!configured) {
|
|
2721
|
+
issues.push({
|
|
2722
|
+
channel: CHANNEL_ID,
|
|
2723
|
+
accountId,
|
|
2724
|
+
kind: "config",
|
|
2725
|
+
message: "企业微信机器人 ID 或 Secret 未配置",
|
|
2726
|
+
fix: `Run: openclaw channels add wecom --account ${accountId} --bot-id <id> --secret <secret>`,
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
return issues;
|
|
2730
|
+
}),
|
|
2731
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
2732
|
+
configured: snapshot.configured ?? false,
|
|
2733
|
+
running: snapshot.running ?? false,
|
|
2734
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
2735
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
2736
|
+
lastError: snapshot.lastError ?? null,
|
|
2737
|
+
}),
|
|
2738
|
+
probeAccount: async () => {
|
|
2739
|
+
return { ok: true, status: 200 };
|
|
2740
|
+
},
|
|
2741
|
+
buildAccountSnapshot: ({ account, runtime }) => {
|
|
2742
|
+
const configured = Boolean(account?.botId?.trim() &&
|
|
2743
|
+
account?.secret?.trim());
|
|
2744
|
+
return {
|
|
2745
|
+
accountId: account?.accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID,
|
|
2746
|
+
name: account?.name,
|
|
2747
|
+
enabled: account?.enabled ?? false,
|
|
2748
|
+
configured,
|
|
2749
|
+
running: runtime?.running ?? false,
|
|
2750
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
2751
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
2752
|
+
lastError: runtime?.lastError ?? null,
|
|
2753
|
+
};
|
|
2754
|
+
},
|
|
2755
|
+
},
|
|
2756
|
+
gateway: {
|
|
2757
|
+
startAccount: async (ctx) => {
|
|
2758
|
+
const account = ctx.account;
|
|
2759
|
+
// 启动 WebSocket 监听
|
|
2760
|
+
return monitorWeComProvider({
|
|
2761
|
+
account,
|
|
2762
|
+
config: ctx.cfg,
|
|
2763
|
+
runtime: ctx.runtime,
|
|
2764
|
+
abortSignal: ctx.abortSignal,
|
|
2765
|
+
});
|
|
2766
|
+
},
|
|
2767
|
+
logoutAccount: async ({ cfg, accountId }) => {
|
|
2768
|
+
const nextCfg = { ...cfg };
|
|
2769
|
+
const id = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
2770
|
+
// 删除指定账户
|
|
2771
|
+
const nextWecom = deleteWeComAccount(nextCfg, id);
|
|
2772
|
+
// 检查是否还有其他账户
|
|
2773
|
+
const remainingAccounts = listWeComAccountIds(nextCfg);
|
|
2774
|
+
if (remainingAccounts.length === 0) {
|
|
2775
|
+
// 没有剩余账户,删除整个频道配置
|
|
2776
|
+
const nextChannels = { ...nextCfg.channels };
|
|
2777
|
+
delete nextChannels[CHANNEL_ID];
|
|
2778
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
2779
|
+
nextCfg.channels = nextChannels;
|
|
2780
|
+
}
|
|
2781
|
+
else {
|
|
2782
|
+
delete nextCfg.channels;
|
|
2783
|
+
}
|
|
2784
|
+
await getWeComRuntime().config.writeConfigFile(nextCfg);
|
|
2785
|
+
}
|
|
2786
|
+
else {
|
|
2787
|
+
// 有剩余账户,写入更新后的配置
|
|
2788
|
+
await getWeComRuntime().config.writeConfigFile(nextWecom);
|
|
2789
|
+
}
|
|
2790
|
+
const resolved = resolveWeComAccountByName(cfg, id);
|
|
2791
|
+
const loggedOut = !resolved?.botId && !resolved?.secret;
|
|
2792
|
+
return { cleared: true, envToken: false, loggedOut };
|
|
2793
|
+
},
|
|
2794
|
+
},
|
|
2795
|
+
};
|
|
2796
|
+
|
|
2797
|
+
/** 从 package.json 中读取版本号,兼容打包产物和直接运行 .ts 两种场景 */
|
|
2798
|
+
const getVersion = () => {
|
|
2799
|
+
try {
|
|
2800
|
+
// ESM 环境使用 import.meta.url,CJS 环境使用全局 __dirname
|
|
2801
|
+
const currentDir = path.dirname(node_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))));
|
|
2802
|
+
// 直接运行 .ts 时在 src/ 下,打包后在 dist/ 下,都向上一级找 package.json
|
|
2803
|
+
const pkgPath = path.resolve(currentDir, "..", "package.json");
|
|
2804
|
+
const pkg = JSON.parse(node_fs.readFileSync(pkgPath, "utf-8"));
|
|
2805
|
+
return pkg.version ?? "";
|
|
2806
|
+
}
|
|
2807
|
+
catch {
|
|
2808
|
+
return "";
|
|
2809
|
+
}
|
|
2810
|
+
};
|
|
2811
|
+
/** 插件版本号,来源于 package.json */
|
|
2812
|
+
const PLUGIN_VERSION = getVersion();
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* MCP Streamable HTTP 传输层模块
|
|
2816
|
+
*
|
|
2817
|
+
* 负责:
|
|
2818
|
+
* - MCP JSON-RPC over HTTP 通信(发送请求、解析响应)
|
|
2819
|
+
* - Streamable HTTP session 生命周期管理(initialize 握手 → Mcp-Session-Id 维护 → 失效重建)
|
|
2820
|
+
* - 自动检测无状态 Server:如果 initialize 响应未返回 Mcp-Session-Id,
|
|
2821
|
+
* 则标记为无状态模式,后续请求跳过握手和 session 管理
|
|
2822
|
+
* - SSE 流式响应解析
|
|
2823
|
+
* - MCP 配置运行时缓存(通过 WSClient 拉取 URL 并缓存在内存中)
|
|
2824
|
+
*/
|
|
2825
|
+
// ============================================================================
|
|
2826
|
+
// 内部状态
|
|
2827
|
+
// ============================================================================
|
|
2828
|
+
/** HTTP 请求超时时间(毫秒) */
|
|
2829
|
+
const HTTP_REQUEST_TIMEOUT_MS = 30000;
|
|
2830
|
+
/** 日志前缀 */
|
|
2831
|
+
const LOG_TAG = "[mcp]";
|
|
2832
|
+
/**
|
|
2833
|
+
* MCP JSON-RPC 错误
|
|
2834
|
+
*
|
|
2835
|
+
* 携带服务端返回的 JSON-RPC error.code,
|
|
2836
|
+
* 用于上层按错误码进行差异化处理(如特定错误码触发缓存清理)。
|
|
2837
|
+
*/
|
|
2838
|
+
class McpRpcError extends Error {
|
|
2839
|
+
constructor(code, message, data) {
|
|
2840
|
+
super(message);
|
|
2841
|
+
this.code = code;
|
|
2842
|
+
this.data = data;
|
|
2843
|
+
this.name = "McpRpcError";
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
/**
|
|
2847
|
+
* MCP HTTP 错误
|
|
2848
|
+
*
|
|
2849
|
+
* 携带 HTTP 状态码,用于精确判断 session 失效(404)等场景,
|
|
2850
|
+
* 避免通过字符串匹配 "404" 导致的误判。
|
|
2851
|
+
*/
|
|
2852
|
+
class McpHttpError extends Error {
|
|
2853
|
+
constructor(statusCode, message) {
|
|
2854
|
+
super(message);
|
|
2855
|
+
this.statusCode = statusCode;
|
|
2856
|
+
this.name = "McpHttpError";
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
/**
|
|
2860
|
+
* 需要清理缓存的 JSON-RPC 错误码集合
|
|
2861
|
+
*
|
|
2862
|
+
* 当 MCP Server 返回以下错误码时,说明服务端状态已发生变化(如配置变更、
|
|
2863
|
+
* 服务重启等),需要清理对应 category 的全部缓存,确保下次请求重新
|
|
2864
|
+
* 拉取配置并重建会话。
|
|
2865
|
+
*
|
|
2866
|
+
* - -32001: 服务不可用(Server Unavailable)
|
|
2867
|
+
* - -32002: 配置已变更(Config Changed)
|
|
2868
|
+
* - -32003: 认证失败(Auth Failed)
|
|
2869
|
+
*/
|
|
2870
|
+
const CACHE_CLEAR_ERROR_CODES = new Set([-32001, -32002, -32003]);
|
|
2871
|
+
/** MCP 配置缓存:category → response.body(完整配置) */
|
|
2872
|
+
const mcpConfigCache = new Map();
|
|
2873
|
+
/** Streamable HTTP 会话缓存:category → session */
|
|
2874
|
+
const mcpSessionCache = new Map();
|
|
2875
|
+
/** 已确认为无状态的 MCP Server 品类集合(跳过后续握手) */
|
|
2876
|
+
const statelessCategories = new Set();
|
|
2877
|
+
/** 正在进行中的 initialize 请求(防止并发重复初始化),key 为 category */
|
|
2878
|
+
const inflightInitRequests = new Map();
|
|
2879
|
+
// ============================================================================
|
|
2880
|
+
// MCP 配置拉取与缓存
|
|
2881
|
+
// ============================================================================
|
|
2882
|
+
/**
|
|
2883
|
+
* 通过 WSClient 拉取指定 category 的 MCP 完整配置
|
|
2884
|
+
*
|
|
2885
|
+
* @param category - MCP 品类名称,如 doc、contact
|
|
2886
|
+
* @param accountId - 账户 ID(可选,默认使用 DEFAULT_ACCOUNT_ID)
|
|
2887
|
+
* @returns 完整的 response.body 配置对象(至少包含 url 字段)
|
|
2888
|
+
*/
|
|
2889
|
+
async function fetchMcpConfig(category, accountId = pluginSdk.DEFAULT_ACCOUNT_ID) {
|
|
2890
|
+
const wsClient = getWeComWebSocket(accountId);
|
|
2891
|
+
if (!wsClient) {
|
|
2892
|
+
throw new Error(`WSClient 未连接,无法拉取 MCP 配置 (accountId="${accountId}")`);
|
|
2893
|
+
}
|
|
2894
|
+
const reqId = aibotNodeSdk.generateReqId("mcp_config");
|
|
2895
|
+
const response = await withTimeout(wsClient.reply({ headers: { req_id: reqId } }, { biz_type: category, plugin_version: PLUGIN_VERSION }, MCP_GET_CONFIG_CMD), MCP_CONFIG_FETCH_TIMEOUT_MS, `MCP config fetch for "${category}" timed out after ${MCP_CONFIG_FETCH_TIMEOUT_MS}ms`);
|
|
2896
|
+
if (response.errcode !== undefined && response.errcode !== 0) {
|
|
2897
|
+
const errMsg = `MCP 配置请求失败: errcode=${response.errcode}, errmsg=${response.errmsg ?? "unknown"}`;
|
|
2898
|
+
console.error(`${LOG_TAG} ${errMsg}`);
|
|
2899
|
+
throw new Error(errMsg);
|
|
2900
|
+
}
|
|
2901
|
+
const body = response.body;
|
|
2902
|
+
if (!body?.url) {
|
|
2903
|
+
throw new Error(`MCP 配置响应缺少 url 字段 (category="${category}")`);
|
|
2904
|
+
}
|
|
2905
|
+
console.log(`${LOG_TAG} 配置拉取成功 (category="${category}", accountId="${accountId}")`);
|
|
2906
|
+
return body;
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2909
|
+
* 获取指定品类的 MCP Server URL
|
|
2910
|
+
*
|
|
2911
|
+
* 优先从内存缓存中读取,未命中时通过 WSClient 拉取并缓存。
|
|
2912
|
+
*
|
|
2913
|
+
* @param category - MCP 品类名称
|
|
2914
|
+
* @param accountId - 账户 ID
|
|
2915
|
+
* @returns MCP Server URL
|
|
2916
|
+
*/
|
|
2917
|
+
async function getMcpUrl(category, accountId = pluginSdk.DEFAULT_ACCOUNT_ID) {
|
|
2918
|
+
// 使用 accountId + category 作为缓存 key,实现多账户隔离
|
|
2919
|
+
const cacheKey = `${accountId}:${category}`;
|
|
2920
|
+
// 查内存缓存
|
|
2921
|
+
const cached = mcpConfigCache.get(cacheKey);
|
|
2922
|
+
if (cached)
|
|
2923
|
+
return cached.url;
|
|
2924
|
+
// 缓存未命中,通过 WSClient 拉取
|
|
2925
|
+
const body = await fetchMcpConfig(category, accountId);
|
|
2926
|
+
// 写入缓存
|
|
2927
|
+
mcpConfigCache.set(cacheKey, body);
|
|
2928
|
+
console.log(`${LOG_TAG} getMcpUrl ${category} (${accountId}): ${body.url}`);
|
|
2929
|
+
return body.url;
|
|
2930
|
+
}
|
|
2931
|
+
// ============================================================================
|
|
2932
|
+
// HTTP 底层通信
|
|
2933
|
+
// ============================================================================
|
|
2934
|
+
/**
|
|
2935
|
+
* 发送原始 HTTP 请求到 MCP Server(底层方法)
|
|
2936
|
+
*
|
|
2937
|
+
* 自动携带 Mcp-Session-Id 请求头(如果有),
|
|
2938
|
+
* 并从响应头中更新 sessionId。
|
|
2939
|
+
*/
|
|
2940
|
+
async function sendRawJsonRpc(url, session, body) {
|
|
2941
|
+
const controller = new AbortController();
|
|
2942
|
+
const timeoutId = setTimeout(() => controller.abort(), HTTP_REQUEST_TIMEOUT_MS);
|
|
2943
|
+
const headers = {
|
|
2944
|
+
"Content-Type": "application/json",
|
|
2945
|
+
Accept: "application/json, text/event-stream",
|
|
2946
|
+
};
|
|
2947
|
+
// Streamable HTTP:携带会话 ID
|
|
2948
|
+
if (session.sessionId) {
|
|
2949
|
+
headers["Mcp-Session-Id"] = session.sessionId;
|
|
2950
|
+
}
|
|
2951
|
+
let response;
|
|
2952
|
+
try {
|
|
2953
|
+
response = await fetch(url, {
|
|
2954
|
+
method: "POST",
|
|
2955
|
+
headers,
|
|
2956
|
+
body: JSON.stringify(body),
|
|
2957
|
+
signal: controller.signal,
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
catch (err) {
|
|
2961
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
2962
|
+
throw new Error(`MCP 请求超时 (${HTTP_REQUEST_TIMEOUT_MS}ms)`);
|
|
2963
|
+
}
|
|
2964
|
+
throw new Error(`MCP 网络请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
2965
|
+
}
|
|
2966
|
+
finally {
|
|
2967
|
+
clearTimeout(timeoutId);
|
|
2968
|
+
}
|
|
2969
|
+
// 从响应头提取新的 sessionId(不直接修改入参,由调用方决定如何更新)
|
|
2970
|
+
const newSessionId = response.headers.get("mcp-session-id");
|
|
2971
|
+
if (!response.ok) {
|
|
2972
|
+
throw new McpHttpError(response.status, `MCP HTTP 请求失败: ${response.status} ${response.statusText}`);
|
|
2973
|
+
}
|
|
2974
|
+
// Streamable HTTP:notification 响应可能无响应体(204 或 content-length: 0)
|
|
2975
|
+
const contentLength = response.headers.get("content-length");
|
|
2976
|
+
if (response.status === 204 || contentLength === "0") {
|
|
2977
|
+
return { response, rpcResult: undefined, newSessionId };
|
|
2978
|
+
}
|
|
2979
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
2980
|
+
// 处理 SSE 流式响应
|
|
2981
|
+
if (contentType.includes("text/event-stream")) {
|
|
2982
|
+
return { response, rpcResult: await parseSseResponse(response), newSessionId };
|
|
2983
|
+
}
|
|
2984
|
+
// 普通 JSON 响应 — 先读取文本,防止空内容导致 JSON.parse 报错
|
|
2985
|
+
const text = await response.text();
|
|
2986
|
+
if (!text.trim()) {
|
|
2987
|
+
return { response, rpcResult: undefined, newSessionId };
|
|
2988
|
+
}
|
|
2989
|
+
const rpc = JSON.parse(text);
|
|
2990
|
+
if (rpc.error) {
|
|
2991
|
+
throw new McpRpcError(rpc.error.code, `MCP 调用错误 [${rpc.error.code}]: ${rpc.error.message}`, rpc.error.data);
|
|
2992
|
+
}
|
|
2993
|
+
return { response, rpcResult: rpc.result, newSessionId };
|
|
2994
|
+
}
|
|
2995
|
+
// ============================================================================
|
|
2996
|
+
// Session 管理
|
|
2997
|
+
// ============================================================================
|
|
2998
|
+
/**
|
|
2999
|
+
* 对指定 URL 执行 Streamable HTTP 的 initialize 握手
|
|
3000
|
+
*
|
|
3001
|
+
* 发送 initialize → 接收 serverInfo → 发送 initialized 通知。
|
|
3002
|
+
* 如果服务端未返回 Mcp-Session-Id,则标记为无状态模式,后续请求跳过 session 管理。
|
|
3003
|
+
*/
|
|
3004
|
+
async function initializeSession(url, sessionKey) {
|
|
3005
|
+
const session = { sessionId: null, initialized: false, stateless: false };
|
|
3006
|
+
console.log(`${LOG_TAG} 开始 initialize 握手 (sessionKey="${sessionKey}")`);
|
|
3007
|
+
// 1. 发送 initialize 请求
|
|
3008
|
+
const initBody = {
|
|
3009
|
+
jsonrpc: "2.0",
|
|
3010
|
+
id: aibotNodeSdk.generateReqId("mcp_init"),
|
|
3011
|
+
method: "initialize",
|
|
3012
|
+
params: {
|
|
3013
|
+
protocolVersion: "2025-03-26",
|
|
3014
|
+
capabilities: {},
|
|
3015
|
+
clientInfo: { name: "wecom_mcp", version: "1.0.0" },
|
|
3016
|
+
},
|
|
3017
|
+
};
|
|
3018
|
+
const { newSessionId: initSessionId } = await sendRawJsonRpc(url, session, initBody);
|
|
3019
|
+
// 用返回的 newSessionId 更新 session(不再依赖副作用修改)
|
|
3020
|
+
if (initSessionId) {
|
|
3021
|
+
session.sessionId = initSessionId;
|
|
3022
|
+
}
|
|
3023
|
+
// 检查服务端是否返回了 Mcp-Session-Id
|
|
3024
|
+
// 如果没有返回,说明该 Server 是无状态实现,无需维护 session
|
|
3025
|
+
if (!session.sessionId) {
|
|
3026
|
+
session.stateless = true;
|
|
3027
|
+
session.initialized = true;
|
|
3028
|
+
statelessCategories.add(sessionKey);
|
|
3029
|
+
mcpSessionCache.set(sessionKey, session);
|
|
3030
|
+
console.log(`${LOG_TAG} 无状态 Server 确认 (sessionKey="${sessionKey}")`);
|
|
3031
|
+
return session;
|
|
3032
|
+
}
|
|
3033
|
+
// 2. 发送 initialized 通知(JSON-RPC notification 不带 id 字段)
|
|
3034
|
+
const notifyBody = {
|
|
3035
|
+
jsonrpc: "2.0",
|
|
3036
|
+
method: "notifications/initialized",
|
|
3037
|
+
};
|
|
3038
|
+
// initialized 通知不需要等待响应,但 Streamable HTTP 要求通过 POST 发送
|
|
3039
|
+
const { newSessionId: notifySessionId } = await sendRawJsonRpc(url, session, notifyBody);
|
|
3040
|
+
// 如果 initialized 通知的响应也携带了 sessionId,以最新的为准
|
|
3041
|
+
if (notifySessionId) {
|
|
3042
|
+
session.sessionId = notifySessionId;
|
|
3043
|
+
}
|
|
3044
|
+
session.initialized = true;
|
|
3045
|
+
mcpSessionCache.set(sessionKey, session);
|
|
3046
|
+
console.log(`${LOG_TAG} 有状态 Session 建立成功 (sessionKey="${sessionKey}", sessionId="${session.sessionId}")`);
|
|
3047
|
+
return session;
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* 获取或创建指定 URL 的 MCP 会话
|
|
3051
|
+
*
|
|
3052
|
+
* - 已确认无状态的 category:直接返回空 session,跳过握手
|
|
3053
|
+
* - 已有可用有状态会话:直接返回缓存
|
|
3054
|
+
* - 其他情况:执行 initialize 握手,并发请求会被合并
|
|
3055
|
+
*/
|
|
3056
|
+
async function getOrCreateSession(url, sessionKey) {
|
|
3057
|
+
// 已确认为无状态的 Server,直接返回空 session 跳过握手
|
|
3058
|
+
if (statelessCategories.has(sessionKey)) {
|
|
3059
|
+
const cached = mcpSessionCache.get(sessionKey);
|
|
3060
|
+
if (cached)
|
|
3061
|
+
return cached;
|
|
3062
|
+
// 首次发现被清除(理论上不会走到这里),重新走握手探测
|
|
3063
|
+
}
|
|
3064
|
+
const cached = mcpSessionCache.get(sessionKey);
|
|
3065
|
+
if (cached?.initialized)
|
|
3066
|
+
return cached;
|
|
3067
|
+
// 防止并发重复初始化
|
|
3068
|
+
const inflight = inflightInitRequests.get(sessionKey);
|
|
3069
|
+
if (inflight)
|
|
3070
|
+
return inflight;
|
|
3071
|
+
const promise = initializeSession(url, sessionKey).finally(() => {
|
|
3072
|
+
inflightInitRequests.delete(sessionKey);
|
|
3073
|
+
});
|
|
3074
|
+
inflightInitRequests.set(sessionKey, promise);
|
|
3075
|
+
return promise;
|
|
3076
|
+
}
|
|
3077
|
+
// ============================================================================
|
|
3078
|
+
// SSE 解析
|
|
3079
|
+
// ============================================================================
|
|
3080
|
+
/**
|
|
3081
|
+
* 解析 SSE 流式响应,提取最终的 JSON-RPC result
|
|
3082
|
+
*
|
|
3083
|
+
* 按照 SSE 规范,同一事件中的多个 `data:` 行会用换行符拼接。
|
|
3084
|
+
* 空行分隔不同事件,取最后一个完整事件的数据。
|
|
3085
|
+
*/
|
|
3086
|
+
async function parseSseResponse(response) {
|
|
3087
|
+
const text = await response.text();
|
|
3088
|
+
const lines = text.split("\n");
|
|
3089
|
+
// 按 SSE 规范解析:空行分隔事件,同一事件内的 data 行用换行拼接
|
|
3090
|
+
let currentDataParts = [];
|
|
3091
|
+
let lastEventData = "";
|
|
3092
|
+
for (const line of lines) {
|
|
3093
|
+
if (line.startsWith("data: ")) {
|
|
3094
|
+
currentDataParts.push(line.slice(6));
|
|
3095
|
+
}
|
|
3096
|
+
else if (line.startsWith("data:")) {
|
|
3097
|
+
// data: 后无空格时,值为空字符串
|
|
3098
|
+
currentDataParts.push(line.slice(5));
|
|
3099
|
+
}
|
|
3100
|
+
else if (line.trim() === "" && currentDataParts.length > 0) {
|
|
3101
|
+
// 空行表示事件结束,拼接所有 data 行
|
|
3102
|
+
lastEventData = currentDataParts.join("\n").trim();
|
|
3103
|
+
currentDataParts = [];
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
// 处理最后一个未以空行结尾的事件
|
|
3107
|
+
if (currentDataParts.length > 0) {
|
|
3108
|
+
lastEventData = currentDataParts.join("\n").trim();
|
|
3109
|
+
}
|
|
3110
|
+
if (!lastEventData) {
|
|
3111
|
+
throw new Error("SSE 响应中未包含有效数据");
|
|
3112
|
+
}
|
|
3113
|
+
try {
|
|
3114
|
+
const rpc = JSON.parse(lastEventData);
|
|
3115
|
+
if (rpc.error) {
|
|
3116
|
+
throw new McpRpcError(rpc.error.code, `MCP 调用错误 [${rpc.error.code}]: ${rpc.error.message}`, rpc.error.data);
|
|
3117
|
+
}
|
|
3118
|
+
return rpc.result;
|
|
3119
|
+
}
|
|
3120
|
+
catch (err) {
|
|
3121
|
+
if (err instanceof SyntaxError) {
|
|
3122
|
+
throw new Error(`SSE 响应解析失败: ${lastEventData.slice(0, 200)}`);
|
|
3123
|
+
}
|
|
3124
|
+
throw err;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
// ============================================================================
|
|
3128
|
+
// 公共 API
|
|
3129
|
+
// ============================================================================
|
|
3130
|
+
/**
|
|
3131
|
+
* 清理指定品类的所有 MCP 缓存(配置、会话、无状态标记)
|
|
3132
|
+
*
|
|
3133
|
+
* 当 MCP Server 返回特定错误码时调用,确保下次请求重新拉取配置并重建会话。
|
|
3134
|
+
*
|
|
3135
|
+
* @param category - MCP 品类名称
|
|
3136
|
+
* @param accountId - 账户 ID(可选,用于清理特定账户的缓存)
|
|
3137
|
+
*/
|
|
3138
|
+
function clearCategoryCache(category, accountId) {
|
|
3139
|
+
if (accountId) {
|
|
3140
|
+
// 清理特定账户的缓存
|
|
3141
|
+
const cacheKey = `${accountId}:${category}`;
|
|
3142
|
+
console.log(`${LOG_TAG} 清理缓存 (category="${category}", accountId="${accountId}")`);
|
|
3143
|
+
mcpConfigCache.delete(cacheKey);
|
|
3144
|
+
mcpSessionCache.delete(cacheKey);
|
|
3145
|
+
statelessCategories.delete(cacheKey);
|
|
3146
|
+
inflightInitRequests.delete(cacheKey);
|
|
3147
|
+
}
|
|
3148
|
+
else {
|
|
3149
|
+
// 清理所有账户的该品类缓存
|
|
3150
|
+
console.log(`${LOG_TAG} 清理缓存 (category="${category}", all accounts)"`);
|
|
3151
|
+
for (const key of mcpConfigCache.keys()) {
|
|
3152
|
+
if (key.endsWith(`:${category}`)) {
|
|
3153
|
+
mcpConfigCache.delete(key);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
for (const key of mcpSessionCache.keys()) {
|
|
3157
|
+
if (key.endsWith(`:${category}`)) {
|
|
3158
|
+
mcpSessionCache.delete(key);
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
for (const key of statelessCategories.keys()) {
|
|
3162
|
+
if (key.endsWith(`:${category}`)) {
|
|
3163
|
+
statelessCategories.delete(key);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
for (const key of inflightInitRequests.keys()) {
|
|
3167
|
+
if (key.endsWith(`:${category}`)) {
|
|
3168
|
+
inflightInitRequests.delete(key);
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
/**
|
|
3174
|
+
* 发送 JSON-RPC 请求到 MCP Server(Streamable HTTP 协议)
|
|
3175
|
+
*
|
|
3176
|
+
* 自动管理 session 生命周期:
|
|
3177
|
+
* - 无状态 Server:跳过 session 管理,直接发送请求
|
|
3178
|
+
* - 有状态 Server:首次调用先执行 initialize 握手,session 失效(404)时自动重建并重试
|
|
3179
|
+
*
|
|
3180
|
+
* @param category - MCP 品类名称
|
|
3181
|
+
* @param method - JSON-RPC 方法名
|
|
3182
|
+
* @param params - JSON-RPC 参数
|
|
3183
|
+
* @param accountId - 账户 ID(可选,默认使用 DEFAULT_ACCOUNT_ID)
|
|
3184
|
+
* @returns JSON-RPC result
|
|
3185
|
+
*/
|
|
3186
|
+
async function sendJsonRpc(category, method, params, accountId = pluginSdk.DEFAULT_ACCOUNT_ID) {
|
|
3187
|
+
const url = await getMcpUrl(category, accountId);
|
|
3188
|
+
// 使用 accountId + category 作为 session 缓存 key
|
|
3189
|
+
const sessionKey = `${accountId}:${category}`;
|
|
3190
|
+
const body = {
|
|
3191
|
+
jsonrpc: "2.0",
|
|
3192
|
+
id: aibotNodeSdk.generateReqId("mcp_rpc"),
|
|
3193
|
+
method,
|
|
3194
|
+
...(params !== undefined ? { params } : {}),
|
|
3195
|
+
};
|
|
3196
|
+
let session = await getOrCreateSession(url, sessionKey);
|
|
3197
|
+
try {
|
|
3198
|
+
const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body);
|
|
3199
|
+
// 用最新的 sessionId 更新 session
|
|
3200
|
+
if (newSessionId) {
|
|
3201
|
+
session.sessionId = newSessionId;
|
|
3202
|
+
}
|
|
3203
|
+
return rpcResult;
|
|
3204
|
+
}
|
|
3205
|
+
catch (err) {
|
|
3206
|
+
// 特定 JSON-RPC 错误码触发缓存清理(统一在传输层处理,上层无需关心)
|
|
3207
|
+
if (err instanceof McpRpcError && CACHE_CLEAR_ERROR_CODES.has(err.code)) {
|
|
3208
|
+
clearCategoryCache(category, accountId);
|
|
3209
|
+
}
|
|
3210
|
+
// 无状态 Server 不存在 session 失效问题,直接抛出错误
|
|
3211
|
+
if (session.stateless)
|
|
3212
|
+
throw err;
|
|
3213
|
+
// 有状态 Server:session 失效时服务端返回 404,需要重新初始化并重试一次
|
|
3214
|
+
// 使用 McpHttpError.statusCode 精确匹配,避免字符串匹配 "404" 导致误判
|
|
3215
|
+
if (err instanceof McpHttpError && err.statusCode === 404) {
|
|
3216
|
+
console.log(`${LOG_TAG} Session 失效 (category="${category}", accountId="${accountId}"),开始重建...`);
|
|
3217
|
+
mcpSessionCache.delete(sessionKey);
|
|
3218
|
+
// 使用 rebuildSession 合并并发的 session 重建请求,避免竞态条件
|
|
3219
|
+
session = await rebuildSession(url, sessionKey);
|
|
3220
|
+
const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body);
|
|
3221
|
+
if (newSessionId) {
|
|
3222
|
+
session.sessionId = newSessionId;
|
|
3223
|
+
}
|
|
3224
|
+
return rpcResult;
|
|
3225
|
+
}
|
|
3226
|
+
// 其他错误记录日志后抛出
|
|
3227
|
+
console.error(`${LOG_TAG} RPC 请求失败 (category="${category}", accountId="${accountId}", method="${method}"): ${err instanceof Error ? err.message : String(err)}`);
|
|
3228
|
+
throw err;
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
/**
|
|
3232
|
+
* 合并并发的 session 重建请求
|
|
3233
|
+
*
|
|
3234
|
+
* 与 getOrCreateSession 类似,使用 inflightInitRequests 防止
|
|
3235
|
+
* 多个并发请求同时遇到 404 时重复执行 initialize 握手。
|
|
3236
|
+
*/
|
|
3237
|
+
async function rebuildSession(url, sessionKey) {
|
|
3238
|
+
const inflight = inflightInitRequests.get(sessionKey);
|
|
3239
|
+
if (inflight)
|
|
3240
|
+
return inflight;
|
|
3241
|
+
const promise = initializeSession(url, sessionKey).finally(() => {
|
|
3242
|
+
inflightInitRequests.delete(sessionKey);
|
|
3243
|
+
});
|
|
3244
|
+
inflightInitRequests.set(sessionKey, promise);
|
|
3245
|
+
return promise;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
/**
|
|
3249
|
+
* MCP Schema 清洗模块
|
|
3250
|
+
*
|
|
3251
|
+
* 负责内联 $ref/$defs 引用并移除 Gemini 不支持的 JSON Schema 关键词,
|
|
3252
|
+
* 防止 Gemini 模型解析 function response 时报 400 错误。
|
|
3253
|
+
*/
|
|
3254
|
+
/** Gemini 不支持的 JSON Schema 关键词 */
|
|
3255
|
+
const GEMINI_UNSUPPORTED_KEYWORDS = new Set([
|
|
3256
|
+
"patternProperties", "additionalProperties", "$schema", "$id", "$ref", "$defs",
|
|
3257
|
+
"definitions", "examples", "minLength", "maxLength", "minimum", "maximum",
|
|
3258
|
+
"multipleOf", "pattern", "format", "minItems", "maxItems", "uniqueItems",
|
|
3259
|
+
"minProperties", "maxProperties",
|
|
3260
|
+
]);
|
|
3261
|
+
/**
|
|
3262
|
+
* 清洗 JSON Schema,内联 $ref 引用并移除 Gemini 不支持的关键词,
|
|
3263
|
+
* 防止 Gemini 模型解析 function response 时报 400 错误。
|
|
3264
|
+
*/
|
|
3265
|
+
function cleanSchemaForGemini(schema) {
|
|
3266
|
+
if (!schema || typeof schema !== "object")
|
|
3267
|
+
return schema;
|
|
3268
|
+
if (Array.isArray(schema))
|
|
3269
|
+
return schema.map(cleanSchemaForGemini);
|
|
3270
|
+
const obj = schema;
|
|
3271
|
+
// 收集 $defs/definitions 用于后续 $ref 内联解析
|
|
3272
|
+
const defs = {
|
|
3273
|
+
...(obj.$defs && typeof obj.$defs === "object" ? obj.$defs : {}),
|
|
3274
|
+
...(obj.definitions && typeof obj.definitions === "object" ? obj.definitions : {}),
|
|
3275
|
+
};
|
|
3276
|
+
return cleanWithDefs(obj, defs, new Set());
|
|
3277
|
+
}
|
|
3278
|
+
function cleanWithDefs(schema, defs, refStack) {
|
|
3279
|
+
if (!schema || typeof schema !== "object")
|
|
3280
|
+
return schema;
|
|
3281
|
+
if (Array.isArray(schema))
|
|
3282
|
+
return schema.map((item) => cleanWithDefs(item, defs, refStack));
|
|
3283
|
+
const obj = schema;
|
|
3284
|
+
// 合并当前层级的 $defs/definitions 到 defs 中
|
|
3285
|
+
if (obj.$defs && typeof obj.$defs === "object") {
|
|
3286
|
+
Object.assign(defs, obj.$defs);
|
|
3287
|
+
}
|
|
3288
|
+
if (obj.definitions && typeof obj.definitions === "object") {
|
|
3289
|
+
Object.assign(defs, obj.definitions);
|
|
3290
|
+
}
|
|
3291
|
+
// 处理 $ref 引用:尝试内联解析
|
|
3292
|
+
if (typeof obj.$ref === "string") {
|
|
3293
|
+
const ref = obj.$ref;
|
|
3294
|
+
if (refStack.has(ref))
|
|
3295
|
+
return {}; // 防止循环引用
|
|
3296
|
+
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
|
|
3297
|
+
if (match && match[1] && defs[match[1]]) {
|
|
3298
|
+
const nextStack = new Set(refStack);
|
|
3299
|
+
nextStack.add(ref);
|
|
3300
|
+
return cleanWithDefs(defs[match[1]], defs, nextStack);
|
|
3301
|
+
}
|
|
3302
|
+
return {}; // 无法解析的 $ref,返回空对象
|
|
3303
|
+
}
|
|
3304
|
+
const cleaned = {};
|
|
3305
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
3306
|
+
if (GEMINI_UNSUPPORTED_KEYWORDS.has(key))
|
|
3307
|
+
continue;
|
|
3308
|
+
if (key === "const") {
|
|
3309
|
+
cleaned.enum = [value];
|
|
3310
|
+
continue;
|
|
3311
|
+
}
|
|
3312
|
+
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
3313
|
+
cleaned[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [
|
|
3314
|
+
k, cleanWithDefs(v, defs, refStack),
|
|
3315
|
+
]));
|
|
3316
|
+
}
|
|
3317
|
+
else if (key === "items" && value) {
|
|
3318
|
+
cleaned[key] = Array.isArray(value)
|
|
3319
|
+
? value.map((item) => cleanWithDefs(item, defs, refStack))
|
|
3320
|
+
: cleanWithDefs(value, defs, refStack);
|
|
3321
|
+
}
|
|
3322
|
+
else if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(value)) {
|
|
3323
|
+
// 过滤掉 null 类型的变体
|
|
3324
|
+
const nonNull = value.filter((v) => {
|
|
3325
|
+
if (!v || typeof v !== "object")
|
|
3326
|
+
return true;
|
|
3327
|
+
const r = v;
|
|
3328
|
+
return r.type !== "null";
|
|
3329
|
+
});
|
|
3330
|
+
if (nonNull.length === 1) {
|
|
3331
|
+
// 只剩一个变体时直接内联
|
|
3332
|
+
const single = cleanWithDefs(nonNull[0], defs, refStack);
|
|
3333
|
+
if (single && typeof single === "object" && !Array.isArray(single)) {
|
|
3334
|
+
Object.assign(cleaned, single);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
else {
|
|
3338
|
+
cleaned[key] = nonNull.map((v) => cleanWithDefs(v, defs, refStack));
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
else {
|
|
3342
|
+
cleaned[key] = value;
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
return cleaned;
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
/**
|
|
3349
|
+
* wecom_mcp — 模拟 MCP 调用的 Agent Tool
|
|
3350
|
+
*
|
|
3351
|
+
* 通过 MCP Streamable HTTP 传输协议调用企业微信 MCP Server,
|
|
3352
|
+
* 提供 list(列出所有工具)和 call(调用工具)两个操作。
|
|
3353
|
+
*
|
|
3354
|
+
* 在 skills 中的使用方式:
|
|
3355
|
+
* wecom_mcp list <category>
|
|
3356
|
+
* wecom_mcp call <category> <method> '<jsonArgs>'
|
|
3357
|
+
*
|
|
3358
|
+
* 示例:
|
|
3359
|
+
* wecom_mcp list contact
|
|
3360
|
+
* wecom_mcp call contact getContact '{}'
|
|
3361
|
+
*/
|
|
3362
|
+
// ============================================================================
|
|
3363
|
+
// 响应构造辅助
|
|
3364
|
+
// ============================================================================
|
|
3365
|
+
/** 构造统一的文本响应结构 */
|
|
3366
|
+
const textResult = (data) => ({
|
|
3367
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
3368
|
+
});
|
|
3369
|
+
/** 构造错误响应 */
|
|
3370
|
+
const errorResult = (err) => {
|
|
3371
|
+
// 适配企业微信 API 返回的 { errcode, errmsg } 结构
|
|
3372
|
+
if (err && typeof err === "object" && "errcode" in err) {
|
|
3373
|
+
const { errcode, errmsg } = err;
|
|
3374
|
+
return textResult({ error: errmsg ?? `错误码: ${errcode}`, errcode });
|
|
3375
|
+
}
|
|
3376
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3377
|
+
return textResult({ error: message });
|
|
3378
|
+
};
|
|
3379
|
+
// ============================================================================
|
|
3380
|
+
// list 操作:列出某品类的所有 MCP 工具
|
|
3381
|
+
// ============================================================================
|
|
3382
|
+
const handleList = async (category, accountId) => {
|
|
3383
|
+
const result = await sendJsonRpc(category, "tools/list", undefined, accountId);
|
|
3384
|
+
const tools = result?.tools ?? [];
|
|
3385
|
+
if (tools.length === 0) {
|
|
3386
|
+
return { message: `品类 "${category}" 下暂无可用工具`, tools: [] };
|
|
3387
|
+
}
|
|
3388
|
+
return {
|
|
3389
|
+
category,
|
|
3390
|
+
accountId: accountId ?? "default",
|
|
3391
|
+
count: tools.length,
|
|
3392
|
+
tools: tools.map((t) => ({
|
|
3393
|
+
name: t.name,
|
|
3394
|
+
description: t.description ?? "",
|
|
3395
|
+
// 清洗 inputSchema,内联 $ref/$defs 引用并移除 Gemini 不支持的关键词,
|
|
3396
|
+
// 避免 Gemini 模型解析 function response 时报 400 错误
|
|
3397
|
+
inputSchema: t.inputSchema ? cleanSchemaForGemini(t.inputSchema) : undefined,
|
|
3398
|
+
})),
|
|
3399
|
+
};
|
|
3400
|
+
};
|
|
3401
|
+
// ============================================================================
|
|
3402
|
+
// call 操作:调用某品类的某个 MCP 工具
|
|
3403
|
+
// ============================================================================
|
|
3404
|
+
/**
|
|
3405
|
+
* 需要触发缓存清理的业务错误码集合
|
|
3406
|
+
*
|
|
3407
|
+
* 这些错误码出现在 MCP 工具调用返回的 content 文本中(业务层面),
|
|
3408
|
+
* 与 JSON-RPC 层面的错误码不同,需要在此处额外检测。
|
|
3409
|
+
*
|
|
3410
|
+
* - 850002: 机器人未被授权使用对应能力,需清理缓存以便下次重新拉取配置
|
|
3411
|
+
*/
|
|
3412
|
+
const BIZ_CACHE_CLEAR_ERROR_CODES = new Set([850002]);
|
|
3413
|
+
/**
|
|
3414
|
+
* 检查 tools/call 的返回结果中是否包含需要清理缓存的业务错误码
|
|
3415
|
+
*
|
|
3416
|
+
* MCP Server 可能在正常的 JSON-RPC 响应中返回业务层错误,
|
|
3417
|
+
* 这些错误被包裹在 result.content[].text 中,需要解析后判断。
|
|
3418
|
+
*/
|
|
3419
|
+
const checkBizErrorAndClearCache = (result, category, accountId) => {
|
|
3420
|
+
if (!result || typeof result !== "object")
|
|
3421
|
+
return;
|
|
3422
|
+
const { content } = result;
|
|
3423
|
+
if (!Array.isArray(content))
|
|
3424
|
+
return;
|
|
3425
|
+
for (const item of content) {
|
|
3426
|
+
if (item.type !== "text" || !item.text)
|
|
3427
|
+
continue;
|
|
3428
|
+
try {
|
|
3429
|
+
const parsed = JSON.parse(item.text);
|
|
3430
|
+
if (typeof parsed.errcode === "number" && BIZ_CACHE_CLEAR_ERROR_CODES.has(parsed.errcode)) {
|
|
3431
|
+
console.log(`[mcp] 检测到业务错误码 ${parsed.errcode} (category="${category}", accountId="${accountId}"),清理缓存`);
|
|
3432
|
+
clearCategoryCache(category, accountId);
|
|
3433
|
+
return;
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
catch {
|
|
3437
|
+
// text 不是 JSON 格式,跳过
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3440
|
+
};
|
|
3441
|
+
const handleCall = async (category, method, args, accountId) => {
|
|
3442
|
+
const result = await sendJsonRpc(category, "tools/call", {
|
|
3443
|
+
name: method,
|
|
3444
|
+
arguments: args,
|
|
3445
|
+
}, accountId);
|
|
3446
|
+
// 检查业务层错误码,必要时清理缓存
|
|
3447
|
+
checkBizErrorAndClearCache(result, category, accountId);
|
|
3448
|
+
return result;
|
|
3449
|
+
};
|
|
3450
|
+
// ============================================================================
|
|
3451
|
+
// 参数解析
|
|
3452
|
+
// ============================================================================
|
|
3453
|
+
/**
|
|
3454
|
+
* 解析 args 参数:支持 JSON 字符串或直接的对象
|
|
3455
|
+
*/
|
|
3456
|
+
const parseArgs = (args) => {
|
|
3457
|
+
if (!args)
|
|
3458
|
+
return {};
|
|
3459
|
+
if (typeof args === "object")
|
|
3460
|
+
return args;
|
|
3461
|
+
try {
|
|
3462
|
+
return JSON.parse(args);
|
|
3463
|
+
}
|
|
3464
|
+
catch (err) {
|
|
3465
|
+
const detail = err instanceof SyntaxError ? err.message : String(err);
|
|
3466
|
+
throw new Error(`args 参数不是合法的 JSON: ${args} (${detail})`);
|
|
3467
|
+
}
|
|
3468
|
+
};
|
|
3469
|
+
// ============================================================================
|
|
3470
|
+
// 工具定义 & 导出
|
|
3471
|
+
// ============================================================================
|
|
3472
|
+
/**
|
|
3473
|
+
* 创建 wecom_mcp Agent Tool 定义
|
|
3474
|
+
*/
|
|
3475
|
+
function createWeComMcpTool() {
|
|
3476
|
+
return {
|
|
3477
|
+
name: "wecom_mcp",
|
|
3478
|
+
label: "企业微信 MCP 工具",
|
|
3479
|
+
description: [
|
|
3480
|
+
"通过 HTTP 直接调用企业微信 MCP Server。",
|
|
3481
|
+
"支持两种操作:",
|
|
3482
|
+
" - list: 列出指定品类的所有 MCP 工具",
|
|
3483
|
+
" - call: 调用指定品类的某个 MCP 工具",
|
|
3484
|
+
"",
|
|
3485
|
+
"使用方式:",
|
|
3486
|
+
" wecom_mcp list <category>",
|
|
3487
|
+
" wecom_mcp call <category> <method> '<jsonArgs>'",
|
|
3488
|
+
"",
|
|
3489
|
+
"示例:",
|
|
3490
|
+
" 列出 contact 品类所有工具:wecom_mcp list contact",
|
|
3491
|
+
" 调用 contact 的 getContact:wecom_mcp call contact getContact '{}'",
|
|
3492
|
+
].join("\n"),
|
|
3493
|
+
parameters: {
|
|
3494
|
+
type: "object",
|
|
3495
|
+
properties: {
|
|
3496
|
+
action: {
|
|
3497
|
+
type: "string",
|
|
3498
|
+
enum: ["list", "call"],
|
|
3499
|
+
description: "操作类型:list(列出工具)或 call(调用工具)",
|
|
3500
|
+
},
|
|
3501
|
+
category: {
|
|
3502
|
+
type: "string",
|
|
3503
|
+
description: "MCP 品类名称,如 doc、contact 等,对应 mcpConfig 中的 key",
|
|
3504
|
+
},
|
|
3505
|
+
accountId: {
|
|
3506
|
+
type: "string",
|
|
3507
|
+
description: "账户 ID(可选,用于多账户场景,指定使用哪个账户的 MCP 服务)",
|
|
3508
|
+
},
|
|
3509
|
+
method: {
|
|
3510
|
+
type: "string",
|
|
3511
|
+
description: "要调用的 MCP 方法名(action=call 时必填)",
|
|
3512
|
+
},
|
|
3513
|
+
args: {
|
|
3514
|
+
type: ["string", "object"],
|
|
3515
|
+
description: "调用 MCP 方法的参数,可以是 JSON 字符串或对象(action=call 时使用,默认 {})",
|
|
3516
|
+
},
|
|
3517
|
+
},
|
|
3518
|
+
required: ["action", "category"],
|
|
3519
|
+
},
|
|
3520
|
+
async execute(_toolCallId, params) {
|
|
3521
|
+
const p = params;
|
|
3522
|
+
try {
|
|
3523
|
+
switch (p.action) {
|
|
3524
|
+
case "list":
|
|
3525
|
+
return textResult(await handleList(p.category, p.accountId));
|
|
3526
|
+
case "call": {
|
|
3527
|
+
if (!p.method) {
|
|
3528
|
+
return textResult({ error: "action 为 call 时必须提供 method 参数" });
|
|
3529
|
+
}
|
|
3530
|
+
const args = parseArgs(p.args);
|
|
3531
|
+
return textResult(await handleCall(p.category, p.method, args, p.accountId));
|
|
3532
|
+
}
|
|
3533
|
+
default:
|
|
3534
|
+
return textResult({ error: `未知操作类型: ${String(p.action)},支持 list 和 call` });
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
catch (err) {
|
|
3538
|
+
return errorResult(err);
|
|
3539
|
+
}
|
|
3540
|
+
},
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
console.log(`[wecom] v${PLUGIN_VERSION} loaded`);
|
|
3545
|
+
const plugin = {
|
|
3546
|
+
id: "wecom-openclaw-plugin",
|
|
3547
|
+
name: "企业微信",
|
|
3548
|
+
description: "企业微信 OpenClaw 插件",
|
|
3549
|
+
configSchema: pluginSdk.emptyPluginConfigSchema(),
|
|
3550
|
+
register(api) {
|
|
3551
|
+
setWeComRuntime(api.runtime);
|
|
3552
|
+
api.registerChannel({ plugin: wecomPlugin });
|
|
3553
|
+
// 注册 wecom_mcp:通过 HTTP 直接调用企业微信 MCP Server
|
|
3554
|
+
api.registerTool(createWeComMcpTool(), { name: "wecom_mcp" });
|
|
3555
|
+
// ── Gateway 启动时自动确保 tools.alsoAllow 包含 wecom_mcp ──────────
|
|
3556
|
+
// 在 gateway_start 阶段检测并写入,保证插件安装/更新后首次启动即生效
|
|
3557
|
+
// api.on("gateway_start", async () => {
|
|
3558
|
+
// await ensureToolsAlsoAllow(api);
|
|
3559
|
+
// });
|
|
3560
|
+
// 注入媒体发送指令和文件大小限制提示词
|
|
3561
|
+
api.on("before_prompt_build", () => {
|
|
3562
|
+
return {
|
|
3563
|
+
appendSystemContext: [
|
|
3564
|
+
"【发送文件/图片/视频/语音】",
|
|
3565
|
+
"当你需要向用户发送文件、图片、视频或语音时,必须在回复中单独一行使用 MEDIA: 指令,后面跟文件的本地路径。",
|
|
3566
|
+
"格式:MEDIA: /文件的绝对路径",
|
|
3567
|
+
"文件优先存放到 ~/.openclaw 目录下,确保路径可访问。",
|
|
3568
|
+
"示例:",
|
|
3569
|
+
" MEDIA: ~/.openclaw/output.png",
|
|
3570
|
+
" MEDIA: ~/.openclaw/report.pdf",
|
|
3571
|
+
"系统会自动识别文件类型并发送给用户。",
|
|
3572
|
+
"",
|
|
3573
|
+
"注意事项:",
|
|
3574
|
+
"- MEDIA: 必须在行首,后面紧跟文件路径(不是 URL)",
|
|
3575
|
+
"- 如果路径中包含空格,可以用反引号包裹:MEDIA: `/path/to/my file.png`",
|
|
3576
|
+
"- 每个文件单独一行 MEDIA: 指令",
|
|
3577
|
+
"- 可以在 MEDIA: 指令前后附带文字说明",
|
|
3578
|
+
"",
|
|
3579
|
+
"【文件大小限制】",
|
|
3580
|
+
"- 图片不超过 10MB,视频不超过 10MB,语音不超过 2MB(仅支持 AMR 格式),文件不超过 20MB",
|
|
3581
|
+
"- 语音消息仅支持 AMR 格式(.amr),如需发送语音请确保文件为 AMR 格式",
|
|
3582
|
+
"- 超过大小限制的图片/视频/语音会被自动转为文件格式发送",
|
|
3583
|
+
"- 如果文件超过 20MB,将无法发送,请提前告知用户并尝试缩减文件大小",
|
|
3584
|
+
].join("\n"),
|
|
3585
|
+
};
|
|
3586
|
+
});
|
|
3587
|
+
},
|
|
3588
|
+
};
|
|
3589
|
+
|
|
3590
|
+
exports.default = plugin;
|
|
3591
|
+
//# sourceMappingURL=index.cjs.js.map
|