@wecom/wecom-openclaw-plugin 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/index.cjs.js +1791 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.esm.js +1787 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/const.d.ts +48 -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 +144 -0
- package/dist/src/media-handler.d.ts +36 -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/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 +73 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1791 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var pluginSdk = require('openclaw/plugin-sdk');
|
|
6
|
+
var aibotNodeSdk = require('@wecom/aibot-node-sdk');
|
|
7
|
+
var fileType = require('file-type');
|
|
8
|
+
var os = require('node:os');
|
|
9
|
+
var path = require('node:path');
|
|
10
|
+
|
|
11
|
+
let runtime = null;
|
|
12
|
+
function setWeComRuntime(r) {
|
|
13
|
+
runtime = r;
|
|
14
|
+
}
|
|
15
|
+
function getWeComRuntime() {
|
|
16
|
+
if (!runtime) {
|
|
17
|
+
throw new Error("WeCom runtime not initialized - plugin not registered");
|
|
18
|
+
}
|
|
19
|
+
return runtime;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 企业微信渠道常量定义
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* 企业微信渠道 ID
|
|
27
|
+
*/
|
|
28
|
+
const CHANNEL_ID = "wecom";
|
|
29
|
+
/**
|
|
30
|
+
* 企业微信 WebSocket 命令枚举
|
|
31
|
+
*/
|
|
32
|
+
var WeComCommand;
|
|
33
|
+
(function (WeComCommand) {
|
|
34
|
+
/** 认证订阅 */
|
|
35
|
+
WeComCommand["SUBSCRIBE"] = "aibot_subscribe";
|
|
36
|
+
/** 心跳 */
|
|
37
|
+
WeComCommand["PING"] = "ping";
|
|
38
|
+
/** 企业微信推送消息 */
|
|
39
|
+
WeComCommand["AIBOT_CALLBACK"] = "aibot_callback";
|
|
40
|
+
/** clawdbot 响应消息 */
|
|
41
|
+
WeComCommand["AIBOT_RESPONSE"] = "aibot_response";
|
|
42
|
+
})(WeComCommand || (WeComCommand = {}));
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// 超时和重试配置
|
|
45
|
+
// ============================================================================
|
|
46
|
+
/** 图片下载超时时间(毫秒) */
|
|
47
|
+
const IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
48
|
+
/** 文件下载超时时间(毫秒) */
|
|
49
|
+
const FILE_DOWNLOAD_TIMEOUT_MS = 60000;
|
|
50
|
+
/** 消息发送超时时间(毫秒) */
|
|
51
|
+
const REPLY_SEND_TIMEOUT_MS = 15000;
|
|
52
|
+
/** 消息处理总超时时间(毫秒) */
|
|
53
|
+
const MESSAGE_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
|
|
54
|
+
/** WebSocket 心跳间隔(毫秒) */
|
|
55
|
+
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
56
|
+
/** WebSocket 最大重连次数 */
|
|
57
|
+
const WS_MAX_RECONNECT_ATTEMPTS = 100;
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// 消息状态管理配置
|
|
60
|
+
// ============================================================================
|
|
61
|
+
/** messageStates Map 条目的最大 TTL(毫秒),防止内存泄漏 */
|
|
62
|
+
const MESSAGE_STATE_TTL_MS = 10 * 60 * 1000;
|
|
63
|
+
/** messageStates Map 清理间隔(毫秒) */
|
|
64
|
+
const MESSAGE_STATE_CLEANUP_INTERVAL_MS = 60000;
|
|
65
|
+
/** messageStates Map 最大条目数 */
|
|
66
|
+
const MESSAGE_STATE_MAX_SIZE = 500;
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// 消息模板
|
|
69
|
+
// ============================================================================
|
|
70
|
+
/** "思考中"流式消息占位内容 */
|
|
71
|
+
const THINKING_MESSAGE = "<think></think>";
|
|
72
|
+
/** 仅包含图片时的消息占位符 */
|
|
73
|
+
const MEDIA_IMAGE_PLACEHOLDER = "<media:image>";
|
|
74
|
+
/** 仅包含文件时的消息占位符 */
|
|
75
|
+
const MEDIA_DOCUMENT_PLACEHOLDER = "<media:document>";
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// 默认值
|
|
78
|
+
// ============================================================================
|
|
79
|
+
/** 默认媒体大小上限(MB) */
|
|
80
|
+
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
81
|
+
/** 文本分块大小上限 */
|
|
82
|
+
const TEXT_CHUNK_LIMIT = 4000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 企业微信公共工具函数
|
|
86
|
+
*/
|
|
87
|
+
const DefaultWsUrl = "wss://openws.work.weixin.qq.com";
|
|
88
|
+
/**
|
|
89
|
+
* 解析企业微信账户配置
|
|
90
|
+
*/
|
|
91
|
+
function resolveWeComAccount(cfg) {
|
|
92
|
+
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
93
|
+
return {
|
|
94
|
+
accountId: pluginSdk.DEFAULT_ACCOUNT_ID,
|
|
95
|
+
name: wecomConfig.name ?? "企业微信",
|
|
96
|
+
enabled: wecomConfig.enabled ?? false,
|
|
97
|
+
websocketUrl: wecomConfig.websocketUrl || DefaultWsUrl,
|
|
98
|
+
botId: wecomConfig.botId ?? "",
|
|
99
|
+
secret: wecomConfig.secret ?? "",
|
|
100
|
+
sendThinkingMessage: wecomConfig.sendThinkingMessage ?? true,
|
|
101
|
+
config: wecomConfig,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 设置企业微信账户配置
|
|
106
|
+
*/
|
|
107
|
+
function setWeComAccount(cfg, account) {
|
|
108
|
+
const existing = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
109
|
+
const merged = {
|
|
110
|
+
enabled: account.enabled ?? existing?.enabled ?? true,
|
|
111
|
+
botId: account.botId ?? existing?.botId ?? "",
|
|
112
|
+
secret: account.secret ?? existing?.secret ?? "",
|
|
113
|
+
allowFrom: account.allowFrom ?? existing?.allowFrom,
|
|
114
|
+
dmPolicy: account.dmPolicy ?? existing?.dmPolicy,
|
|
115
|
+
// 以下字段仅在已有配置值或显式传入时才写入,onboarding 时不主动生成
|
|
116
|
+
...(account.websocketUrl || existing?.websocketUrl
|
|
117
|
+
? { websocketUrl: account.websocketUrl ?? existing?.websocketUrl }
|
|
118
|
+
: {}),
|
|
119
|
+
...(account.name || existing?.name
|
|
120
|
+
? { name: account.name ?? existing?.name }
|
|
121
|
+
: {}),
|
|
122
|
+
...(account.sendThinkingMessage !== undefined || existing?.sendThinkingMessage !== undefined
|
|
123
|
+
? { sendThinkingMessage: account.sendThinkingMessage ?? existing?.sendThinkingMessage }
|
|
124
|
+
: {}),
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
...cfg,
|
|
128
|
+
channels: {
|
|
129
|
+
...cfg.channels,
|
|
130
|
+
[CHANNEL_ID]: merged,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 生成随机字符串
|
|
136
|
+
*/
|
|
137
|
+
function generateRandomString(length = 9) {
|
|
138
|
+
return Math.random().toString(36).substr(2, length);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* 生成企业微信请求 ID
|
|
142
|
+
* @param prefix - 请求类型前缀(subscribe, ping, response 等)
|
|
143
|
+
* @returns 格式化的请求 ID
|
|
144
|
+
*/
|
|
145
|
+
function generateReqId(prefix) {
|
|
146
|
+
const timestamp = Date.now();
|
|
147
|
+
const random = generateRandomString();
|
|
148
|
+
return `${prefix}_${timestamp}_${random}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 企业微信消息内容解析模块
|
|
153
|
+
*
|
|
154
|
+
* 负责从 WsFrame 中提取文本、图片、引用等内容
|
|
155
|
+
*/
|
|
156
|
+
// ============================================================================
|
|
157
|
+
// 解析函数
|
|
158
|
+
// ============================================================================
|
|
159
|
+
/**
|
|
160
|
+
* 解析消息内容(支持单条消息、图文混排和引用消息)
|
|
161
|
+
* @returns 提取的文本数组、图片URL数组和引用消息内容
|
|
162
|
+
*/
|
|
163
|
+
function parseMessageContent(body) {
|
|
164
|
+
const textParts = [];
|
|
165
|
+
const imageUrls = [];
|
|
166
|
+
const imageAesKeys = new Map();
|
|
167
|
+
const fileUrls = [];
|
|
168
|
+
const fileAesKeys = new Map();
|
|
169
|
+
let quoteContent;
|
|
170
|
+
// 处理图文混排消息
|
|
171
|
+
if (body.msgtype === "mixed" && body.mixed?.msg_item) {
|
|
172
|
+
for (const item of body.mixed.msg_item) {
|
|
173
|
+
if (item.msgtype === "text" && item.text?.content) {
|
|
174
|
+
textParts.push(item.text.content);
|
|
175
|
+
}
|
|
176
|
+
else if (item.msgtype === "image" && item.image?.url) {
|
|
177
|
+
imageUrls.push(item.image.url);
|
|
178
|
+
if (item.image.aeskey) {
|
|
179
|
+
imageAesKeys.set(item.image.url, item.image.aeskey);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// 处理单条消息
|
|
186
|
+
if (body.text?.content) {
|
|
187
|
+
textParts.push(body.text.content);
|
|
188
|
+
}
|
|
189
|
+
// 处理语音消息(语音转文字后的文本内容)
|
|
190
|
+
if (body.msgtype === "voice" && body.voice?.content) {
|
|
191
|
+
textParts.push(body.voice.content);
|
|
192
|
+
}
|
|
193
|
+
if (body.image?.url) {
|
|
194
|
+
imageUrls.push(body.image.url);
|
|
195
|
+
if (body.image.aeskey) {
|
|
196
|
+
imageAesKeys.set(body.image.url, body.image.aeskey);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// 处理文件消息
|
|
200
|
+
if (body.msgtype === "file" && body.file?.url) {
|
|
201
|
+
fileUrls.push(body.file.url);
|
|
202
|
+
if (body.file.aeskey) {
|
|
203
|
+
fileAesKeys.set(body.file.url, body.file.aeskey);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// 处理引用消息
|
|
208
|
+
if (body.quote) {
|
|
209
|
+
if (body.quote.msgtype === "text" && body.quote.text?.content) {
|
|
210
|
+
quoteContent = body.quote.text.content;
|
|
211
|
+
}
|
|
212
|
+
else if (body.quote.msgtype === "voice" && body.quote.voice?.content) {
|
|
213
|
+
quoteContent = body.quote.voice.content;
|
|
214
|
+
}
|
|
215
|
+
else if (body.quote.msgtype === "image" && body.quote.image?.url) {
|
|
216
|
+
// 引用的图片消息:将图片 URL 加入下载列表
|
|
217
|
+
imageUrls.push(body.quote.image.url);
|
|
218
|
+
if (body.quote.image.aeskey) {
|
|
219
|
+
imageAesKeys.set(body.quote.image.url, body.quote.image.aeskey);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (body.quote.msgtype === "file" && body.quote.file?.url) {
|
|
223
|
+
// 引用的文件消息:将文件 URL 加入下载列表
|
|
224
|
+
fileUrls.push(body.quote.file.url);
|
|
225
|
+
if (body.quote.file.aeskey) {
|
|
226
|
+
fileAesKeys.set(body.quote.file.url, body.quote.file.aeskey);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 超时控制工具模块
|
|
235
|
+
*
|
|
236
|
+
* 为异步操作提供统一的超时保护机制
|
|
237
|
+
*/
|
|
238
|
+
/**
|
|
239
|
+
* 为 Promise 添加超时保护
|
|
240
|
+
*
|
|
241
|
+
* @param promise - 原始 Promise
|
|
242
|
+
* @param timeoutMs - 超时时间(毫秒)
|
|
243
|
+
* @param message - 超时错误消息
|
|
244
|
+
* @returns 带超时保护的 Promise
|
|
245
|
+
*/
|
|
246
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
247
|
+
if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
|
|
248
|
+
return promise;
|
|
249
|
+
}
|
|
250
|
+
let timeoutId;
|
|
251
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
252
|
+
timeoutId = setTimeout(() => {
|
|
253
|
+
reject(new TimeoutError(message ?? `Operation timed out after ${timeoutMs}ms`));
|
|
254
|
+
}, timeoutMs);
|
|
255
|
+
});
|
|
256
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
257
|
+
clearTimeout(timeoutId);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* 超时错误类型
|
|
262
|
+
*/
|
|
263
|
+
class TimeoutError extends Error {
|
|
264
|
+
constructor(message) {
|
|
265
|
+
super(message);
|
|
266
|
+
this.name = "TimeoutError";
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 企业微信消息发送模块
|
|
272
|
+
*
|
|
273
|
+
* 负责通过 WSClient 发送回复消息,包含超时保护
|
|
274
|
+
*/
|
|
275
|
+
// ============================================================================
|
|
276
|
+
// 消息发送
|
|
277
|
+
// ============================================================================
|
|
278
|
+
/**
|
|
279
|
+
* 发送企业微信回复消息
|
|
280
|
+
* 供 monitor 内部和 channel outbound 使用
|
|
281
|
+
*
|
|
282
|
+
* @returns messageId (streamId)
|
|
283
|
+
*/
|
|
284
|
+
async function sendWeComReply(params) {
|
|
285
|
+
const { wsClient, frame, text, runtime, finish = true, streamId: existingStreamId } = params;
|
|
286
|
+
if (!text) {
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
const streamId = existingStreamId || generateReqId("stream");
|
|
290
|
+
if (!wsClient.isConnected) {
|
|
291
|
+
runtime.error?.(`[WeCom] WSClient not connected, cannot send reply`);
|
|
292
|
+
throw new Error("WSClient not connected");
|
|
293
|
+
}
|
|
294
|
+
// 使用 SDK 的 replyStream 方法发送消息,带超时保护
|
|
295
|
+
await withTimeout(wsClient.replyStream(frame, streamId, text, finish), REPLY_SEND_TIMEOUT_MS, `Reply send timed out (streamId=${streamId})`);
|
|
296
|
+
runtime.log?.(`[WeCom] Sent reply: streamId=${streamId}, finish=${finish}`);
|
|
297
|
+
return streamId;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 企业微信媒体(图片)下载和保存模块
|
|
302
|
+
*
|
|
303
|
+
* 负责下载、检测格式、保存图片到本地,包含超时保护
|
|
304
|
+
*/
|
|
305
|
+
// ============================================================================
|
|
306
|
+
// 图片格式检测辅助函数(基于 file-type 包)
|
|
307
|
+
// ============================================================================
|
|
308
|
+
/**
|
|
309
|
+
* 检查 Buffer 是否为有效的图片格式
|
|
310
|
+
*/
|
|
311
|
+
async function isImageBuffer(data) {
|
|
312
|
+
const type = await fileType.fileTypeFromBuffer(data);
|
|
313
|
+
return type?.mime.startsWith("image/") ?? false;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* 检测 Buffer 的图片内容类型
|
|
317
|
+
*/
|
|
318
|
+
async function detectImageContentType(data) {
|
|
319
|
+
const type = await fileType.fileTypeFromBuffer(data);
|
|
320
|
+
if (type?.mime.startsWith("image/")) {
|
|
321
|
+
return type.mime;
|
|
322
|
+
}
|
|
323
|
+
return "application/octet-stream";
|
|
324
|
+
}
|
|
325
|
+
// ============================================================================
|
|
326
|
+
// 图片下载和保存
|
|
327
|
+
// ============================================================================
|
|
328
|
+
/**
|
|
329
|
+
* 下载并保存所有图片到本地,每张图片的下载带超时保护
|
|
330
|
+
*/
|
|
331
|
+
async function downloadAndSaveImages(params) {
|
|
332
|
+
const { imageUrls, config, runtime, wsClient } = params;
|
|
333
|
+
const core = getWeComRuntime();
|
|
334
|
+
const mediaList = [];
|
|
335
|
+
for (const imageUrl of imageUrls) {
|
|
336
|
+
try {
|
|
337
|
+
runtime.log?.(`[WeCom] Downloading image from: ${imageUrl}`);
|
|
338
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
339
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
340
|
+
let imageBuffer;
|
|
341
|
+
let imageContentType;
|
|
342
|
+
let originalFilename;
|
|
343
|
+
const imageAesKey = params.imageAesKeys?.get(imageUrl);
|
|
344
|
+
try {
|
|
345
|
+
// 优先使用 SDK 的 downloadFile 方法下载(带超时保护)
|
|
346
|
+
const result = await withTimeout(wsClient.downloadFile(imageUrl, imageAesKey), IMAGE_DOWNLOAD_TIMEOUT_MS, `Image download timed out: ${imageUrl}`);
|
|
347
|
+
imageBuffer = result.buffer;
|
|
348
|
+
originalFilename = result.filename;
|
|
349
|
+
imageContentType = await detectImageContentType(imageBuffer);
|
|
350
|
+
runtime.log?.(`[WeCom] Image downloaded via SDK: size=${imageBuffer.length}, contentType=${imageContentType}${originalFilename ? `, filename=${originalFilename}` : ""}`);
|
|
351
|
+
}
|
|
352
|
+
catch (sdkError) {
|
|
353
|
+
// 如果 SDK 方法失败,回退到原有方式(带超时保护)
|
|
354
|
+
runtime.log?.(`[WeCom] SDK download failed, falling back to manual download: ${String(sdkError)}`);
|
|
355
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: imageUrl }), IMAGE_DOWNLOAD_TIMEOUT_MS, `Manual image download timed out: ${imageUrl}`);
|
|
356
|
+
runtime.log?.(`[WeCom] Image fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}, first4Bytes=${fetched.buffer.slice(0, 4).toString("hex")}`);
|
|
357
|
+
imageBuffer = fetched.buffer;
|
|
358
|
+
imageContentType = fetched.contentType ?? "application/octet-stream";
|
|
359
|
+
const isValidImage = await isImageBuffer(fetched.buffer);
|
|
360
|
+
if (!isValidImage) {
|
|
361
|
+
runtime.log?.(`[WeCom] WARN: Image does not appear to be a valid image format`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
|
|
365
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
366
|
+
runtime.log?.(`[WeCom] Image saved to ${saved.path}, finalContentType=${saved.contentType}`);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
runtime.error?.(`[WeCom] Failed to download image: ${String(err)}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return mediaList;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* 下载并保存所有文件到本地,每个文件的下载带超时保护
|
|
376
|
+
*/
|
|
377
|
+
async function downloadAndSaveFiles(params) {
|
|
378
|
+
const { fileUrls, config, runtime, wsClient } = params;
|
|
379
|
+
const core = getWeComRuntime();
|
|
380
|
+
const mediaList = [];
|
|
381
|
+
for (const fileUrl of fileUrls) {
|
|
382
|
+
try {
|
|
383
|
+
runtime.log?.(`[WeCom] Downloading file from: ${fileUrl}`);
|
|
384
|
+
const mediaMaxMb = config.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
385
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
386
|
+
let fileBuffer;
|
|
387
|
+
let fileContentType;
|
|
388
|
+
let originalFilename;
|
|
389
|
+
const fileAesKey = params.fileAesKeys?.get(fileUrl);
|
|
390
|
+
try {
|
|
391
|
+
// 使用 SDK 的 downloadFile 方法下载(带超时保护)
|
|
392
|
+
const result = await withTimeout(wsClient.downloadFile(fileUrl, fileAesKey), FILE_DOWNLOAD_TIMEOUT_MS, `File download timed out: ${fileUrl}`);
|
|
393
|
+
fileBuffer = result.buffer;
|
|
394
|
+
originalFilename = result.filename;
|
|
395
|
+
// 检测文件类型
|
|
396
|
+
const type = await fileType.fileTypeFromBuffer(fileBuffer);
|
|
397
|
+
fileContentType = type?.mime ?? "application/octet-stream";
|
|
398
|
+
runtime.log?.(`[WeCom] File downloaded via SDK: size=${fileBuffer.length}, contentType=${fileContentType}${originalFilename ? `, filename=${originalFilename}` : ""}`);
|
|
399
|
+
}
|
|
400
|
+
catch (sdkError) {
|
|
401
|
+
// 如果 SDK 方法失败,回退到 fetchRemoteMedia(带超时保护)
|
|
402
|
+
runtime.log?.(`[WeCom] SDK file download failed, falling back to manual download: ${String(sdkError)}`);
|
|
403
|
+
const fetched = await withTimeout(core.channel.media.fetchRemoteMedia({ url: fileUrl }), FILE_DOWNLOAD_TIMEOUT_MS, `Manual file download timed out: ${fileUrl}`);
|
|
404
|
+
runtime.log?.(`[WeCom] File fetched: contentType=${fetched.contentType}, size=${fetched.buffer.length}`);
|
|
405
|
+
fileBuffer = fetched.buffer;
|
|
406
|
+
fileContentType = fetched.contentType ?? "application/octet-stream";
|
|
407
|
+
}
|
|
408
|
+
const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
|
|
409
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType });
|
|
410
|
+
runtime.log?.(`[WeCom] File saved to ${saved.path}, finalContentType=${saved.contentType}`);
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
runtime.error?.(`[WeCom] Failed to download file: ${String(err)}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return mediaList;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 企业微信群组访问控制模块
|
|
421
|
+
*
|
|
422
|
+
* 负责群组策略检查(groupPolicy、群组白名单、群内发送者白名单)
|
|
423
|
+
*/
|
|
424
|
+
// ============================================================================
|
|
425
|
+
// 内部辅助函数
|
|
426
|
+
// ============================================================================
|
|
427
|
+
/**
|
|
428
|
+
* 解析企业微信群组配置
|
|
429
|
+
*/
|
|
430
|
+
function resolveWeComGroupConfig(params) {
|
|
431
|
+
const groups = params.cfg?.groups ?? {};
|
|
432
|
+
const wildcard = groups["*"];
|
|
433
|
+
const groupId = params.groupId?.trim();
|
|
434
|
+
if (!groupId) {
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
const direct = groups[groupId];
|
|
438
|
+
if (direct) {
|
|
439
|
+
return direct;
|
|
440
|
+
}
|
|
441
|
+
const lowered = groupId.toLowerCase();
|
|
442
|
+
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
|
|
443
|
+
if (matchKey) {
|
|
444
|
+
return groups[matchKey];
|
|
445
|
+
}
|
|
446
|
+
return wildcard;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* 检查群组是否在允许列表中
|
|
450
|
+
*/
|
|
451
|
+
function isWeComGroupAllowed(params) {
|
|
452
|
+
const { groupPolicy } = params;
|
|
453
|
+
if (groupPolicy === "disabled") {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
if (groupPolicy === "open") {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
// allowlist 模式:检查群组是否在允许列表中
|
|
460
|
+
const normalizedAllowFrom = params.allowFrom.map((entry) => String(entry).replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim());
|
|
461
|
+
if (normalizedAllowFrom.includes("*")) {
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
const normalizedGroupId = params.groupId.trim();
|
|
465
|
+
return normalizedAllowFrom.some((entry) => entry === normalizedGroupId || entry.toLowerCase() === normalizedGroupId.toLowerCase());
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* 检查群组内发送者是否在允许列表中
|
|
469
|
+
*/
|
|
470
|
+
function isGroupSenderAllowed(params) {
|
|
471
|
+
const { senderId, groupId, wecomConfig } = params;
|
|
472
|
+
const groupConfig = resolveWeComGroupConfig({
|
|
473
|
+
cfg: wecomConfig,
|
|
474
|
+
groupId,
|
|
475
|
+
});
|
|
476
|
+
const perGroupSenderAllowFrom = (groupConfig?.allowFrom ?? []).map((v) => String(v));
|
|
477
|
+
if (perGroupSenderAllowFrom.length === 0) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
if (perGroupSenderAllowFrom.includes("*")) {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
return perGroupSenderAllowFrom.some((entry) => {
|
|
484
|
+
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
|
|
485
|
+
return normalized === senderId || normalized === `user:${senderId}`;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// ============================================================================
|
|
489
|
+
// 公开 API
|
|
490
|
+
// ============================================================================
|
|
491
|
+
/**
|
|
492
|
+
* 检查群组策略访问控制
|
|
493
|
+
* @returns 检查结果,包含是否允许继续处理
|
|
494
|
+
*/
|
|
495
|
+
function checkGroupPolicy(params) {
|
|
496
|
+
const { chatId, senderId, account, config, runtime } = params;
|
|
497
|
+
const wecomConfig = (config.channels?.[CHANNEL_ID] ?? {});
|
|
498
|
+
const defaultGroupPolicy = pluginSdk.resolveDefaultGroupPolicy(config);
|
|
499
|
+
const { groupPolicy, providerMissingFallbackApplied } = pluginSdk.resolveOpenProviderRuntimeGroupPolicy({
|
|
500
|
+
providerConfigPresent: config.channels?.[CHANNEL_ID] !== undefined,
|
|
501
|
+
groupPolicy: wecomConfig.groupPolicy,
|
|
502
|
+
defaultGroupPolicy,
|
|
503
|
+
});
|
|
504
|
+
pluginSdk.warnMissingProviderGroupPolicyFallbackOnce({
|
|
505
|
+
providerMissingFallbackApplied,
|
|
506
|
+
providerKey: CHANNEL_ID,
|
|
507
|
+
accountId: account.accountId,
|
|
508
|
+
log: (msg) => runtime.log?.(msg),
|
|
509
|
+
});
|
|
510
|
+
const groupAllowFrom = wecomConfig.groupAllowFrom ?? [];
|
|
511
|
+
const groupAllowed = isWeComGroupAllowed({
|
|
512
|
+
groupPolicy,
|
|
513
|
+
allowFrom: groupAllowFrom,
|
|
514
|
+
groupId: chatId,
|
|
515
|
+
});
|
|
516
|
+
if (!groupAllowed) {
|
|
517
|
+
runtime.log?.(`[WeCom] Group ${chatId} not allowed (groupPolicy=${groupPolicy})`);
|
|
518
|
+
return { allowed: false };
|
|
519
|
+
}
|
|
520
|
+
const senderAllowed = isGroupSenderAllowed({
|
|
521
|
+
senderId,
|
|
522
|
+
groupId: chatId,
|
|
523
|
+
wecomConfig,
|
|
524
|
+
});
|
|
525
|
+
if (!senderAllowed) {
|
|
526
|
+
runtime.log?.(`[WeCom] Sender ${senderId} not in group ${chatId} sender allowlist`);
|
|
527
|
+
return { allowed: false };
|
|
528
|
+
}
|
|
529
|
+
return { allowed: true };
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 检查发送者是否在允许列表中(通用)
|
|
533
|
+
*/
|
|
534
|
+
function isSenderAllowed(senderId, allowFrom) {
|
|
535
|
+
if (allowFrom.includes("*")) {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
return allowFrom.some((entry) => {
|
|
539
|
+
const normalized = entry.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim();
|
|
540
|
+
return normalized === senderId || normalized === `user:${senderId}`;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 企业微信 DM(私聊)访问控制模块
|
|
546
|
+
*
|
|
547
|
+
* 负责私聊策略检查、配对流程
|
|
548
|
+
*/
|
|
549
|
+
// ============================================================================
|
|
550
|
+
// 公开 API
|
|
551
|
+
// ============================================================================
|
|
552
|
+
/**
|
|
553
|
+
* 检查 DM Policy 访问控制
|
|
554
|
+
* @returns 检查结果,包含是否允许继续处理
|
|
555
|
+
*/
|
|
556
|
+
async function checkDmPolicy(params) {
|
|
557
|
+
const { senderId, isGroup, account, wsClient, frame, runtime } = params;
|
|
558
|
+
const core = getWeComRuntime();
|
|
559
|
+
// 群聊消息不检查 DM Policy
|
|
560
|
+
if (isGroup) {
|
|
561
|
+
return { allowed: true };
|
|
562
|
+
}
|
|
563
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
564
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
|
565
|
+
// 如果 dmPolicy 是 disabled,直接拒绝
|
|
566
|
+
if (dmPolicy === "disabled") {
|
|
567
|
+
runtime.log?.(`[WeCom] Blocked DM from ${senderId} (dmPolicy=disabled)`);
|
|
568
|
+
return { allowed: false };
|
|
569
|
+
}
|
|
570
|
+
// 如果是 open 模式,允许所有人
|
|
571
|
+
if (dmPolicy === "open") {
|
|
572
|
+
return { allowed: true };
|
|
573
|
+
}
|
|
574
|
+
// 检查发送者是否在允许列表中
|
|
575
|
+
const storeAllowFrom = await core.channel.pairing
|
|
576
|
+
.readAllowFromStore({ channel: CHANNEL_ID, accountId: account.accountId })
|
|
577
|
+
.catch(() => []);
|
|
578
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
579
|
+
const senderAllowedResult = isSenderAllowed(senderId, effectiveAllowFrom);
|
|
580
|
+
if (senderAllowedResult) {
|
|
581
|
+
return { allowed: true };
|
|
582
|
+
}
|
|
583
|
+
// 处理未授权用户
|
|
584
|
+
if (dmPolicy === "pairing") {
|
|
585
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
586
|
+
channel: CHANNEL_ID,
|
|
587
|
+
id: senderId,
|
|
588
|
+
accountId: account.accountId,
|
|
589
|
+
meta: { name: senderId },
|
|
590
|
+
});
|
|
591
|
+
if (created) {
|
|
592
|
+
runtime.log?.(`[WeCom] Pairing request created for sender=${senderId}`);
|
|
593
|
+
try {
|
|
594
|
+
await sendWeComReply({
|
|
595
|
+
wsClient,
|
|
596
|
+
frame,
|
|
597
|
+
text: core.channel.pairing.buildPairingReply({
|
|
598
|
+
channel: CHANNEL_ID,
|
|
599
|
+
idLine: `您的企业微信用户ID: ${senderId}`,
|
|
600
|
+
code,
|
|
601
|
+
}),
|
|
602
|
+
runtime,
|
|
603
|
+
finish: true,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
runtime.error?.(`[WeCom] Failed to send pairing reply to ${senderId}: ${String(err)}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
runtime.log?.(`[WeCom] Pairing request already exists for sender=${senderId}`);
|
|
612
|
+
}
|
|
613
|
+
return { allowed: false, pairingSent: created };
|
|
614
|
+
}
|
|
615
|
+
// allowlist 模式:直接拒绝未授权用户
|
|
616
|
+
runtime.log?.(`[WeCom] Blocked unauthorized sender ${senderId} (dmPolicy=${dmPolicy})`);
|
|
617
|
+
return { allowed: false };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ============================================================================
|
|
621
|
+
// 常量
|
|
622
|
+
// ============================================================================
|
|
623
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天
|
|
624
|
+
const DEFAULT_MEMORY_MAX_SIZE = 200;
|
|
625
|
+
const DEFAULT_FILE_MAX_ENTRIES = 500;
|
|
626
|
+
const DEFAULT_FLUSH_DEBOUNCE_MS = 1000;
|
|
627
|
+
const DEFAULT_LOCK_OPTIONS = {
|
|
628
|
+
stale: 60000,
|
|
629
|
+
retries: {
|
|
630
|
+
retries: 6,
|
|
631
|
+
factor: 1.35,
|
|
632
|
+
minTimeout: 8,
|
|
633
|
+
maxTimeout: 180,
|
|
634
|
+
randomize: true,
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
// ============================================================================
|
|
638
|
+
// 状态目录解析
|
|
639
|
+
// ============================================================================
|
|
640
|
+
function resolveStateDirFromEnv(env = process.env) {
|
|
641
|
+
const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
642
|
+
if (stateOverride) {
|
|
643
|
+
return stateOverride;
|
|
644
|
+
}
|
|
645
|
+
if (env.VITEST || env.NODE_ENV === "test") {
|
|
646
|
+
return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
|
|
647
|
+
}
|
|
648
|
+
return path.join(os.homedir(), ".openclaw");
|
|
649
|
+
}
|
|
650
|
+
function resolveReqIdFilePath(accountId) {
|
|
651
|
+
const safe = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
652
|
+
return path.join(resolveStateDirFromEnv(), "wecom", `reqid-map-${safe}.json`);
|
|
653
|
+
}
|
|
654
|
+
// ============================================================================
|
|
655
|
+
// 核心实现
|
|
656
|
+
// ============================================================================
|
|
657
|
+
function createPersistentReqIdStore(accountId, options) {
|
|
658
|
+
const ttlMs = DEFAULT_TTL_MS;
|
|
659
|
+
const memoryMaxSize = DEFAULT_MEMORY_MAX_SIZE;
|
|
660
|
+
const fileMaxEntries = DEFAULT_FILE_MAX_ENTRIES;
|
|
661
|
+
const flushDebounceMs = DEFAULT_FLUSH_DEBOUNCE_MS;
|
|
662
|
+
const filePath = resolveReqIdFilePath(accountId);
|
|
663
|
+
// 内存层:chatId → ReqIdEntry
|
|
664
|
+
const memory = new Map();
|
|
665
|
+
// 防抖写入相关
|
|
666
|
+
let dirty = false;
|
|
667
|
+
let flushTimer = null;
|
|
668
|
+
// ========== 内部辅助函数 ==========
|
|
669
|
+
/** 检查条目是否过期 */
|
|
670
|
+
function isExpired(entry, now) {
|
|
671
|
+
return now - entry.ts >= ttlMs;
|
|
672
|
+
}
|
|
673
|
+
/** 验证磁盘条目的合法性 */
|
|
674
|
+
function isValidEntry(entry) {
|
|
675
|
+
return (typeof entry === "object" &&
|
|
676
|
+
entry !== null &&
|
|
677
|
+
typeof entry.reqId === "string" &&
|
|
678
|
+
typeof entry.ts === "number" &&
|
|
679
|
+
Number.isFinite(entry.ts));
|
|
680
|
+
}
|
|
681
|
+
/** 清理磁盘数据中的无效值,返回干净的 Record */
|
|
682
|
+
function sanitizeData(value) {
|
|
683
|
+
if (!value || typeof value !== "object") {
|
|
684
|
+
return {};
|
|
685
|
+
}
|
|
686
|
+
const out = {};
|
|
687
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
688
|
+
if (isValidEntry(entry)) {
|
|
689
|
+
out[key] = entry;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* 内存容量控制:淘汰最旧的条目。
|
|
696
|
+
* 利用 Map 的插入顺序 + touch(先 delete 再 set) 实现类 LRU 效果。
|
|
697
|
+
*/
|
|
698
|
+
function pruneMemory() {
|
|
699
|
+
if (memory.size <= memoryMaxSize)
|
|
700
|
+
return;
|
|
701
|
+
const sorted = [...memory.entries()].sort((a, b) => a[1].ts - b[1].ts);
|
|
702
|
+
const toRemove = sorted.slice(0, memory.size - memoryMaxSize);
|
|
703
|
+
for (const [key] of toRemove) {
|
|
704
|
+
memory.delete(key);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/** 磁盘数据容量控制:先清过期,再按时间淘汰超量 */
|
|
708
|
+
function pruneFileData(data, now) {
|
|
709
|
+
{
|
|
710
|
+
for (const [key, entry] of Object.entries(data)) {
|
|
711
|
+
if (now - entry.ts >= ttlMs) {
|
|
712
|
+
delete data[key];
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const keys = Object.keys(data);
|
|
717
|
+
if (keys.length <= fileMaxEntries)
|
|
718
|
+
return;
|
|
719
|
+
keys
|
|
720
|
+
.sort((a, b) => data[a].ts - data[b].ts)
|
|
721
|
+
.slice(0, keys.length - fileMaxEntries)
|
|
722
|
+
.forEach((key) => delete data[key]);
|
|
723
|
+
}
|
|
724
|
+
/** 防抖写入磁盘 */
|
|
725
|
+
function scheduleDiskFlush() {
|
|
726
|
+
dirty = true;
|
|
727
|
+
if (flushTimer)
|
|
728
|
+
return;
|
|
729
|
+
flushTimer = setTimeout(async () => {
|
|
730
|
+
flushTimer = null;
|
|
731
|
+
if (!dirty)
|
|
732
|
+
return;
|
|
733
|
+
await flushToDisk();
|
|
734
|
+
}, flushDebounceMs);
|
|
735
|
+
}
|
|
736
|
+
/** 立即写入磁盘(带文件锁,参考 createPersistentDedupe 的 checkAndRecordInner) */
|
|
737
|
+
async function flushToDisk() {
|
|
738
|
+
dirty = false;
|
|
739
|
+
const now = Date.now();
|
|
740
|
+
try {
|
|
741
|
+
await pluginSdk.withFileLock(filePath, DEFAULT_LOCK_OPTIONS, async () => {
|
|
742
|
+
// 读取现有磁盘数据并合并
|
|
743
|
+
const { value } = await pluginSdk.readJsonFileWithFallback(filePath, {});
|
|
744
|
+
const data = sanitizeData(value);
|
|
745
|
+
// 将内存中未过期的数据合并到磁盘数据(内存优先)
|
|
746
|
+
for (const [chatId, entry] of memory) {
|
|
747
|
+
if (!isExpired(entry, now)) {
|
|
748
|
+
data[chatId] = entry;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// 清理过期和超量
|
|
752
|
+
pruneFileData(data, now);
|
|
753
|
+
// 原子写入
|
|
754
|
+
await pluginSdk.writeJsonFileAtomically(filePath, data);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
// 磁盘写入失败不影响内存使用,降级到纯内存模式
|
|
759
|
+
console.error(`[WeCom] reqid-store: flush to disk failed: ${String(error)}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// ========== 公开 API ==========
|
|
763
|
+
function set(chatId, reqId) {
|
|
764
|
+
const entry = { reqId, ts: Date.now() };
|
|
765
|
+
// touch:先删再设,保持 Map 插入顺序(类 LRU)
|
|
766
|
+
memory.delete(chatId);
|
|
767
|
+
memory.set(chatId, entry);
|
|
768
|
+
pruneMemory();
|
|
769
|
+
scheduleDiskFlush();
|
|
770
|
+
}
|
|
771
|
+
async function get(chatId) {
|
|
772
|
+
const now = Date.now();
|
|
773
|
+
// 1. 先查内存
|
|
774
|
+
const memEntry = memory.get(chatId);
|
|
775
|
+
if (memEntry && !isExpired(memEntry, now)) {
|
|
776
|
+
return memEntry.reqId;
|
|
777
|
+
}
|
|
778
|
+
if (memEntry) {
|
|
779
|
+
memory.delete(chatId); // 过期则删除
|
|
780
|
+
}
|
|
781
|
+
// 2. 内存 miss,回查磁盘并回填内存
|
|
782
|
+
try {
|
|
783
|
+
const { value } = await pluginSdk.readJsonFileWithFallback(filePath, {});
|
|
784
|
+
const data = sanitizeData(value);
|
|
785
|
+
const diskEntry = data[chatId];
|
|
786
|
+
if (diskEntry && !isExpired(diskEntry, now)) {
|
|
787
|
+
// 回填内存
|
|
788
|
+
memory.set(chatId, diskEntry);
|
|
789
|
+
return diskEntry.reqId;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
// 磁盘读取失败,降级返回 undefined
|
|
794
|
+
}
|
|
795
|
+
return undefined;
|
|
796
|
+
}
|
|
797
|
+
function getSync(chatId) {
|
|
798
|
+
const now = Date.now();
|
|
799
|
+
const entry = memory.get(chatId);
|
|
800
|
+
if (entry && !isExpired(entry, now)) {
|
|
801
|
+
return entry.reqId;
|
|
802
|
+
}
|
|
803
|
+
if (entry) {
|
|
804
|
+
memory.delete(chatId);
|
|
805
|
+
}
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
function del(chatId) {
|
|
809
|
+
memory.delete(chatId);
|
|
810
|
+
scheduleDiskFlush();
|
|
811
|
+
}
|
|
812
|
+
async function warmup(onError) {
|
|
813
|
+
const now = Date.now();
|
|
814
|
+
try {
|
|
815
|
+
const { value } = await pluginSdk.readJsonFileWithFallback(filePath, {});
|
|
816
|
+
const data = sanitizeData(value);
|
|
817
|
+
let loaded = 0;
|
|
818
|
+
for (const [chatId, entry] of Object.entries(data)) {
|
|
819
|
+
if (!isExpired(entry, now)) {
|
|
820
|
+
memory.set(chatId, entry);
|
|
821
|
+
loaded++;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
pruneMemory();
|
|
825
|
+
return loaded;
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
onError?.(error);
|
|
829
|
+
return 0;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async function flush() {
|
|
833
|
+
if (flushTimer) {
|
|
834
|
+
clearTimeout(flushTimer);
|
|
835
|
+
flushTimer = null;
|
|
836
|
+
}
|
|
837
|
+
await flushToDisk();
|
|
838
|
+
}
|
|
839
|
+
function clearMemory() {
|
|
840
|
+
memory.clear();
|
|
841
|
+
}
|
|
842
|
+
function memorySize() {
|
|
843
|
+
return memory.size;
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
set,
|
|
847
|
+
get,
|
|
848
|
+
getSync,
|
|
849
|
+
delete: del,
|
|
850
|
+
warmup,
|
|
851
|
+
flush,
|
|
852
|
+
clearMemory,
|
|
853
|
+
memorySize,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* 企业微信全局状态管理模块
|
|
859
|
+
*
|
|
860
|
+
* 负责管理 WSClient 实例、消息状态(带 TTL 清理)、ReqId 存储
|
|
861
|
+
* 解决全局 Map 的内存泄漏问题
|
|
862
|
+
*/
|
|
863
|
+
// ============================================================================
|
|
864
|
+
// WSClient 实例管理
|
|
865
|
+
// ============================================================================
|
|
866
|
+
/** WSClient 实例管理 */
|
|
867
|
+
const wsClientInstances = new Map();
|
|
868
|
+
/**
|
|
869
|
+
* 获取指定账户的 WSClient 实例
|
|
870
|
+
*/
|
|
871
|
+
function getWeComWebSocket(accountId) {
|
|
872
|
+
return wsClientInstances.get(accountId) ?? null;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* 设置指定账户的 WSClient 实例
|
|
876
|
+
*/
|
|
877
|
+
function setWeComWebSocket(accountId, client) {
|
|
878
|
+
wsClientInstances.set(accountId, client);
|
|
879
|
+
}
|
|
880
|
+
/** 消息状态管理 */
|
|
881
|
+
const messageStates = new Map();
|
|
882
|
+
/** 定期清理定时器 */
|
|
883
|
+
let cleanupTimer = null;
|
|
884
|
+
/**
|
|
885
|
+
* 启动消息状态定期清理(自动 TTL 清理 + 容量限制)
|
|
886
|
+
*/
|
|
887
|
+
function startMessageStateCleanup() {
|
|
888
|
+
if (cleanupTimer)
|
|
889
|
+
return;
|
|
890
|
+
cleanupTimer = setInterval(() => {
|
|
891
|
+
pruneMessageStates();
|
|
892
|
+
}, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
|
|
893
|
+
// 允许进程退出时不阻塞
|
|
894
|
+
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
895
|
+
cleanupTimer.unref();
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* 停止消息状态定期清理
|
|
900
|
+
*/
|
|
901
|
+
function stopMessageStateCleanup() {
|
|
902
|
+
if (cleanupTimer) {
|
|
903
|
+
clearInterval(cleanupTimer);
|
|
904
|
+
cleanupTimer = null;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* 清理过期和超量的消息状态条目
|
|
909
|
+
*/
|
|
910
|
+
function pruneMessageStates() {
|
|
911
|
+
const now = Date.now();
|
|
912
|
+
// 1. 清理过期条目
|
|
913
|
+
for (const [key, entry] of messageStates) {
|
|
914
|
+
if (now - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
|
|
915
|
+
messageStates.delete(key);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// 2. 容量限制:如果仍超过最大条目数,按时间淘汰最旧的
|
|
919
|
+
if (messageStates.size > MESSAGE_STATE_MAX_SIZE) {
|
|
920
|
+
const sorted = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
921
|
+
const toRemove = sorted.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE);
|
|
922
|
+
for (const [key] of toRemove) {
|
|
923
|
+
messageStates.delete(key);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* 设置消息状态
|
|
929
|
+
*/
|
|
930
|
+
function setMessageState(messageId, state) {
|
|
931
|
+
messageStates.set(messageId, {
|
|
932
|
+
state,
|
|
933
|
+
createdAt: Date.now(),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* 删除消息状态
|
|
938
|
+
*/
|
|
939
|
+
function deleteMessageState(messageId) {
|
|
940
|
+
messageStates.delete(messageId);
|
|
941
|
+
}
|
|
942
|
+
// ============================================================================
|
|
943
|
+
// ReqId 持久化存储管理(按 accountId 隔离)
|
|
944
|
+
// ============================================================================
|
|
945
|
+
/**
|
|
946
|
+
* ReqId 持久化存储管理
|
|
947
|
+
* 参考 createPersistentDedupe 模式:内存 + 磁盘双层、文件锁、原子写入、TTL 过期、防抖写入
|
|
948
|
+
* 重启后可从磁盘恢复,确保主动推送消息时能获取到 reqId
|
|
949
|
+
*/
|
|
950
|
+
const reqIdStores = new Map();
|
|
951
|
+
function getOrCreateReqIdStore(accountId) {
|
|
952
|
+
let store = reqIdStores.get(accountId);
|
|
953
|
+
if (!store) {
|
|
954
|
+
store = createPersistentReqIdStore(accountId);
|
|
955
|
+
reqIdStores.set(accountId, store);
|
|
956
|
+
}
|
|
957
|
+
return store;
|
|
958
|
+
}
|
|
959
|
+
// ============================================================================
|
|
960
|
+
// ReqId 操作函数
|
|
961
|
+
// ============================================================================
|
|
962
|
+
/**
|
|
963
|
+
* 设置 chatId 对应的 reqId(写入内存 + 防抖写磁盘)
|
|
964
|
+
*/
|
|
965
|
+
function setReqIdForChat(chatId, reqId, accountId = "default") {
|
|
966
|
+
getOrCreateReqIdStore(accountId).set(chatId, reqId);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* 启动时预热 reqId 缓存(从磁盘加载到内存)
|
|
970
|
+
*/
|
|
971
|
+
async function warmupReqIdStore(accountId = "default", log) {
|
|
972
|
+
const store = getOrCreateReqIdStore(accountId);
|
|
973
|
+
return store.warmup((error) => {
|
|
974
|
+
log?.(`[WeCom] reqid-store warmup error: ${String(error)}`);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
// ============================================================================
|
|
978
|
+
// 全局 cleanup(断开连接时释放所有资源)
|
|
979
|
+
// ============================================================================
|
|
980
|
+
/**
|
|
981
|
+
* 清理指定账户的所有资源
|
|
982
|
+
*/
|
|
983
|
+
async function cleanupAccount(accountId) {
|
|
984
|
+
// 1. 断开 WSClient
|
|
985
|
+
const wsClient = wsClientInstances.get(accountId);
|
|
986
|
+
if (wsClient) {
|
|
987
|
+
try {
|
|
988
|
+
wsClient.disconnect();
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
// 忽略断开连接时的错误
|
|
992
|
+
}
|
|
993
|
+
wsClientInstances.delete(accountId);
|
|
994
|
+
}
|
|
995
|
+
// 2. flush reqId 存储到磁盘
|
|
996
|
+
const store = reqIdStores.get(accountId);
|
|
997
|
+
if (store) {
|
|
998
|
+
try {
|
|
999
|
+
await store.flush();
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
// 忽略 flush 错误
|
|
1003
|
+
}
|
|
1004
|
+
// 注意:不删除 store,因为重连后可能还需要
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* 企业微信 WebSocket 监控器主模块
|
|
1010
|
+
*
|
|
1011
|
+
* 负责:
|
|
1012
|
+
* - 建立和管理 WebSocket 连接
|
|
1013
|
+
* - 协调消息处理流程(解析→策略检查→下载图片→路由回复)
|
|
1014
|
+
* - 资源生命周期管理
|
|
1015
|
+
*
|
|
1016
|
+
* 子模块:
|
|
1017
|
+
* - message-parser.ts : 消息内容解析
|
|
1018
|
+
* - message-sender.ts : 消息发送(带超时保护)
|
|
1019
|
+
* - media-handler.ts : 图片下载和保存(带超时保护)
|
|
1020
|
+
* - group-policy.ts : 群组访问控制
|
|
1021
|
+
* - dm-policy.ts : 私聊访问控制
|
|
1022
|
+
* - state-manager.ts : 全局状态管理(带 TTL 清理)
|
|
1023
|
+
* - timeout.ts : 超时工具
|
|
1024
|
+
*/
|
|
1025
|
+
// ============================================================================
|
|
1026
|
+
// 消息上下文构建
|
|
1027
|
+
// ============================================================================
|
|
1028
|
+
/**
|
|
1029
|
+
* 构建消息上下文
|
|
1030
|
+
*/
|
|
1031
|
+
function buildMessageContext(frame, account, config, text, mediaList, quoteContent) {
|
|
1032
|
+
const core = getWeComRuntime();
|
|
1033
|
+
const body = frame.body;
|
|
1034
|
+
const chatId = body.chatid || body.from.userid;
|
|
1035
|
+
const chatType = body.chattype === "group" ? "group" : "direct";
|
|
1036
|
+
// 解析路由信息
|
|
1037
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
1038
|
+
cfg: config,
|
|
1039
|
+
channel: CHANNEL_ID,
|
|
1040
|
+
accountId: account.accountId,
|
|
1041
|
+
peer: {
|
|
1042
|
+
kind: chatType,
|
|
1043
|
+
id: chatId,
|
|
1044
|
+
},
|
|
1045
|
+
});
|
|
1046
|
+
// 构建会话标签
|
|
1047
|
+
const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${body.from.userid}`;
|
|
1048
|
+
// 当只有媒体没有文本时,使用占位符标识媒体类型
|
|
1049
|
+
const hasImages = mediaList.some((m) => m.contentType?.startsWith("image/"));
|
|
1050
|
+
const messageBody = text || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
|
|
1051
|
+
// 构建多媒体数组
|
|
1052
|
+
const mediaPaths = mediaList.length > 0 ? mediaList.map((m) => m.path) : undefined;
|
|
1053
|
+
const mediaTypes = mediaList.length > 0
|
|
1054
|
+
? mediaList.map((m) => m.contentType).filter(Boolean)
|
|
1055
|
+
: undefined;
|
|
1056
|
+
// 构建标准消息上下文
|
|
1057
|
+
return core.channel.reply.finalizeInboundContext({
|
|
1058
|
+
Body: messageBody,
|
|
1059
|
+
RawBody: messageBody,
|
|
1060
|
+
CommandBody: messageBody,
|
|
1061
|
+
MessageSid: body.msgid,
|
|
1062
|
+
From: chatType === "group" ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${body.from.userid}`,
|
|
1063
|
+
To: `${CHANNEL_ID}:${chatId}`,
|
|
1064
|
+
SenderId: body.from.userid,
|
|
1065
|
+
SessionKey: route.sessionKey,
|
|
1066
|
+
AccountId: account.accountId,
|
|
1067
|
+
ChatType: chatType,
|
|
1068
|
+
ConversationLabel: fromLabel,
|
|
1069
|
+
Timestamp: Date.now(),
|
|
1070
|
+
Provider: CHANNEL_ID,
|
|
1071
|
+
Surface: CHANNEL_ID,
|
|
1072
|
+
OriginatingChannel: CHANNEL_ID,
|
|
1073
|
+
OriginatingTo: `${CHANNEL_ID}:${chatId}`,
|
|
1074
|
+
CommandAuthorized: true,
|
|
1075
|
+
ResponseUrl: body.response_url,
|
|
1076
|
+
ReqId: frame.headers.req_id,
|
|
1077
|
+
WeComFrame: frame,
|
|
1078
|
+
MediaPath: mediaList[0]?.path,
|
|
1079
|
+
MediaType: mediaList[0]?.contentType,
|
|
1080
|
+
MediaPaths: mediaPaths,
|
|
1081
|
+
MediaTypes: mediaTypes,
|
|
1082
|
+
MediaUrls: mediaPaths,
|
|
1083
|
+
QuoteContent: quoteContent,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
// ============================================================================
|
|
1087
|
+
// 消息处理和回复
|
|
1088
|
+
// ============================================================================
|
|
1089
|
+
/**
|
|
1090
|
+
* 发送"思考中"消息
|
|
1091
|
+
*/
|
|
1092
|
+
async function sendThinkingReply(params) {
|
|
1093
|
+
const { wsClient, frame, streamId, runtime } = params;
|
|
1094
|
+
runtime.log?.(`[WeCom] Sending thinking message`);
|
|
1095
|
+
try {
|
|
1096
|
+
await sendWeComReply({
|
|
1097
|
+
wsClient,
|
|
1098
|
+
frame,
|
|
1099
|
+
text: THINKING_MESSAGE,
|
|
1100
|
+
runtime,
|
|
1101
|
+
finish: false,
|
|
1102
|
+
streamId,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
runtime.error?.(`[WeCom] Failed to send thinking message: ${String(err)}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* 路由消息到核心处理流程并处理回复
|
|
1111
|
+
*/
|
|
1112
|
+
async function routeAndDispatchMessage(params) {
|
|
1113
|
+
const { ctxPayload, config, wsClient, frame, state, runtime, onCleanup } = params;
|
|
1114
|
+
const core = getWeComRuntime();
|
|
1115
|
+
try {
|
|
1116
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1117
|
+
ctx: ctxPayload,
|
|
1118
|
+
cfg: config,
|
|
1119
|
+
dispatcherOptions: {
|
|
1120
|
+
deliver: async (payload, info) => {
|
|
1121
|
+
state.accumulatedText += payload.text;
|
|
1122
|
+
if (info.kind !== "final") {
|
|
1123
|
+
await sendWeComReply({
|
|
1124
|
+
wsClient,
|
|
1125
|
+
frame,
|
|
1126
|
+
text: state.accumulatedText,
|
|
1127
|
+
runtime,
|
|
1128
|
+
finish: false,
|
|
1129
|
+
streamId: state.streamId,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
},
|
|
1133
|
+
onError: (err, info) => {
|
|
1134
|
+
runtime.error?.(`[WeCom] ${info.kind} reply failed: ${String(err)}`);
|
|
1135
|
+
onCleanup();
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
1138
|
+
});
|
|
1139
|
+
// 发送最终消息
|
|
1140
|
+
if (state.accumulatedText) {
|
|
1141
|
+
await sendWeComReply({
|
|
1142
|
+
wsClient,
|
|
1143
|
+
frame,
|
|
1144
|
+
text: state.accumulatedText,
|
|
1145
|
+
runtime,
|
|
1146
|
+
finish: true,
|
|
1147
|
+
streamId: state.streamId,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
onCleanup();
|
|
1151
|
+
}
|
|
1152
|
+
catch (err) {
|
|
1153
|
+
runtime.error?.(`[WeCom] Failed to process message: ${String(err)}`);
|
|
1154
|
+
onCleanup();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* 处理企业微信消息(主函数)
|
|
1159
|
+
*
|
|
1160
|
+
* 处理流程:
|
|
1161
|
+
* 1. 解析消息内容(文本、图片、引用)
|
|
1162
|
+
* 2. 群组策略检查(仅群聊)
|
|
1163
|
+
* 3. DM Policy 访问控制检查(仅私聊)
|
|
1164
|
+
* 4. 下载并保存图片
|
|
1165
|
+
* 5. 初始化消息状态
|
|
1166
|
+
* 6. 发送"思考中"消息
|
|
1167
|
+
* 7. 路由消息到核心处理流程
|
|
1168
|
+
*
|
|
1169
|
+
* 整体带超时保护,防止单条消息处理阻塞过久
|
|
1170
|
+
*/
|
|
1171
|
+
async function processWeComMessage(params) {
|
|
1172
|
+
const { frame, account, config, runtime, wsClient } = params;
|
|
1173
|
+
const body = frame.body;
|
|
1174
|
+
const chatId = body.chatid || body.from.userid;
|
|
1175
|
+
const chatType = body.chattype === "group" ? "group" : "direct";
|
|
1176
|
+
const messageId = body.msgid;
|
|
1177
|
+
const reqId = frame.headers.req_id;
|
|
1178
|
+
// Step 1: 解析消息内容
|
|
1179
|
+
const { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent } = parseMessageContent(body);
|
|
1180
|
+
let text = textParts.join("\n").trim();
|
|
1181
|
+
// 群聊中移除 @机器人 的提及标记
|
|
1182
|
+
if (body.chattype === "group") {
|
|
1183
|
+
text = text.replace(/@\S+/g, "").trim();
|
|
1184
|
+
}
|
|
1185
|
+
// 如果文本为空但存在引用消息,使用引用消息内容
|
|
1186
|
+
if (!text && quoteContent) {
|
|
1187
|
+
text = quoteContent;
|
|
1188
|
+
runtime.log?.("[WeCom] Using quote content as message body (user only mentioned bot)");
|
|
1189
|
+
}
|
|
1190
|
+
// 如果既没有文本也没有图片也没有文件也没有引用内容,则跳过
|
|
1191
|
+
if (!text && imageUrls.length === 0 && fileUrls.length === 0) {
|
|
1192
|
+
runtime.log?.("[WeCom] Skipping empty message (no text, image, file or quote)");
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
runtime.log?.(`[WeCom] Processing ${chatType} message from chat: ${chatId} user: ${body.from.userid} reqId: ${reqId}${imageUrls.length > 0 ? ` (with ${imageUrls.length} image(s))` : ""}${fileUrls.length > 0 ? ` (with ${fileUrls.length} file(s))` : ""}${quoteContent ? ` (with quote)` : ""}`);
|
|
1196
|
+
// Step 2: 群组策略检查(仅群聊)
|
|
1197
|
+
if (chatType === "group") {
|
|
1198
|
+
const groupPolicyResult = checkGroupPolicy({
|
|
1199
|
+
chatId,
|
|
1200
|
+
senderId: body.from.userid,
|
|
1201
|
+
account,
|
|
1202
|
+
config,
|
|
1203
|
+
runtime,
|
|
1204
|
+
});
|
|
1205
|
+
if (!groupPolicyResult.allowed) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
// Step 3: DM Policy 访问控制检查(仅私聊)
|
|
1210
|
+
const dmPolicyResult = await checkDmPolicy({
|
|
1211
|
+
senderId: body.from.userid,
|
|
1212
|
+
isGroup: chatType === "group",
|
|
1213
|
+
account,
|
|
1214
|
+
wsClient,
|
|
1215
|
+
frame,
|
|
1216
|
+
runtime,
|
|
1217
|
+
});
|
|
1218
|
+
if (!dmPolicyResult.allowed) {
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
// Step 4: 下载并保存图片和文件
|
|
1222
|
+
const [imageMediaList, fileMediaList] = await Promise.all([
|
|
1223
|
+
downloadAndSaveImages({
|
|
1224
|
+
imageUrls,
|
|
1225
|
+
imageAesKeys,
|
|
1226
|
+
account,
|
|
1227
|
+
config,
|
|
1228
|
+
runtime,
|
|
1229
|
+
wsClient,
|
|
1230
|
+
}),
|
|
1231
|
+
downloadAndSaveFiles({
|
|
1232
|
+
fileUrls,
|
|
1233
|
+
fileAesKeys,
|
|
1234
|
+
account,
|
|
1235
|
+
config,
|
|
1236
|
+
runtime,
|
|
1237
|
+
wsClient,
|
|
1238
|
+
}),
|
|
1239
|
+
]);
|
|
1240
|
+
const mediaList = [...imageMediaList, ...fileMediaList];
|
|
1241
|
+
// Step 5: 初始化消息状态
|
|
1242
|
+
setReqIdForChat(chatId, reqId, account.accountId);
|
|
1243
|
+
const streamId = generateReqId("stream");
|
|
1244
|
+
const state = { accumulatedText: "", streamId };
|
|
1245
|
+
setMessageState(messageId, state);
|
|
1246
|
+
const cleanupState = () => {
|
|
1247
|
+
deleteMessageState(messageId);
|
|
1248
|
+
};
|
|
1249
|
+
// Step 6: 发送"思考中"消息
|
|
1250
|
+
const shouldSendThinking = account.sendThinkingMessage ?? true;
|
|
1251
|
+
if (shouldSendThinking) {
|
|
1252
|
+
await sendThinkingReply({ wsClient, frame, streamId, runtime });
|
|
1253
|
+
}
|
|
1254
|
+
// Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
|
|
1255
|
+
const ctxPayload = buildMessageContext(frame, account, config, text, mediaList, quoteContent);
|
|
1256
|
+
try {
|
|
1257
|
+
await withTimeout(routeAndDispatchMessage({
|
|
1258
|
+
ctxPayload,
|
|
1259
|
+
config,
|
|
1260
|
+
wsClient,
|
|
1261
|
+
frame,
|
|
1262
|
+
state,
|
|
1263
|
+
runtime,
|
|
1264
|
+
onCleanup: cleanupState,
|
|
1265
|
+
}), MESSAGE_PROCESS_TIMEOUT_MS, `Message processing timed out (msgId=${messageId})`);
|
|
1266
|
+
}
|
|
1267
|
+
catch (err) {
|
|
1268
|
+
runtime.error?.(`[WeCom] Message processing failed or timed out: ${String(err)}`);
|
|
1269
|
+
cleanupState();
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
// ============================================================================
|
|
1273
|
+
// 创建 SDK Logger 适配器
|
|
1274
|
+
// ============================================================================
|
|
1275
|
+
/**
|
|
1276
|
+
* 创建适配 RuntimeEnv 的 Logger
|
|
1277
|
+
*/
|
|
1278
|
+
function createSdkLogger(runtime, accountId) {
|
|
1279
|
+
return {
|
|
1280
|
+
debug: (message, ...args) => {
|
|
1281
|
+
runtime.log?.(`[${accountId}] ${message}`, ...args);
|
|
1282
|
+
},
|
|
1283
|
+
info: (message, ...args) => {
|
|
1284
|
+
runtime.log?.(`[${accountId}] ${message}`, ...args);
|
|
1285
|
+
},
|
|
1286
|
+
warn: (message, ...args) => {
|
|
1287
|
+
runtime.log?.(`[${accountId}] WARN: ${message}`, ...args);
|
|
1288
|
+
},
|
|
1289
|
+
error: (message, ...args) => {
|
|
1290
|
+
runtime.error?.(`[${accountId}] ${message}`, ...args);
|
|
1291
|
+
},
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
// ============================================================================
|
|
1295
|
+
// 主函数
|
|
1296
|
+
// ============================================================================
|
|
1297
|
+
/**
|
|
1298
|
+
* 监听企业微信 WebSocket 连接
|
|
1299
|
+
* 使用 aibot-node-sdk 简化连接管理
|
|
1300
|
+
*/
|
|
1301
|
+
async function monitorWeComProvider(options) {
|
|
1302
|
+
const { account, config, runtime, abortSignal } = options;
|
|
1303
|
+
runtime.log?.(`[${account.accountId}] Initializing WSClient with SDK...`);
|
|
1304
|
+
// 启动消息状态定期清理
|
|
1305
|
+
startMessageStateCleanup();
|
|
1306
|
+
return new Promise((resolve, reject) => {
|
|
1307
|
+
const logger = createSdkLogger(runtime, account.accountId);
|
|
1308
|
+
const wsClient = new aibotNodeSdk.WSClient({
|
|
1309
|
+
botId: account.botId,
|
|
1310
|
+
secret: account.secret,
|
|
1311
|
+
wsUrl: account.websocketUrl,
|
|
1312
|
+
logger,
|
|
1313
|
+
heartbeatInterval: WS_HEARTBEAT_INTERVAL_MS,
|
|
1314
|
+
maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
|
|
1315
|
+
});
|
|
1316
|
+
// 清理函数:确保所有资源被释放
|
|
1317
|
+
const cleanup = async () => {
|
|
1318
|
+
stopMessageStateCleanup();
|
|
1319
|
+
await cleanupAccount(account.accountId);
|
|
1320
|
+
};
|
|
1321
|
+
// 处理中止信号
|
|
1322
|
+
if (abortSignal) {
|
|
1323
|
+
abortSignal.addEventListener("abort", async () => {
|
|
1324
|
+
runtime.log?.(`[${account.accountId}] Connection aborted`);
|
|
1325
|
+
await cleanup();
|
|
1326
|
+
resolve();
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
// 监听连接事件
|
|
1330
|
+
wsClient.on("connected", () => {
|
|
1331
|
+
runtime.log?.(`[${account.accountId}] WebSocket connected`);
|
|
1332
|
+
});
|
|
1333
|
+
// 监听认证成功事件
|
|
1334
|
+
wsClient.on("authenticated", () => {
|
|
1335
|
+
runtime.log?.(`[${account.accountId}] Authentication successful`);
|
|
1336
|
+
setWeComWebSocket(account.accountId, wsClient);
|
|
1337
|
+
});
|
|
1338
|
+
// 监听断开事件
|
|
1339
|
+
wsClient.on("disconnected", (reason) => {
|
|
1340
|
+
runtime.log?.(`[${account.accountId}] WebSocket disconnected: ${reason}`);
|
|
1341
|
+
});
|
|
1342
|
+
// 监听重连事件
|
|
1343
|
+
wsClient.on("reconnecting", (attempt) => {
|
|
1344
|
+
runtime.log?.(`[${account.accountId}] Reconnecting attempt ${attempt}...`);
|
|
1345
|
+
});
|
|
1346
|
+
// 监听错误事件
|
|
1347
|
+
wsClient.on("error", (error) => {
|
|
1348
|
+
runtime.error?.(`[${account.accountId}] WebSocket error: ${error.message}`);
|
|
1349
|
+
// 认证失败时拒绝 Promise
|
|
1350
|
+
if (error.message.includes("Authentication failed")) {
|
|
1351
|
+
cleanup().finally(() => reject(error));
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
// 监听所有消息
|
|
1355
|
+
wsClient.on("message", async (frame) => {
|
|
1356
|
+
try {
|
|
1357
|
+
await processWeComMessage({
|
|
1358
|
+
frame,
|
|
1359
|
+
account,
|
|
1360
|
+
config,
|
|
1361
|
+
runtime,
|
|
1362
|
+
wsClient,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
catch (err) {
|
|
1366
|
+
runtime.error?.(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
// 启动前预热 reqId 缓存
|
|
1370
|
+
warmupReqIdStore(account.accountId, (...args) => runtime.log?.(...args))
|
|
1371
|
+
.then((count) => {
|
|
1372
|
+
runtime.log?.(`[${account.accountId}] Warmed up ${count} reqId entries from disk`);
|
|
1373
|
+
})
|
|
1374
|
+
.catch((err) => {
|
|
1375
|
+
runtime.error?.(`[${account.accountId}] Failed to warmup reqId store: ${String(err)}`);
|
|
1376
|
+
});
|
|
1377
|
+
// 建立连接
|
|
1378
|
+
wsClient.connect();
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* 企业微信 onboarding adapter for CLI setup wizard.
|
|
1384
|
+
*/
|
|
1385
|
+
const channel = CHANNEL_ID;
|
|
1386
|
+
/**
|
|
1387
|
+
* 企业微信设置帮助说明
|
|
1388
|
+
*/
|
|
1389
|
+
async function noteWeComSetupHelp(prompter) {
|
|
1390
|
+
await prompter.note([
|
|
1391
|
+
"企业微信机器人需要以下配置信息:",
|
|
1392
|
+
"1. Bot ID: 企业微信机器人id",
|
|
1393
|
+
"2. Secret: 企业微信机器人密钥",
|
|
1394
|
+
].join("\n"), "企业微信设置");
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* 提示输入 Bot ID
|
|
1398
|
+
*/
|
|
1399
|
+
async function promptBotId(prompter, account) {
|
|
1400
|
+
return String(await prompter.text({
|
|
1401
|
+
message: "企业微信机器人 Bot ID",
|
|
1402
|
+
initialValue: account?.botId ?? "",
|
|
1403
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
1404
|
+
})).trim();
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* 提示输入 Secret
|
|
1408
|
+
*/
|
|
1409
|
+
async function promptSecret(prompter, account) {
|
|
1410
|
+
return String(await prompter.text({
|
|
1411
|
+
message: "企业微信机器人 Secret",
|
|
1412
|
+
initialValue: account?.secret ?? "",
|
|
1413
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
1414
|
+
})).trim();
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* 设置企业微信 dmPolicy
|
|
1418
|
+
*/
|
|
1419
|
+
function setWeComDmPolicy(cfg, dmPolicy) {
|
|
1420
|
+
const account = resolveWeComAccount(cfg);
|
|
1421
|
+
const existingAllowFrom = account.config.allowFrom ?? [];
|
|
1422
|
+
const allowFrom = dmPolicy === "open"
|
|
1423
|
+
? pluginSdk.addWildcardAllowFrom(existingAllowFrom.map((x) => String(x)))
|
|
1424
|
+
: existingAllowFrom.map((x) => String(x));
|
|
1425
|
+
return setWeComAccount(cfg, {
|
|
1426
|
+
dmPolicy,
|
|
1427
|
+
allowFrom,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
const dmPolicy = {
|
|
1431
|
+
label: "企业微信",
|
|
1432
|
+
channel,
|
|
1433
|
+
policyKey: `channels.${CHANNEL_ID}.dmPolicy`,
|
|
1434
|
+
allowFromKey: `channels.${CHANNEL_ID}.allowFrom`,
|
|
1435
|
+
getCurrent: (cfg) => {
|
|
1436
|
+
const account = resolveWeComAccount(cfg);
|
|
1437
|
+
return account.config.dmPolicy ?? "pairing";
|
|
1438
|
+
},
|
|
1439
|
+
setPolicy: (cfg, policy) => {
|
|
1440
|
+
return setWeComDmPolicy(cfg, policy);
|
|
1441
|
+
},
|
|
1442
|
+
promptAllowFrom: async ({ cfg, prompter }) => {
|
|
1443
|
+
const account = resolveWeComAccount(cfg);
|
|
1444
|
+
const existingAllowFrom = account.config.allowFrom ?? [];
|
|
1445
|
+
const entry = await prompter.text({
|
|
1446
|
+
message: "企业微信允许来源(用户ID或群组ID,每行一个,推荐用于安全控制)",
|
|
1447
|
+
placeholder: "user123 或 group456",
|
|
1448
|
+
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
1449
|
+
});
|
|
1450
|
+
const allowFrom = String(entry ?? "")
|
|
1451
|
+
.split(/[\n,;]+/g)
|
|
1452
|
+
.map((s) => s.trim())
|
|
1453
|
+
.filter(Boolean);
|
|
1454
|
+
return setWeComAccount(cfg, { allowFrom });
|
|
1455
|
+
},
|
|
1456
|
+
};
|
|
1457
|
+
const wecomOnboardingAdapter = {
|
|
1458
|
+
channel,
|
|
1459
|
+
getStatus: async ({ cfg }) => {
|
|
1460
|
+
const account = resolveWeComAccount(cfg);
|
|
1461
|
+
const configured = Boolean(account.botId?.trim() &&
|
|
1462
|
+
account.secret?.trim());
|
|
1463
|
+
return {
|
|
1464
|
+
channel,
|
|
1465
|
+
configured,
|
|
1466
|
+
statusLines: [`企业微信: ${configured ? "已配置" : "需要 Bot ID 和 Secret"}`],
|
|
1467
|
+
selectionHint: configured ? "已配置" : "需要设置",
|
|
1468
|
+
};
|
|
1469
|
+
},
|
|
1470
|
+
configure: async ({ cfg, prompter, forceAllowFrom }) => {
|
|
1471
|
+
const account = resolveWeComAccount(cfg);
|
|
1472
|
+
if (!account.botId?.trim() || !account.secret?.trim()) {
|
|
1473
|
+
await noteWeComSetupHelp(prompter);
|
|
1474
|
+
}
|
|
1475
|
+
// 提示输入必要的配置信息:Bot ID 和 Secret
|
|
1476
|
+
const botId = await promptBotId(prompter, account);
|
|
1477
|
+
const secret = await promptSecret(prompter, account);
|
|
1478
|
+
// 使用默认值配置其他选项
|
|
1479
|
+
const cfgWithAccount = setWeComAccount(cfg, {
|
|
1480
|
+
botId,
|
|
1481
|
+
secret,
|
|
1482
|
+
enabled: true,
|
|
1483
|
+
dmPolicy: account.config.dmPolicy ?? "pairing",
|
|
1484
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
1485
|
+
});
|
|
1486
|
+
return { cfg: cfgWithAccount };
|
|
1487
|
+
},
|
|
1488
|
+
dmPolicy,
|
|
1489
|
+
disable: (cfg) => {
|
|
1490
|
+
return setWeComAccount(cfg, { enabled: false });
|
|
1491
|
+
},
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* 使用 SDK 的 sendMessage 主动发送企业微信消息
|
|
1496
|
+
* 无需依赖 reqId,直接向指定会话推送消息
|
|
1497
|
+
*/
|
|
1498
|
+
async function sendWeComMessage({ to, content, accountId, }) {
|
|
1499
|
+
const resolvedAccountId = accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID;
|
|
1500
|
+
// 从 to 中提取 chatId(格式是 "${CHANNEL_ID}:chatId" 或直接是 chatId)
|
|
1501
|
+
const channelPrefix = new RegExp(`^${CHANNEL_ID}:`, "i");
|
|
1502
|
+
const chatId = to.replace(channelPrefix, "");
|
|
1503
|
+
console.log(`[WeCom] sendWeComMessage: ${JSON.stringify({ to, content, accountId })}`);
|
|
1504
|
+
// 获取 WSClient 实例
|
|
1505
|
+
const wsClient = getWeComWebSocket(resolvedAccountId);
|
|
1506
|
+
if (!wsClient || !wsClient.isConnected) {
|
|
1507
|
+
throw new Error(`WSClient not connected for account ${resolvedAccountId}`);
|
|
1508
|
+
}
|
|
1509
|
+
// 使用 SDK 的 sendMessage 主动发送 markdown 消息
|
|
1510
|
+
const result = await wsClient.sendMessage(chatId, {
|
|
1511
|
+
msgtype: 'markdown',
|
|
1512
|
+
markdown: { content },
|
|
1513
|
+
});
|
|
1514
|
+
const messageId = result?.headers?.req_id ?? `wecom-${Date.now()}`;
|
|
1515
|
+
console.log(`[WeCom] Sent message to ${chatId}, messageId=${messageId}`);
|
|
1516
|
+
return {
|
|
1517
|
+
channel: CHANNEL_ID,
|
|
1518
|
+
messageId,
|
|
1519
|
+
chatId,
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
// 企业微信频道元数据
|
|
1523
|
+
const meta = {
|
|
1524
|
+
id: CHANNEL_ID,
|
|
1525
|
+
label: "企业微信",
|
|
1526
|
+
selectionLabel: "企业微信 (WeCom)",
|
|
1527
|
+
detailLabel: "企业微信智能机器人",
|
|
1528
|
+
docsPath: `/channels/${CHANNEL_ID}`,
|
|
1529
|
+
docsLabel: CHANNEL_ID,
|
|
1530
|
+
blurb: "企业微信智能机器人接入插件",
|
|
1531
|
+
systemImage: "message.fill",
|
|
1532
|
+
};
|
|
1533
|
+
const wecomPlugin = {
|
|
1534
|
+
id: CHANNEL_ID,
|
|
1535
|
+
meta: {
|
|
1536
|
+
...meta,
|
|
1537
|
+
quickstartAllowFrom: true,
|
|
1538
|
+
},
|
|
1539
|
+
pairing: {
|
|
1540
|
+
idLabel: "wecomUserId",
|
|
1541
|
+
normalizeAllowEntry: (entry) => entry.replace(new RegExp(`^(${CHANNEL_ID}|user):`, "i"), "").trim(),
|
|
1542
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
1543
|
+
// 企业微信机器人不支持主动发送消息,只能在用户下次发消息时通知
|
|
1544
|
+
// 这里暂时不实现,因为需要有 reqId 才能回复
|
|
1545
|
+
console.log(`[WeCom] Pairing approved for user: ${id}`);
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
onboarding: wecomOnboardingAdapter,
|
|
1549
|
+
capabilities: {
|
|
1550
|
+
chatTypes: ["direct", "group"],
|
|
1551
|
+
reactions: false,
|
|
1552
|
+
threads: false,
|
|
1553
|
+
media: true,
|
|
1554
|
+
nativeCommands: false,
|
|
1555
|
+
blockStreaming: true,
|
|
1556
|
+
},
|
|
1557
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
1558
|
+
config: {
|
|
1559
|
+
// 列出所有账户 ID(最小实现只支持默认账户)
|
|
1560
|
+
listAccountIds: () => [pluginSdk.DEFAULT_ACCOUNT_ID],
|
|
1561
|
+
// 解析账户配置
|
|
1562
|
+
resolveAccount: (cfg) => resolveWeComAccount(cfg),
|
|
1563
|
+
// 获取默认账户 ID
|
|
1564
|
+
defaultAccountId: () => pluginSdk.DEFAULT_ACCOUNT_ID,
|
|
1565
|
+
// 设置账户启用状态
|
|
1566
|
+
setAccountEnabled: ({ cfg, enabled }) => {
|
|
1567
|
+
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1568
|
+
return {
|
|
1569
|
+
...cfg,
|
|
1570
|
+
channels: {
|
|
1571
|
+
...cfg.channels,
|
|
1572
|
+
[CHANNEL_ID]: {
|
|
1573
|
+
...wecomConfig,
|
|
1574
|
+
enabled,
|
|
1575
|
+
},
|
|
1576
|
+
},
|
|
1577
|
+
};
|
|
1578
|
+
},
|
|
1579
|
+
// 删除账户
|
|
1580
|
+
deleteAccount: ({ cfg }) => {
|
|
1581
|
+
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1582
|
+
const { botId, secret, ...rest } = wecomConfig;
|
|
1583
|
+
return {
|
|
1584
|
+
...cfg,
|
|
1585
|
+
channels: {
|
|
1586
|
+
...cfg.channels,
|
|
1587
|
+
[CHANNEL_ID]: rest,
|
|
1588
|
+
},
|
|
1589
|
+
};
|
|
1590
|
+
},
|
|
1591
|
+
// 检查是否已配置
|
|
1592
|
+
isConfigured: (account) => Boolean(account.botId?.trim() && account.secret?.trim()),
|
|
1593
|
+
// 描述账户信息
|
|
1594
|
+
describeAccount: (account) => ({
|
|
1595
|
+
accountId: account.accountId,
|
|
1596
|
+
name: account.name,
|
|
1597
|
+
enabled: account.enabled,
|
|
1598
|
+
configured: Boolean(account.botId?.trim() && account.secret?.trim()),
|
|
1599
|
+
botId: account.botId,
|
|
1600
|
+
websocketUrl: account.websocketUrl,
|
|
1601
|
+
}),
|
|
1602
|
+
// 解析允许来源列表
|
|
1603
|
+
resolveAllowFrom: ({ cfg }) => {
|
|
1604
|
+
const account = resolveWeComAccount(cfg);
|
|
1605
|
+
return (account.config.allowFrom ?? []).map((entry) => String(entry));
|
|
1606
|
+
},
|
|
1607
|
+
// 格式化允许来源列表
|
|
1608
|
+
formatAllowFrom: ({ allowFrom }) => allowFrom
|
|
1609
|
+
.map((entry) => String(entry).trim())
|
|
1610
|
+
.filter(Boolean),
|
|
1611
|
+
},
|
|
1612
|
+
security: {
|
|
1613
|
+
resolveDmPolicy: ({ account }) => {
|
|
1614
|
+
const basePath = `channels.${CHANNEL_ID}.`;
|
|
1615
|
+
return {
|
|
1616
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
1617
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
1618
|
+
policyPath: `${basePath}dmPolicy`,
|
|
1619
|
+
allowFromPath: basePath,
|
|
1620
|
+
approveHint: pluginSdk.formatPairingApproveHint(CHANNEL_ID),
|
|
1621
|
+
normalizeEntry: (raw) => raw.replace(new RegExp(`^${CHANNEL_ID}:`, "i"), "").trim(),
|
|
1622
|
+
};
|
|
1623
|
+
},
|
|
1624
|
+
collectWarnings: ({ account }) => {
|
|
1625
|
+
const warnings = [];
|
|
1626
|
+
// DM 策略警告
|
|
1627
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
1628
|
+
if (dmPolicy === "open") {
|
|
1629
|
+
const hasWildcard = (account.config.allowFrom ?? []).some((entry) => String(entry).trim() === "*");
|
|
1630
|
+
if (!hasWildcard) {
|
|
1631
|
+
warnings.push(`- 企业微信私信:dmPolicy="open" 但 allowFrom 未包含 "*"。任何人都可以发消息,但允许列表为空可能导致意外行为。建议设置 channels.${CHANNEL_ID}.allowFrom=["*"] 或使用 dmPolicy="pairing"。`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
// 群组策略警告
|
|
1635
|
+
const defaultGroupPolicy = pluginSdk.resolveDefaultGroupPolicy({});
|
|
1636
|
+
const { groupPolicy } = pluginSdk.resolveOpenProviderRuntimeGroupPolicy({
|
|
1637
|
+
providerConfigPresent: true,
|
|
1638
|
+
groupPolicy: account.config.groupPolicy,
|
|
1639
|
+
defaultGroupPolicy,
|
|
1640
|
+
});
|
|
1641
|
+
if (groupPolicy === "open") {
|
|
1642
|
+
warnings.push(`- 企业微信群组:groupPolicy="open" 允许所有群组中的成员触发。设置 channels.${CHANNEL_ID}.groupPolicy="allowlist" + channels.${CHANNEL_ID}.groupAllowFrom 来限制群组。`);
|
|
1643
|
+
}
|
|
1644
|
+
return warnings;
|
|
1645
|
+
},
|
|
1646
|
+
},
|
|
1647
|
+
messaging: {
|
|
1648
|
+
normalizeTarget: (target) => {
|
|
1649
|
+
const trimmed = target.trim();
|
|
1650
|
+
if (!trimmed)
|
|
1651
|
+
return undefined;
|
|
1652
|
+
return trimmed;
|
|
1653
|
+
},
|
|
1654
|
+
targetResolver: {
|
|
1655
|
+
looksLikeId: (id) => {
|
|
1656
|
+
const trimmed = id?.trim();
|
|
1657
|
+
return Boolean(trimmed);
|
|
1658
|
+
},
|
|
1659
|
+
hint: "<userId|groupId>",
|
|
1660
|
+
},
|
|
1661
|
+
},
|
|
1662
|
+
directory: {
|
|
1663
|
+
self: async () => null,
|
|
1664
|
+
listPeers: async () => [],
|
|
1665
|
+
listGroups: async () => [],
|
|
1666
|
+
},
|
|
1667
|
+
outbound: {
|
|
1668
|
+
deliveryMode: "direct",
|
|
1669
|
+
chunker: (text, limit) => getWeComRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
1670
|
+
textChunkLimit: TEXT_CHUNK_LIMIT,
|
|
1671
|
+
sendText: async ({ to, text, accountId, ...rest }) => {
|
|
1672
|
+
console.log(`[WeCom] sendText: ${JSON.stringify({ to, text, accountId, ...rest })}`);
|
|
1673
|
+
return sendWeComMessage({ to, content: text, accountId: accountId ?? undefined });
|
|
1674
|
+
},
|
|
1675
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, ...rest }) => {
|
|
1676
|
+
console.log(`[WeCom] sendMedia: ${JSON.stringify({ to, text, mediaUrl, accountId, ...rest })}`);
|
|
1677
|
+
const content = `Sending attachments is not supported yet\n${text ? `${text}\n${mediaUrl}` : (mediaUrl ?? "")}`;
|
|
1678
|
+
return sendWeComMessage({ to, content, accountId: accountId ?? undefined });
|
|
1679
|
+
},
|
|
1680
|
+
},
|
|
1681
|
+
status: {
|
|
1682
|
+
defaultRuntime: {
|
|
1683
|
+
accountId: pluginSdk.DEFAULT_ACCOUNT_ID,
|
|
1684
|
+
running: false,
|
|
1685
|
+
lastStartAt: null,
|
|
1686
|
+
lastStopAt: null,
|
|
1687
|
+
lastError: null,
|
|
1688
|
+
},
|
|
1689
|
+
collectStatusIssues: (accounts) => accounts.flatMap((entry) => {
|
|
1690
|
+
const accountId = String(entry.accountId ?? pluginSdk.DEFAULT_ACCOUNT_ID);
|
|
1691
|
+
const enabled = entry.enabled !== false;
|
|
1692
|
+
const configured = entry.configured === true;
|
|
1693
|
+
if (!enabled) {
|
|
1694
|
+
return [];
|
|
1695
|
+
}
|
|
1696
|
+
const issues = [];
|
|
1697
|
+
if (!configured) {
|
|
1698
|
+
issues.push({
|
|
1699
|
+
channel: CHANNEL_ID,
|
|
1700
|
+
accountId,
|
|
1701
|
+
kind: "config",
|
|
1702
|
+
message: "企业微信机器人 ID 或 Secret 未配置",
|
|
1703
|
+
fix: "Run: openclaw channels add wecom --bot-id <id> --secret <secret>",
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
return issues;
|
|
1707
|
+
}),
|
|
1708
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
1709
|
+
configured: snapshot.configured ?? false,
|
|
1710
|
+
running: snapshot.running ?? false,
|
|
1711
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
1712
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
1713
|
+
lastError: snapshot.lastError ?? null,
|
|
1714
|
+
}),
|
|
1715
|
+
probeAccount: async () => {
|
|
1716
|
+
return { ok: true, status: 200 };
|
|
1717
|
+
},
|
|
1718
|
+
buildAccountSnapshot: ({ account, runtime }) => {
|
|
1719
|
+
const configured = Boolean(account.botId?.trim() &&
|
|
1720
|
+
account.secret?.trim());
|
|
1721
|
+
return {
|
|
1722
|
+
accountId: account.accountId,
|
|
1723
|
+
name: account.name,
|
|
1724
|
+
enabled: account.enabled,
|
|
1725
|
+
configured,
|
|
1726
|
+
running: runtime?.running ?? false,
|
|
1727
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
1728
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
1729
|
+
lastError: runtime?.lastError ?? null,
|
|
1730
|
+
};
|
|
1731
|
+
},
|
|
1732
|
+
},
|
|
1733
|
+
gateway: {
|
|
1734
|
+
startAccount: async (ctx) => {
|
|
1735
|
+
const account = ctx.account;
|
|
1736
|
+
// 启动 WebSocket 监听
|
|
1737
|
+
return monitorWeComProvider({
|
|
1738
|
+
account,
|
|
1739
|
+
config: ctx.cfg,
|
|
1740
|
+
runtime: ctx.runtime,
|
|
1741
|
+
abortSignal: ctx.abortSignal,
|
|
1742
|
+
});
|
|
1743
|
+
},
|
|
1744
|
+
logoutAccount: async ({ cfg }) => {
|
|
1745
|
+
const nextCfg = { ...cfg };
|
|
1746
|
+
const wecomConfig = (cfg.channels?.[CHANNEL_ID] ?? {});
|
|
1747
|
+
const nextWecom = { ...wecomConfig };
|
|
1748
|
+
let cleared = false;
|
|
1749
|
+
let changed = false;
|
|
1750
|
+
if (nextWecom.botId || nextWecom.secret) {
|
|
1751
|
+
delete nextWecom.botId;
|
|
1752
|
+
delete nextWecom.secret;
|
|
1753
|
+
cleared = true;
|
|
1754
|
+
changed = true;
|
|
1755
|
+
}
|
|
1756
|
+
if (changed) {
|
|
1757
|
+
if (Object.keys(nextWecom).length > 0) {
|
|
1758
|
+
nextCfg.channels = { ...nextCfg.channels, [CHANNEL_ID]: nextWecom };
|
|
1759
|
+
}
|
|
1760
|
+
else {
|
|
1761
|
+
const nextChannels = { ...nextCfg.channels };
|
|
1762
|
+
delete nextChannels[CHANNEL_ID];
|
|
1763
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
1764
|
+
nextCfg.channels = nextChannels;
|
|
1765
|
+
}
|
|
1766
|
+
else {
|
|
1767
|
+
delete nextCfg.channels;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
await getWeComRuntime().config.writeConfigFile(nextCfg);
|
|
1771
|
+
}
|
|
1772
|
+
const resolved = resolveWeComAccount(changed ? nextCfg : cfg);
|
|
1773
|
+
const loggedOut = !resolved.botId && !resolved.secret;
|
|
1774
|
+
return { cleared, envToken: false, loggedOut };
|
|
1775
|
+
},
|
|
1776
|
+
},
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
const plugin = {
|
|
1780
|
+
id: "wecom",
|
|
1781
|
+
name: "企业微信",
|
|
1782
|
+
description: "企业微信 OpenClaw 插件",
|
|
1783
|
+
configSchema: pluginSdk.emptyPluginConfigSchema(),
|
|
1784
|
+
register(api) {
|
|
1785
|
+
setWeComRuntime(api.runtime);
|
|
1786
|
+
api.registerChannel({ plugin: wecomPlugin });
|
|
1787
|
+
},
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
exports.default = plugin;
|
|
1791
|
+
//# sourceMappingURL=index.cjs.js.map
|