@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.
@@ -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
- /** MCP 配置缓存:category → response.body(完整配置) */
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
- * 自动根据 defaultAccount 配置获取对应账户的 WSClient,无需手动传入 accountId。
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(category);
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(category, body);
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(category);
284
- mcpSessionCache.set(category, session);
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(category, session);
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(category)) {
314
- const cached = mcpSessionCache.get(category);
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(category);
337
+ const cached = mcpSessionCache.get(key);
320
338
  if (cached?.initialized)
321
339
  return cached;
322
340
  // 防止并发重复初始化
323
- const inflight = inflightInitRequests.get(category);
341
+ const inflight = inflightInitRequests.get(key);
324
342
  if (inflight)
325
343
  return inflight;
326
- const promise = initializeSession(url, category).finally(() => {
327
- inflightInitRequests.delete(category);
344
+ const promise = initializeSession(url, accountId, category, requesterUserId).finally(() => {
345
+ inflightInitRequests.delete(key);
328
346
  });
329
- inflightInitRequests.set(category, promise);
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
- * 清理指定品类的所有 MCP 缓存(配置、会话、无状态标记)
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
- console.log(`${LOG_TAG} 清理缓存 (category="${category}")`);
394
- mcpConfigCache.delete(category);
395
- mcpSessionCache.delete(category);
396
- statelessCategories.delete(category);
397
- inflightInitRequests.delete(category);
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 url = await getMcpUrl(category);
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(category);
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 inflight = inflightInitRequests.get(category);
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(category);
520
+ const promise = initializeSession(url, accountId, category, requesterUserId).finally(() => {
521
+ inflightInitRequests.delete(key);
468
522
  });
469
- inflightInitRequests.set(category, promise);
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);
@@ -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}, text=${JSON.stringify(payload.text ?? '')}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
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 !== "template_card_event") {
772
- return;
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
- const templateCardEvent = eventBody.event?.template_card_event;
775
- runtime.log?.(`[${account.accountId}] Received template_card_event: event_key=${templateCardEvent?.event_key ?? ""}, task_id=${templateCardEvent?.task_id ?? ""}`);
776
- try {
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
- catch (updateErr) {
785
- runtime.error?.(`[${account.accountId}] [template-card-update] Failed to update template card: ${String(updateErr)}`);
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 template_card_event: ${String(err)}`);
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;