@wecom/wecom-openclaw-plugin 2026.4.10-beta.1 → 2026.4.21-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -8
- package/dist/index.js +18 -1
- package/dist/src/accounts.d.ts +1 -1
- package/dist/src/agent/handler.d.ts +1 -1
- package/dist/src/agent/webhook.d.ts +1 -1
- package/dist/src/channel.js +3 -3
- package/dist/src/const.d.ts +4 -0
- package/dist/src/const.js +4 -0
- package/dist/src/dynamic-agent.d.ts +1 -1
- package/dist/src/dynamic-routing.d.ts +1 -1
- package/dist/src/mcp/index.d.ts +1 -1
- package/dist/src/mcp/index.js +5 -1
- package/dist/src/mcp/interceptors/biz-error.js +11 -6
- package/dist/src/mcp/interceptors/doc-auth-error.d.ts +10 -0
- package/dist/src/mcp/interceptors/doc-auth-error.js +171 -0
- package/dist/src/mcp/interceptors/index.js +2 -0
- package/dist/src/mcp/interceptors/types.d.ts +8 -0
- package/dist/src/mcp/tool.d.ts +13 -1
- package/dist/src/mcp/tool.js +34 -12
- package/dist/src/mcp/transport.d.ts +30 -3
- package/dist/src/mcp/transport.js +108 -54
- package/dist/src/message-parser.d.ts +7 -0
- package/dist/src/message-parser.js +75 -1
- package/dist/src/monitor.js +42 -17
- package/dist/src/openclaw-compat.js +1 -1
- package/dist/src/shared/command-auth.d.ts +1 -1
- package/dist/src/state-manager.d.ts +19 -0
- package/dist/src/state-manager.js +34 -1
- package/dist/src/webhook/command-auth.d.ts +1 -1
- package/dist/src/webhook/helpers.d.ts +1 -1
- package/dist/src/webhook/types.d.ts +1 -1
- package/package.json +3 -2
|
@@ -26,6 +26,8 @@ const HTTP_REQUEST_TIMEOUT_MS = 30_000;
|
|
|
26
26
|
export const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
27
27
|
/** 日志前缀 */
|
|
28
28
|
const LOG_TAG = "[mcp]";
|
|
29
|
+
/** 请求 MCP Server 时透传可信企业微信 userid 的 header 名 */
|
|
30
|
+
export const WECOM_USERID_HEADER = "x-openclaw-wecom-userid";
|
|
29
31
|
/**
|
|
30
32
|
* MCP JSON-RPC 错误
|
|
31
33
|
*
|
|
@@ -68,13 +70,22 @@ export class McpHttpError extends Error {
|
|
|
68
70
|
* - -32003: 认证失败(Auth Failed)
|
|
69
71
|
*/
|
|
70
72
|
const CACHE_CLEAR_ERROR_CODES = new Set([-32001, -32002, -32003]);
|
|
71
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* 生成多账号感知的缓存 key
|
|
75
|
+
*
|
|
76
|
+
* 不同账号的同一 category 对应不同的 MCP Server(URL、凭据均不同),
|
|
77
|
+
* 必须用 accountId:category 的复合键隔离,避免跨账号缓存污染。
|
|
78
|
+
*/
|
|
79
|
+
function cacheKey(accountId, category) {
|
|
80
|
+
return `${accountId}:${category}`;
|
|
81
|
+
}
|
|
82
|
+
/** MCP 配置缓存:accountId:category → response.body(完整配置) */
|
|
72
83
|
const mcpConfigCache = new Map();
|
|
73
|
-
/** Streamable HTTP 会话缓存:category → session */
|
|
84
|
+
/** Streamable HTTP 会话缓存:accountId:category → session */
|
|
74
85
|
const mcpSessionCache = new Map();
|
|
75
|
-
/** 已确认为无状态的 MCP Server
|
|
86
|
+
/** 已确认为无状态的 MCP Server 集合(跳过后续握手),key 为 accountId:category */
|
|
76
87
|
const statelessCategories = new Set();
|
|
77
|
-
/** 正在进行中的 initialize 请求(防止并发重复初始化),key 为 category */
|
|
88
|
+
/** 正在进行中的 initialize 请求(防止并发重复初始化),key 为 accountId:category */
|
|
78
89
|
const inflightInitRequests = new Map();
|
|
79
90
|
// ============================================================================
|
|
80
91
|
// MCP 配置拉取与缓存
|
|
@@ -118,7 +129,7 @@ function resolveMcpAccountId(cfg) {
|
|
|
118
129
|
* 2. 第一个配置了 botId + secret 的长连接账户
|
|
119
130
|
* 3. 无可用长连接账户时回退到默认账户(会导致后续 WSClient 获取失败)
|
|
120
131
|
*/
|
|
121
|
-
function resolveCurrentAccountId() {
|
|
132
|
+
export function resolveCurrentAccountId() {
|
|
122
133
|
try {
|
|
123
134
|
const core = getWeComRuntime();
|
|
124
135
|
const cfg = core.config.loadConfig();
|
|
@@ -138,13 +149,11 @@ function resolveCurrentAccountId() {
|
|
|
138
149
|
/**
|
|
139
150
|
* 通过 WSClient 拉取指定 category 的 MCP 完整配置
|
|
140
151
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
152
|
+
* @param accountId - 账户 ID,决定使用哪个 WSClient
|
|
143
153
|
* @param category - MCP 品类名称,如 doc、contact
|
|
144
154
|
* @returns 完整的 response.body 配置对象(至少包含 url 字段)
|
|
145
155
|
*/
|
|
146
|
-
async function fetchMcpConfig(category) {
|
|
147
|
-
const accountId = resolveCurrentAccountId();
|
|
156
|
+
async function fetchMcpConfig(accountId, category) {
|
|
148
157
|
const wsClient = getWeComWebSocket(accountId);
|
|
149
158
|
if (!wsClient) {
|
|
150
159
|
throw new Error(`WSClient 未连接 (accountId="${accountId}"),无法拉取 MCP 配置`);
|
|
@@ -168,19 +177,21 @@ async function fetchMcpConfig(category) {
|
|
|
168
177
|
*
|
|
169
178
|
* 优先从内存缓存中读取,未命中时通过 WSClient 拉取并缓存。
|
|
170
179
|
*
|
|
180
|
+
* @param accountId - 账户 ID
|
|
171
181
|
* @param category - MCP 品类名称
|
|
172
182
|
* @returns MCP Server URL
|
|
173
183
|
*/
|
|
174
|
-
async function getMcpUrl(category) {
|
|
184
|
+
async function getMcpUrl(accountId, category) {
|
|
185
|
+
const key = cacheKey(accountId, category);
|
|
175
186
|
// 查内存缓存
|
|
176
|
-
const cached = mcpConfigCache.get(
|
|
187
|
+
const cached = mcpConfigCache.get(key);
|
|
177
188
|
if (cached)
|
|
178
189
|
return cached.url;
|
|
179
190
|
// 缓存未命中,通过 WSClient 拉取
|
|
180
|
-
const body = await fetchMcpConfig(category);
|
|
191
|
+
const body = await fetchMcpConfig(accountId, category);
|
|
181
192
|
// 写入缓存
|
|
182
|
-
mcpConfigCache.set(
|
|
183
|
-
console.log(`${LOG_TAG} getMcpUrl ${category}: ${body.url}`);
|
|
193
|
+
mcpConfigCache.set(key, body);
|
|
194
|
+
console.log(`${LOG_TAG} getMcpUrl ${accountId}/${category}: ${body.url}`);
|
|
184
195
|
return body.url;
|
|
185
196
|
}
|
|
186
197
|
// ============================================================================
|
|
@@ -192,7 +203,7 @@ async function getMcpUrl(category) {
|
|
|
192
203
|
* 自动携带 Mcp-Session-Id 请求头(如果有),
|
|
193
204
|
* 并从响应头中更新 sessionId。
|
|
194
205
|
*/
|
|
195
|
-
async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEOUT_MS) {
|
|
206
|
+
async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEOUT_MS, requesterUserId) {
|
|
196
207
|
const controller = new AbortController();
|
|
197
208
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
198
209
|
const headers = {
|
|
@@ -203,6 +214,11 @@ async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEO
|
|
|
203
214
|
if (session.sessionId) {
|
|
204
215
|
headers["Mcp-Session-Id"] = session.sessionId;
|
|
205
216
|
}
|
|
217
|
+
const normalizedRequesterUserId = requesterUserId?.trim();
|
|
218
|
+
if (normalizedRequesterUserId) {
|
|
219
|
+
headers[WECOM_USERID_HEADER] = normalizedRequesterUserId;
|
|
220
|
+
}
|
|
221
|
+
console.log(`${LOG_TAG} sendRawJsonRpc → ${body.method} | ${WECOM_USERID_HEADER}: ${headers[WECOM_USERID_HEADER] ?? "(not set)"}`);
|
|
206
222
|
let response;
|
|
207
223
|
try {
|
|
208
224
|
response = await fetch(url, {
|
|
@@ -256,9 +272,10 @@ async function sendRawJsonRpc(url, session, body, timeoutMs = HTTP_REQUEST_TIMEO
|
|
|
256
272
|
* 发送 initialize → 接收 serverInfo → 发送 initialized 通知。
|
|
257
273
|
* 如果服务端未返回 Mcp-Session-Id,则标记为无状态模式,后续请求跳过 session 管理。
|
|
258
274
|
*/
|
|
259
|
-
async function initializeSession(url, category) {
|
|
275
|
+
async function initializeSession(url, accountId, category, requesterUserId) {
|
|
276
|
+
const key = cacheKey(accountId, category);
|
|
260
277
|
const session = { sessionId: null, initialized: false, stateless: false };
|
|
261
|
-
console.log(`${LOG_TAG} 开始 initialize 握手 (category="${category}")`);
|
|
278
|
+
console.log(`${LOG_TAG} 开始 initialize 握手 (accountId="${accountId}", category="${category}")`);
|
|
262
279
|
// 1. 发送 initialize 请求
|
|
263
280
|
const initBody = {
|
|
264
281
|
jsonrpc: "2.0",
|
|
@@ -270,7 +287,7 @@ async function initializeSession(url, category) {
|
|
|
270
287
|
clientInfo: { name: "wecom_mcp", version: "1.0.0" },
|
|
271
288
|
},
|
|
272
289
|
};
|
|
273
|
-
const { newSessionId: initSessionId } = await sendRawJsonRpc(url, session, initBody);
|
|
290
|
+
const { newSessionId: initSessionId } = await sendRawJsonRpc(url, session, initBody, HTTP_REQUEST_TIMEOUT_MS, requesterUserId);
|
|
274
291
|
// 用返回的 newSessionId 更新 session(不再依赖副作用修改)
|
|
275
292
|
if (initSessionId) {
|
|
276
293
|
session.sessionId = initSessionId;
|
|
@@ -280,9 +297,9 @@ async function initializeSession(url, category) {
|
|
|
280
297
|
if (!session.sessionId) {
|
|
281
298
|
session.stateless = true;
|
|
282
299
|
session.initialized = true;
|
|
283
|
-
statelessCategories.add(
|
|
284
|
-
mcpSessionCache.set(
|
|
285
|
-
console.log(`${LOG_TAG} 无状态 Server 确认 (category="${category}")`);
|
|
300
|
+
statelessCategories.add(key);
|
|
301
|
+
mcpSessionCache.set(key, session);
|
|
302
|
+
console.log(`${LOG_TAG} 无状态 Server 确认 (accountId="${accountId}", category="${category}")`);
|
|
286
303
|
return session;
|
|
287
304
|
}
|
|
288
305
|
// 2. 发送 initialized 通知(JSON-RPC notification 不带 id 字段)
|
|
@@ -291,14 +308,14 @@ async function initializeSession(url, category) {
|
|
|
291
308
|
method: "notifications/initialized",
|
|
292
309
|
};
|
|
293
310
|
// initialized 通知不需要等待响应,但 Streamable HTTP 要求通过 POST 发送
|
|
294
|
-
const { newSessionId: notifySessionId } = await sendRawJsonRpc(url, session, notifyBody);
|
|
311
|
+
const { newSessionId: notifySessionId } = await sendRawJsonRpc(url, session, notifyBody, HTTP_REQUEST_TIMEOUT_MS, requesterUserId);
|
|
295
312
|
// 如果 initialized 通知的响应也携带了 sessionId,以最新的为准
|
|
296
313
|
if (notifySessionId) {
|
|
297
314
|
session.sessionId = notifySessionId;
|
|
298
315
|
}
|
|
299
316
|
session.initialized = true;
|
|
300
|
-
mcpSessionCache.set(
|
|
301
|
-
console.log(`${LOG_TAG} 有状态 Session 建立成功 (category="${category}", sessionId="${session.sessionId}")`);
|
|
317
|
+
mcpSessionCache.set(key, session);
|
|
318
|
+
console.log(`${LOG_TAG} 有状态 Session 建立成功 (accountId="${accountId}", category="${category}", sessionId="${session.sessionId}")`);
|
|
302
319
|
return session;
|
|
303
320
|
}
|
|
304
321
|
/**
|
|
@@ -308,25 +325,26 @@ async function initializeSession(url, category) {
|
|
|
308
325
|
* - 已有可用有状态会话:直接返回缓存
|
|
309
326
|
* - 其他情况:执行 initialize 握手,并发请求会被合并
|
|
310
327
|
*/
|
|
311
|
-
async function getOrCreateSession(url, category) {
|
|
328
|
+
async function getOrCreateSession(url, accountId, category, requesterUserId) {
|
|
329
|
+
const key = cacheKey(accountId, category);
|
|
312
330
|
// 已确认为无状态的 Server,直接返回空 session 跳过握手
|
|
313
|
-
if (statelessCategories.has(
|
|
314
|
-
const cached = mcpSessionCache.get(
|
|
331
|
+
if (statelessCategories.has(key)) {
|
|
332
|
+
const cached = mcpSessionCache.get(key);
|
|
315
333
|
if (cached)
|
|
316
334
|
return cached;
|
|
317
335
|
// 首次发现被清除(理论上不会走到这里),重新走握手探测
|
|
318
336
|
}
|
|
319
|
-
const cached = mcpSessionCache.get(
|
|
337
|
+
const cached = mcpSessionCache.get(key);
|
|
320
338
|
if (cached?.initialized)
|
|
321
339
|
return cached;
|
|
322
340
|
// 防止并发重复初始化
|
|
323
|
-
const inflight = inflightInitRequests.get(
|
|
341
|
+
const inflight = inflightInitRequests.get(key);
|
|
324
342
|
if (inflight)
|
|
325
343
|
return inflight;
|
|
326
|
-
const promise = initializeSession(url, category).finally(() => {
|
|
327
|
-
inflightInitRequests.delete(
|
|
344
|
+
const promise = initializeSession(url, accountId, category, requesterUserId).finally(() => {
|
|
345
|
+
inflightInitRequests.delete(key);
|
|
328
346
|
});
|
|
329
|
-
inflightInitRequests.set(
|
|
347
|
+
inflightInitRequests.set(key, promise);
|
|
330
348
|
return promise;
|
|
331
349
|
}
|
|
332
350
|
// ============================================================================
|
|
@@ -383,18 +401,47 @@ async function parseSseResponse(response) {
|
|
|
383
401
|
// 公共 API
|
|
384
402
|
// ============================================================================
|
|
385
403
|
/**
|
|
386
|
-
*
|
|
404
|
+
* 清理指定账户+品类的所有 MCP 缓存(配置、会话、无状态标记)
|
|
387
405
|
*
|
|
388
406
|
* 当 MCP Server 返回特定错误码时调用,确保下次请求重新拉取配置并重建会话。
|
|
389
407
|
*
|
|
408
|
+
* @param accountId - 账户 ID
|
|
390
409
|
* @param category - MCP 品类名称
|
|
391
410
|
*/
|
|
392
|
-
export function clearCategoryCache(category) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
411
|
+
export function clearCategoryCache(accountId, category) {
|
|
412
|
+
const key = cacheKey(accountId, category);
|
|
413
|
+
console.log(`${LOG_TAG} 清理缓存 (accountId="${accountId}", category="${category}")`);
|
|
414
|
+
mcpConfigCache.delete(key);
|
|
415
|
+
mcpSessionCache.delete(key);
|
|
416
|
+
statelessCategories.delete(key);
|
|
417
|
+
inflightInitRequests.delete(key);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 清理指定账户下所有品类的 MCP 缓存
|
|
421
|
+
*
|
|
422
|
+
* 适用于账户掉线、凭据变更等需要整体清理的场景。
|
|
423
|
+
*
|
|
424
|
+
* @param accountId - 账户 ID
|
|
425
|
+
*/
|
|
426
|
+
export function clearAccountCache(accountId) {
|
|
427
|
+
const prefix = `${accountId}:`;
|
|
428
|
+
console.log(`${LOG_TAG} 清理账户所有缓存 (accountId="${accountId}")`);
|
|
429
|
+
for (const key of mcpConfigCache.keys()) {
|
|
430
|
+
if (key.startsWith(prefix))
|
|
431
|
+
mcpConfigCache.delete(key);
|
|
432
|
+
}
|
|
433
|
+
for (const key of mcpSessionCache.keys()) {
|
|
434
|
+
if (key.startsWith(prefix))
|
|
435
|
+
mcpSessionCache.delete(key);
|
|
436
|
+
}
|
|
437
|
+
for (const key of statelessCategories) {
|
|
438
|
+
if (key.startsWith(prefix))
|
|
439
|
+
statelessCategories.delete(key);
|
|
440
|
+
}
|
|
441
|
+
for (const key of inflightInitRequests.keys()) {
|
|
442
|
+
if (key.startsWith(prefix))
|
|
443
|
+
inflightInitRequests.delete(key);
|
|
444
|
+
}
|
|
398
445
|
}
|
|
399
446
|
/**
|
|
400
447
|
* 发送 JSON-RPC 请求到 MCP Server(Streamable HTTP 协议)
|
|
@@ -403,24 +450,30 @@ export function clearCategoryCache(category) {
|
|
|
403
450
|
* - 无状态 Server:跳过 session 管理,直接发送请求
|
|
404
451
|
* - 有状态 Server:首次调用先执行 initialize 握手,session 失效(404)时自动重建并重试
|
|
405
452
|
*
|
|
453
|
+
* accountId 优先使用 options.accountId(由工具工厂从 ctx.agentAccountId 传入),
|
|
454
|
+
* 未提供时 fallback 到 resolveCurrentAccountId()。解析结果贯穿整条调用链,避免跨账号污染。
|
|
455
|
+
*
|
|
406
456
|
* @param category - MCP 品类名称
|
|
407
457
|
* @param method - JSON-RPC 方法名
|
|
408
458
|
* @param params - JSON-RPC 参数
|
|
409
|
-
* @param options -
|
|
459
|
+
* @param options - 可选配置(如自定义超时、指定账户)
|
|
410
460
|
* @returns JSON-RPC result
|
|
411
461
|
*/
|
|
412
462
|
export async function sendJsonRpc(category, method, params, options) {
|
|
413
|
-
const
|
|
463
|
+
const accountId = options?.accountId?.trim() || resolveCurrentAccountId();
|
|
464
|
+
const key = cacheKey(accountId, category);
|
|
465
|
+
const url = await getMcpUrl(accountId, category);
|
|
414
466
|
const timeoutMs = options?.timeoutMs;
|
|
467
|
+
const requesterUserId = options?.requesterUserId;
|
|
415
468
|
const body = {
|
|
416
469
|
jsonrpc: "2.0",
|
|
417
470
|
id: generateReqId("mcp_rpc"),
|
|
418
471
|
method,
|
|
419
472
|
...(params !== undefined ? { params } : {}),
|
|
420
473
|
};
|
|
421
|
-
let session = await getOrCreateSession(url, category);
|
|
474
|
+
let session = await getOrCreateSession(url, accountId, category, requesterUserId);
|
|
422
475
|
try {
|
|
423
|
-
const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs);
|
|
476
|
+
const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs, requesterUserId);
|
|
424
477
|
// 用最新的 sessionId 更新 session
|
|
425
478
|
if (newSessionId) {
|
|
426
479
|
session.sessionId = newSessionId;
|
|
@@ -430,7 +483,7 @@ export async function sendJsonRpc(category, method, params, options) {
|
|
|
430
483
|
catch (err) {
|
|
431
484
|
// 特定 JSON-RPC 错误码触发缓存清理(统一在传输层处理,上层无需关心)
|
|
432
485
|
if (err instanceof McpRpcError && CACHE_CLEAR_ERROR_CODES.has(err.code)) {
|
|
433
|
-
clearCategoryCache(category);
|
|
486
|
+
clearCategoryCache(accountId, category);
|
|
434
487
|
}
|
|
435
488
|
// 无状态 Server 不存在 session 失效问题,直接抛出错误
|
|
436
489
|
if (session.stateless)
|
|
@@ -438,18 +491,18 @@ export async function sendJsonRpc(category, method, params, options) {
|
|
|
438
491
|
// 有状态 Server:session 失效时服务端返回 404,需要重新初始化并重试一次
|
|
439
492
|
// 使用 McpHttpError.statusCode 精确匹配,避免字符串匹配 "404" 导致误判
|
|
440
493
|
if (err instanceof McpHttpError && err.statusCode === 404) {
|
|
441
|
-
console.log(`${LOG_TAG} Session 失效 (category="${category}"),开始重建...`);
|
|
442
|
-
mcpSessionCache.delete(
|
|
494
|
+
console.log(`${LOG_TAG} Session 失效 (accountId="${accountId}", category="${category}"),开始重建...`);
|
|
495
|
+
mcpSessionCache.delete(key);
|
|
443
496
|
// 使用 rebuildSession 合并并发的 session 重建请求,避免竞态条件
|
|
444
|
-
session = await rebuildSession(url, category);
|
|
445
|
-
const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs);
|
|
497
|
+
session = await rebuildSession(url, accountId, category, requesterUserId);
|
|
498
|
+
const { rpcResult, newSessionId } = await sendRawJsonRpc(url, session, body, timeoutMs, requesterUserId);
|
|
446
499
|
if (newSessionId) {
|
|
447
500
|
session.sessionId = newSessionId;
|
|
448
501
|
}
|
|
449
502
|
return rpcResult;
|
|
450
503
|
}
|
|
451
504
|
// 其他错误记录日志后抛出
|
|
452
|
-
console.error(`${LOG_TAG} RPC 请求失败 (category="${category}", method="${method}"): ${err instanceof Error ? err.message : String(err)}`);
|
|
505
|
+
console.error(`${LOG_TAG} RPC 请求失败 (accountId="${accountId}", category="${category}", method="${method}"): ${err instanceof Error ? err.message : String(err)}`);
|
|
453
506
|
throw err;
|
|
454
507
|
}
|
|
455
508
|
}
|
|
@@ -459,13 +512,14 @@ export async function sendJsonRpc(category, method, params, options) {
|
|
|
459
512
|
* 与 getOrCreateSession 类似,使用 inflightInitRequests 防止
|
|
460
513
|
* 多个并发请求同时遇到 404 时重复执行 initialize 握手。
|
|
461
514
|
*/
|
|
462
|
-
async function rebuildSession(url, category) {
|
|
463
|
-
const
|
|
515
|
+
async function rebuildSession(url, accountId, category, requesterUserId) {
|
|
516
|
+
const key = cacheKey(accountId, category);
|
|
517
|
+
const inflight = inflightInitRequests.get(key);
|
|
464
518
|
if (inflight)
|
|
465
519
|
return inflight;
|
|
466
|
-
const promise = initializeSession(url, category).finally(() => {
|
|
467
|
-
inflightInitRequests.delete(
|
|
520
|
+
const promise = initializeSession(url, accountId, category, requesterUserId).finally(() => {
|
|
521
|
+
inflightInitRequests.delete(key);
|
|
468
522
|
});
|
|
469
|
-
inflightInitRequests.set(
|
|
523
|
+
inflightInitRequests.set(key, promise);
|
|
470
524
|
return promise;
|
|
471
525
|
}
|
|
@@ -11,6 +11,8 @@ export interface MessageBody {
|
|
|
11
11
|
from: {
|
|
12
12
|
corpid?: string;
|
|
13
13
|
userid: string;
|
|
14
|
+
/** 会话 ID(权限变更事件回调中携带) */
|
|
15
|
+
chat_id?: string;
|
|
14
16
|
};
|
|
15
17
|
response_url?: string;
|
|
16
18
|
msgtype: string;
|
|
@@ -72,6 +74,11 @@ export interface MessageBody {
|
|
|
72
74
|
}>;
|
|
73
75
|
};
|
|
74
76
|
};
|
|
77
|
+
/** 权限变更事件回调(如文档授权) */
|
|
78
|
+
auth_change_event?: {
|
|
79
|
+
/** 当前权限列表:1-新建和编辑文档;2-获取成员文档内容 */
|
|
80
|
+
auth_list?: number[];
|
|
81
|
+
};
|
|
75
82
|
};
|
|
76
83
|
}
|
|
77
84
|
export interface ParsedMessageContent {
|
|
@@ -46,6 +46,74 @@ function buildTemplateCardEventText(body) {
|
|
|
46
46
|
.filter((line) => Boolean(line))
|
|
47
47
|
.join("\n");
|
|
48
48
|
}
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// 权限类型映射
|
|
51
|
+
// ============================================================================
|
|
52
|
+
/** 权限类型枚举 */
|
|
53
|
+
var AuthType;
|
|
54
|
+
(function (AuthType) {
|
|
55
|
+
/** 新建和编辑文档 */
|
|
56
|
+
AuthType[AuthType["CREATE_AND_EDIT_DOC"] = 1] = "CREATE_AND_EDIT_DOC";
|
|
57
|
+
/** 获取成员文档内容 */
|
|
58
|
+
AuthType[AuthType["GET_DOC_CONTENT"] = 2] = "GET_DOC_CONTENT";
|
|
59
|
+
})(AuthType || (AuthType = {}));
|
|
60
|
+
/** 权限类型值 → 中文描述 */
|
|
61
|
+
const AUTH_TYPE_MAP = {
|
|
62
|
+
[AuthType.CREATE_AND_EDIT_DOC]: "新建和编辑文档",
|
|
63
|
+
[AuthType.GET_DOC_CONTENT]: "获取成员文档内容",
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* 将权限变更事件回调格式化为可继续路由给大模型的文本。
|
|
67
|
+
*
|
|
68
|
+
* 当管理员在授权页面变更权限后,系统收到 auth_change_event 回调,
|
|
69
|
+
* 根据 auth_list 生成对应的提示文本,引导 Agent 继续操作。
|
|
70
|
+
*/
|
|
71
|
+
function buildAuthChangeEventText(body) {
|
|
72
|
+
console.log("authChangeEventCheck", body.event);
|
|
73
|
+
const authChangeEvent = body.event?.auth_change_event;
|
|
74
|
+
if (body.msgtype !== "event" ||
|
|
75
|
+
body.event?.eventtype !== "auth_change_event" ||
|
|
76
|
+
!authChangeEvent) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const authList = authChangeEvent.auth_list ?? [];
|
|
80
|
+
const authDescriptions = authList
|
|
81
|
+
.map((code) => AUTH_TYPE_MAP[code] || `未知权限(${code})`)
|
|
82
|
+
.join("、");
|
|
83
|
+
// 根据权限列表内容生成不同的操作指引
|
|
84
|
+
const hasDocContentAuth = authList.includes(AuthType.GET_DOC_CONTENT);
|
|
85
|
+
let actionHint;
|
|
86
|
+
if (hasDocContentAuth) {
|
|
87
|
+
// 包含"获取成员文档内容"权限,引导 Agent 继续文档操作
|
|
88
|
+
actionHint = "用户已授予文档内容读取权限,请继续之前的文档操作。";
|
|
89
|
+
}
|
|
90
|
+
else if (authList.length > 0) {
|
|
91
|
+
// 有其他权限但没有文档内容读取权限
|
|
92
|
+
actionHint = "当前授权不包含文档内容读取权限,无法继续文档操作。请引导用户授予「获取成员文档内容」权限,该权限需要向管理员申请,管理员审批通过后可使用。";
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// 权限列表为空
|
|
96
|
+
actionHint = "当前无任何文档权限,无法继续文档操作。请引导用户完成文档授权。";
|
|
97
|
+
}
|
|
98
|
+
const senderUserId = body.from?.userid || "";
|
|
99
|
+
const senderCorpId = body.from?.corpid || "";
|
|
100
|
+
const chatId = body.from?.chat_id || body.chatid || senderUserId;
|
|
101
|
+
return [
|
|
102
|
+
"[企业微信文档权限变更回调]",
|
|
103
|
+
`event_type(事件类型): auth_change_event`,
|
|
104
|
+
`auth_list(当前权限列表): [${authList.join(", ")}] (${authDescriptions || "无"})`,
|
|
105
|
+
body.msgid ? `msgid(消息 id): ${body.msgid}` : undefined,
|
|
106
|
+
body.aibotid ? `aibotid(机器人 id): ${body.aibotid}` : undefined,
|
|
107
|
+
body.chattype ? `chat_type(会话类型): ${body.chattype}` : undefined,
|
|
108
|
+
chatId ? `chat_id(会话 id): ${chatId}` : undefined,
|
|
109
|
+
senderCorpId ? `from.corpid(企业 id): ${senderCorpId}` : undefined,
|
|
110
|
+
senderUserId ? `from.userid(发送人 id): ${senderUserId}` : undefined,
|
|
111
|
+
"",
|
|
112
|
+
`[操作指引] ${actionHint}`,
|
|
113
|
+
]
|
|
114
|
+
.filter((line) => line !== undefined)
|
|
115
|
+
.join("\n");
|
|
116
|
+
}
|
|
49
117
|
/**
|
|
50
118
|
* 解析消息内容(支持单条消息、图文混排、事件回调和引用消息)
|
|
51
119
|
* @returns 提取的文本数组、图片URL数组和引用消息内容
|
|
@@ -57,8 +125,14 @@ export function parseMessageContent(body) {
|
|
|
57
125
|
const fileUrls = [];
|
|
58
126
|
const fileAesKeys = new Map();
|
|
59
127
|
let quoteContent;
|
|
60
|
-
// 处理模板卡片事件回调
|
|
61
128
|
if (body.msgtype === "event") {
|
|
129
|
+
// 处理权限变更事件回调(如文档授权)
|
|
130
|
+
const authChangeText = buildAuthChangeEventText(body);
|
|
131
|
+
if (authChangeText) {
|
|
132
|
+
textParts.push(authChangeText);
|
|
133
|
+
return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
|
|
134
|
+
}
|
|
135
|
+
// 处理模板卡片事件回调
|
|
62
136
|
const eventText = buildTemplateCardEventText(body);
|
|
63
137
|
if (eventText) {
|
|
64
138
|
textParts.push(eventText);
|
package/dist/src/monitor.js
CHANGED
|
@@ -29,7 +29,7 @@ import { maskTemplateCardBlocks } from "./template-card-parser.js";
|
|
|
29
29
|
import { updateTemplateCardOnEvent, processTemplateCardsIfNeeded, } from "./template-card-manager.js";
|
|
30
30
|
import { checkGroupPolicy } from "./group-policy.js";
|
|
31
31
|
import { checkDmPolicy } from "./dm-policy.js";
|
|
32
|
-
import { setWeComWebSocket, setMessageState, deleteMessageState, setReqIdForChat, warmupReqIdStore, startMessageStateCleanup, stopMessageStateCleanup, cleanupAccount, } from "./state-manager.js";
|
|
32
|
+
import { setWeComWebSocket, setMessageState, deleteMessageState, setReqIdForChat, setSessionChatInfo, warmupReqIdStore, startMessageStateCleanup, stopMessageStateCleanup, cleanupAccount, } from "./state-manager.js";
|
|
33
33
|
import { PLUGIN_VERSION } from "./version.js";
|
|
34
34
|
import { processDynamicRouting } from "./dynamic-routing.js";
|
|
35
35
|
// ============================================================================
|
|
@@ -340,6 +340,13 @@ async function routeAndDispatchMessage(params) {
|
|
|
340
340
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
341
341
|
ctx: ctxPayload,
|
|
342
342
|
cfg: config,
|
|
343
|
+
replyOptions: {
|
|
344
|
+
// 打印 LLM 返回的原始分片内容(在 openclaw 核心对 MEDIA: 指令解析之前),
|
|
345
|
+
// 用于排查流式分片导致 MEDIA 指令被切断、识别丢失等问题
|
|
346
|
+
onPartialReply: (payload) => {
|
|
347
|
+
runtime.log?.(`[openclaw -> plugin][partial] payload=${JSON.stringify(payload)}`);
|
|
348
|
+
},
|
|
349
|
+
},
|
|
343
350
|
dispatcherOptions: {
|
|
344
351
|
onReplyStart: async () => {
|
|
345
352
|
if (!isShowThink && state.streamId && !state.accumulatedText) {
|
|
@@ -353,7 +360,7 @@ async function routeAndDispatchMessage(params) {
|
|
|
353
360
|
}
|
|
354
361
|
},
|
|
355
362
|
deliver: async (payload, info) => {
|
|
356
|
-
runtime.log?.(`[openclaw -> plugin] kind=${info.kind},
|
|
363
|
+
runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, payload=${JSON.stringify(payload)}, info=${JSON.stringify(info)}`);
|
|
357
364
|
// 累积文本
|
|
358
365
|
if (payload.text) {
|
|
359
366
|
state.accumulatedText += `${payload.text || ''}`;
|
|
@@ -541,6 +548,17 @@ async function processWeComMessageNow(entry) {
|
|
|
541
548
|
// }
|
|
542
549
|
// Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
|
|
543
550
|
const { ctxPayload, route, storePath, chatId: resolvedChatId, chatType } = buildMessageContext(frame, account, config, text, mediaList, quoteContent, runtime);
|
|
551
|
+
// 以 sessionKey 为键记录「原始大小写」的 chatId 与 chatType,
|
|
552
|
+
// 供 MCP 工具工厂(index.ts:registerTool)在构造工具闭包时取回,
|
|
553
|
+
// 进而传递给需要原始 chatId 的拦截器(如 doc-auth-error 发送 biz_msg)。
|
|
554
|
+
//
|
|
555
|
+
// 注意:不要使用 parseSessionKeyChat 反解 sessionKey —— OpenClaw core
|
|
556
|
+
// 构建 sessionKey 时会将 peer.id 强制小写化,会导致企业微信
|
|
557
|
+
// aibot_send_biz_msg 报 errcode=93006 invalid chatid。
|
|
558
|
+
setSessionChatInfo(route.sessionKey, {
|
|
559
|
+
chatId: resolvedChatId,
|
|
560
|
+
chatType: chatType === "group" ? "group" : "single",
|
|
561
|
+
});
|
|
544
562
|
// runtime.log?.(`[plugin -> openclaw] body=${text}, mediaPaths=${JSON.stringify(mediaList.map(m => m.path))}${quoteContent ? `, quote=${quoteContent}` : ''}`);
|
|
545
563
|
try {
|
|
546
564
|
await routeAndDispatchMessage({
|
|
@@ -768,21 +786,28 @@ export async function monitorWeComProvider(options) {
|
|
|
768
786
|
const eventBody = frame.body;
|
|
769
787
|
const eventType = eventBody.event?.eventtype;
|
|
770
788
|
runtime.log?.(`[${account.accountId}] Received event callback: eventtype=${eventType ?? ""}, msgid=${eventBody.msgid ?? ""}`);
|
|
771
|
-
if (eventType
|
|
772
|
-
|
|
789
|
+
if (eventType === "template_card_event") {
|
|
790
|
+
const templateCardEvent = eventBody.event?.template_card_event;
|
|
791
|
+
runtime.log?.(`[${account.accountId}] Received template_card_event: event_key=${templateCardEvent?.event_key ?? ""}, task_id=${templateCardEvent?.task_id ?? ""}`);
|
|
792
|
+
try {
|
|
793
|
+
await updateTemplateCardOnEvent({
|
|
794
|
+
frame,
|
|
795
|
+
accountId: account.accountId,
|
|
796
|
+
runtime,
|
|
797
|
+
wsClient,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
catch (updateErr) {
|
|
801
|
+
runtime.error?.(`[${account.accountId}] [template-card-update] Failed to update template card: ${String(updateErr)}`);
|
|
802
|
+
}
|
|
773
803
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
await updateTemplateCardOnEvent({
|
|
778
|
-
frame,
|
|
779
|
-
accountId: account.accountId,
|
|
780
|
-
runtime,
|
|
781
|
-
wsClient,
|
|
782
|
-
});
|
|
804
|
+
else if (eventType === "auth_change_event") {
|
|
805
|
+
const authChangeEvent = eventBody.event?.auth_change_event;
|
|
806
|
+
runtime.log?.(`[${account.accountId}] Received auth_change_event: auth_list=[${authChangeEvent?.auth_list?.join(", ") ?? ""}]`);
|
|
783
807
|
}
|
|
784
|
-
|
|
785
|
-
|
|
808
|
+
else {
|
|
809
|
+
// 其他未识别的事件类型,跳过
|
|
810
|
+
return;
|
|
786
811
|
}
|
|
787
812
|
const entry = await prepareWeComMessage({
|
|
788
813
|
frame,
|
|
@@ -796,10 +821,10 @@ export async function monitorWeComProvider(options) {
|
|
|
796
821
|
}
|
|
797
822
|
}
|
|
798
823
|
catch (err) {
|
|
799
|
-
runtime.error?.(`[${account.accountId}] Failed to process
|
|
824
|
+
runtime.error?.(`[${account.accountId}] Failed to process event callback (${frame.body?.event?.eventtype ?? "unknown"}): ${String(err)}`);
|
|
800
825
|
}
|
|
801
826
|
});
|
|
802
|
-
runtime.log?.(`[${account.accountId}] Event listeners attached: message + event(template_card_event)`);
|
|
827
|
+
runtime.log?.(`[${account.accountId}] Event listeners attached: message + event(template_card_event, auth_change_event)`);
|
|
803
828
|
// 启动前预热 reqId 缓存,确保完成后再建立连接,避免 getSync 在预热完成前返回 undefined
|
|
804
829
|
warmupReqIdStore(account.accountId, (...args) => runtime.log?.(...args))
|
|
805
830
|
.then((count) => {
|
|
@@ -446,7 +446,7 @@ function buildAccountScopedDmSecurityPolicyFallback(params) {
|
|
|
446
446
|
const _sdkPolicyReady = (async () => {
|
|
447
447
|
try {
|
|
448
448
|
// 尝试从主 SDK 包导入
|
|
449
|
-
const sdk = await import("openclaw/plugin-sdk");
|
|
449
|
+
const sdk = await import("openclaw/plugin-sdk/channel-policy");
|
|
450
450
|
if (typeof sdk.buildAccountScopedDmSecurityPolicy === "function") {
|
|
451
451
|
return { buildAccountScopedDmSecurityPolicy: sdk.buildAccountScopedDmSecurityPolicy };
|
|
452
452
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import type { WecomAgentConfig, WecomBotConfig } from "../types/index.js";
|
|
3
3
|
type WecomCommandAuthAccountConfig = Pick<WecomBotConfig, "dmPolicy" | "allowFrom"> | Pick<WecomAgentConfig, "dmPolicy" | "allowFrom">;
|
|
4
4
|
export declare function resolveWecomCommandAuthorization(params: {
|
|
@@ -70,6 +70,25 @@ export declare function warmupReqIdStore(accountId?: string, log?: (...args: unk
|
|
|
70
70
|
* 注意:由于移除了磁盘存储,此函数现在是无操作
|
|
71
71
|
*/
|
|
72
72
|
export declare function flushReqIdStore(accountId?: string): Promise<void>;
|
|
73
|
+
/** 会话 chat 信息 */
|
|
74
|
+
export interface SessionChatInfo {
|
|
75
|
+
/** 原始大小写的 chatId(群 ID 或用户 ID) */
|
|
76
|
+
chatId: string;
|
|
77
|
+
/** 聊天类型:single(单聊)或 group(群聊) */
|
|
78
|
+
chatType: "single" | "group";
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 记录 sessionKey 对应的原始会话信息(由 monitor.ts 在消息入站时调用)
|
|
82
|
+
*/
|
|
83
|
+
export declare function setSessionChatInfo(sessionKey: string, info: SessionChatInfo): void;
|
|
84
|
+
/**
|
|
85
|
+
* 获取 sessionKey 对应的原始会话信息(由 registerTool 闭包调用)
|
|
86
|
+
*/
|
|
87
|
+
export declare function getSessionChatInfo(sessionKey: string | undefined): SessionChatInfo | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* 删除 sessionKey 对应的会话信息(会话结束时可选调用)
|
|
90
|
+
*/
|
|
91
|
+
export declare function deleteSessionChatInfo(sessionKey: string): void;
|
|
73
92
|
/**
|
|
74
93
|
* 清理指定账户的所有资源
|
|
75
94
|
*/
|
|
@@ -174,8 +174,41 @@ export async function warmupReqIdStore(accountId = "default", log) {
|
|
|
174
174
|
export async function flushReqIdStore(accountId = "default") {
|
|
175
175
|
// 由于移除了磁盘存储,不再需要刷写操作
|
|
176
176
|
}
|
|
177
|
+
/** sessionKey → SessionChatInfo 映射 */
|
|
178
|
+
const sessionChatInfoMap = new Map();
|
|
179
|
+
/** 容量上限,超出按插入顺序淘汰最旧项,避免长时间运行内存增长 */
|
|
180
|
+
const SESSION_CHAT_INFO_MAX_SIZE = 5000;
|
|
181
|
+
/**
|
|
182
|
+
* 记录 sessionKey 对应的原始会话信息(由 monitor.ts 在消息入站时调用)
|
|
183
|
+
*/
|
|
184
|
+
export function setSessionChatInfo(sessionKey, info) {
|
|
185
|
+
if (!sessionKey)
|
|
186
|
+
return;
|
|
187
|
+
// 容量控制:超限时淘汰最早插入的条目(Map 保留插入顺序)
|
|
188
|
+
if (sessionChatInfoMap.size >= SESSION_CHAT_INFO_MAX_SIZE && !sessionChatInfoMap.has(sessionKey)) {
|
|
189
|
+
const oldestKey = sessionChatInfoMap.keys().next().value;
|
|
190
|
+
if (oldestKey !== undefined) {
|
|
191
|
+
sessionChatInfoMap.delete(oldestKey);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
sessionChatInfoMap.set(sessionKey, info);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 获取 sessionKey 对应的原始会话信息(由 registerTool 闭包调用)
|
|
198
|
+
*/
|
|
199
|
+
export function getSessionChatInfo(sessionKey) {
|
|
200
|
+
if (!sessionKey)
|
|
201
|
+
return undefined;
|
|
202
|
+
return sessionChatInfoMap.get(sessionKey);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* 删除 sessionKey 对应的会话信息(会话结束时可选调用)
|
|
206
|
+
*/
|
|
207
|
+
export function deleteSessionChatInfo(sessionKey) {
|
|
208
|
+
sessionChatInfoMap.delete(sessionKey);
|
|
209
|
+
}
|
|
177
210
|
// ============================================================================
|
|
178
|
-
// 全局 cleanup
|
|
211
|
+
// 全局 cleanup 原始位置
|
|
179
212
|
// ============================================================================
|
|
180
213
|
/**
|
|
181
214
|
* 清理指定账户的所有资源
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 从 lh 版 shared/command-auth.ts 迁移。
|
|
5
5
|
* 适配新版 WeComConfig(dmPolicy / allowFrom 扁平化在顶层)。
|
|
6
6
|
*/
|
|
7
|
-
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
7
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
8
8
|
import type { WeComConfig } from "../utils.js";
|
|
9
9
|
/** 命令授权结果 */
|
|
10
10
|
export interface WecomCommandAuthResult {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 从 @mocrane/wecom monitor.ts 迁移的辅助工具函数集合。
|
|
5
5
|
* 包含:文本截断、兜底提示构建、本机路径提取、MIME 推断等。
|
|
6
6
|
*/
|
|
7
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
7
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
8
8
|
import type { StreamState, WecomWebhookTarget, WebhookInboundMessage, WebhookInboundQuote } from "./types.js";
|
|
9
9
|
/** DM 文本最大字节数上限 */
|
|
10
10
|
export declare const STREAM_MAX_DM_BYTES = 200000;
|