@wecom/wecom-openclaw-plugin 2026.4.2201 → 2026.5.7-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/mcp/interceptors/index.js +2 -0
- package/dist/src/mcp/interceptors/smartsheet-upload.d.ts +22 -0
- package/dist/src/mcp/interceptors/smartsheet-upload.js +263 -0
- package/dist/src/mcp/transport.js +26 -2
- package/dist/src/media-handler.d.ts +18 -0
- package/dist/src/media-handler.js +52 -0
- package/dist/src/message-parser.d.ts +8 -0
- package/dist/src/message-parser.js +14 -0
- package/dist/src/monitor.js +117 -45
- package/dist/src/state-manager.js +59 -32
- package/package.json +10 -1
- package/skills/wecom-doc-manager/SKILL.md +1 -0
- package/skills/wecom-smartsheet-data/SKILL.md +21 -2
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +13 -2
- package/skills/wecom-smartsheet-data/references/webhook-examples.md +185 -0
- package/skills/wecom-smartsheet-data/references/webhook-fallback.md +184 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +1 -0
package/dist/src/monitor.js
CHANGED
|
@@ -17,21 +17,31 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as os from "os";
|
|
19
19
|
import * as path from "path";
|
|
20
|
-
import { WSClient, generateReqId, WSAuthFailureError, WSReconnectExhaustedError } from "@wecom/aibot-node-sdk";
|
|
21
|
-
import { getWeComRuntime } from "./runtime.js";
|
|
22
|
-
import { getDefaultMediaLocalRoots, resolveStateDir } from "./openclaw-compat.js";
|
|
20
|
+
import { WSClient, generateReqId, WSAuthFailureError, WSReconnectExhaustedError, } from "@wecom/aibot-node-sdk";
|
|
23
21
|
import { CHANNEL_ID, THINKING_MESSAGE, MEDIA_IMAGE_PLACEHOLDER, MEDIA_DOCUMENT_PLACEHOLDER, WS_HEARTBEAT_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_MAX_AUTH_FAILURE_ATTEMPTS, EVENT_ENTER_CHECK_UPDATE, CMD_ENTER_EVENT_REPLY, SCENE_WECOM_OPENCLAW, } from "./const.js";
|
|
22
|
+
import { checkDmPolicy } from "./dm-policy.js";
|
|
23
|
+
import { processDynamicRouting } from "./dynamic-routing.js";
|
|
24
|
+
import { checkGroupPolicy } from "./group-policy.js";
|
|
25
|
+
import { downloadAndSaveImages, downloadAndSaveFiles, MediaOversizeError, } from "./media-handler.js";
|
|
26
|
+
import { uploadAndSendMedia } from "./media-uploader.js";
|
|
24
27
|
import { parseMessageContent } from "./message-parser.js";
|
|
25
28
|
import { sendWeComReply, StreamExpiredError } from "./message-sender.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import { maskTemplateCardBlocks } from "./template-card-parser.js";
|
|
29
|
-
import { updateTemplateCardOnEvent, processTemplateCardsIfNeeded, } from "./template-card-manager.js";
|
|
30
|
-
import { checkGroupPolicy } from "./group-policy.js";
|
|
31
|
-
import { checkDmPolicy } from "./dm-policy.js";
|
|
29
|
+
import { getDefaultMediaLocalRoots, resolveStateDir } from "./openclaw-compat.js";
|
|
30
|
+
import { getWeComRuntime } from "./runtime.js";
|
|
32
31
|
import { setWeComWebSocket, setMessageState, deleteMessageState, setReqIdForChat, setSessionChatInfo, warmupReqIdStore, startMessageStateCleanup, stopMessageStateCleanup, cleanupAccount, } from "./state-manager.js";
|
|
32
|
+
import { updateTemplateCardOnEvent, processTemplateCardsIfNeeded, } from "./template-card-manager.js";
|
|
33
|
+
import { maskTemplateCardBlocks } from "./template-card-parser.js";
|
|
33
34
|
import { PLUGIN_VERSION } from "./version.js";
|
|
34
|
-
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// 附件超限提示文案
|
|
37
|
+
// ============================================================================
|
|
38
|
+
/**
|
|
39
|
+
* 构造「附件超过 OpenClaw 大小限制」的中文提示文案。
|
|
40
|
+
*/
|
|
41
|
+
function buildMediaOversizeHintText(err) {
|
|
42
|
+
const maxMb = err.maxBytes / 1024 / 1024;
|
|
43
|
+
return `当前OpenClaw限制文件不超过${maxMb}MB,请修改OpenClaw配置。`;
|
|
44
|
+
}
|
|
35
45
|
// ============================================================================
|
|
36
46
|
// 媒体本地路径白名单扩展
|
|
37
47
|
// ============================================================================
|
|
@@ -135,7 +145,12 @@ function buildMessageContext(frame, account, config, text, mediaList, quoteConte
|
|
|
135
145
|
const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${body.from.userid}`;
|
|
136
146
|
// 当只有媒体没有文本时,使用占位符标识媒体类型
|
|
137
147
|
const hasImages = mediaList.some((m) => m.contentType?.startsWith("image/"));
|
|
138
|
-
const messageBody = text ||
|
|
148
|
+
const messageBody = text ||
|
|
149
|
+
(mediaList.length > 0
|
|
150
|
+
? hasImages
|
|
151
|
+
? MEDIA_IMAGE_PLACEHOLDER
|
|
152
|
+
: MEDIA_DOCUMENT_PLACEHOLDER
|
|
153
|
+
: "");
|
|
139
154
|
// 构建多媒体数组
|
|
140
155
|
const mediaPaths = mediaList.length > 0 ? mediaList.map((m) => m.path) : undefined;
|
|
141
156
|
const mediaTypes = mediaList.length > 0
|
|
@@ -272,7 +287,9 @@ async function finishThinkingStream(ctx) {
|
|
|
272
287
|
}
|
|
273
288
|
else if (state.hasMedia) {
|
|
274
289
|
if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
275
|
-
finishText = finishText
|
|
290
|
+
finishText = finishText
|
|
291
|
+
? `${finishText}\n\n${state.mediaErrorSummary}`
|
|
292
|
+
: state.mediaErrorSummary;
|
|
276
293
|
}
|
|
277
294
|
else if (!finishText) {
|
|
278
295
|
finishText = "📎 文件已发送,请查收。";
|
|
@@ -283,7 +300,14 @@ async function finishThinkingStream(ctx) {
|
|
|
283
300
|
let expired = state.streamExpired;
|
|
284
301
|
if (!expired) {
|
|
285
302
|
try {
|
|
286
|
-
await sendWeComReply({
|
|
303
|
+
await sendWeComReply({
|
|
304
|
+
wsClient,
|
|
305
|
+
frame,
|
|
306
|
+
text: finishText,
|
|
307
|
+
runtime,
|
|
308
|
+
finish: true,
|
|
309
|
+
streamId: state.streamId,
|
|
310
|
+
});
|
|
287
311
|
}
|
|
288
312
|
catch (err) {
|
|
289
313
|
if (err instanceof StreamExpiredError) {
|
|
@@ -307,7 +331,7 @@ async function finishThinkingStream(ctx) {
|
|
|
307
331
|
* 路由消息到核心处理流程并处理回复
|
|
308
332
|
*/
|
|
309
333
|
async function routeAndDispatchMessage(params) {
|
|
310
|
-
const { ctxPayload, route, storePath, chatId, chatType, config, account, wsClient, frame, state, runtime, onCleanup } = params;
|
|
334
|
+
const { ctxPayload, route, storePath, chatId, chatType, config, account, wsClient, frame, state, runtime, onCleanup, } = params;
|
|
311
335
|
const core = getWeComRuntime();
|
|
312
336
|
const ctx = { wsClient, frame, state, account, runtime };
|
|
313
337
|
// 防止 onCleanup 被多次调用(onError 回调与 catch 块可能重复触发)
|
|
@@ -341,17 +365,23 @@ async function routeAndDispatchMessage(params) {
|
|
|
341
365
|
ctx: ctxPayload,
|
|
342
366
|
cfg: config,
|
|
343
367
|
replyOptions: {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
368
|
+
// 打印 LLM 返回的原始分片内容(在 openclaw 核心对 MEDIA: 指令解析之前),
|
|
369
|
+
// 用于排查流式分片导致 MEDIA 指令被切断、识别丢失等问题
|
|
370
|
+
// onPartialReply: (payload: unknown) => {
|
|
371
|
+
// runtime.log?.(`[openclaw -> plugin][partial] payload=${JSON.stringify(payload)}`);
|
|
372
|
+
// },
|
|
349
373
|
},
|
|
350
374
|
dispatcherOptions: {
|
|
351
375
|
onReplyStart: async () => {
|
|
352
376
|
if (!isShowThink && state.streamId && !state.accumulatedText) {
|
|
353
377
|
try {
|
|
354
|
-
await sendThinkingReply({
|
|
378
|
+
await sendThinkingReply({
|
|
379
|
+
wsClient,
|
|
380
|
+
frame,
|
|
381
|
+
streamId: state.streamId,
|
|
382
|
+
runtime,
|
|
383
|
+
state,
|
|
384
|
+
});
|
|
355
385
|
}
|
|
356
386
|
catch (e) {
|
|
357
387
|
runtime.error?.(`[wecom] sendThinkingReply threw err: ${String(e)}`);
|
|
@@ -363,10 +393,14 @@ async function routeAndDispatchMessage(params) {
|
|
|
363
393
|
runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, payload=${JSON.stringify(payload)}, info=${JSON.stringify(info)}`);
|
|
364
394
|
// 累积文本
|
|
365
395
|
if (payload.text) {
|
|
366
|
-
state.accumulatedText += `${payload.text ||
|
|
396
|
+
state.accumulatedText += `${payload.text || ""}`;
|
|
367
397
|
}
|
|
368
398
|
// 发送媒体(统一走主动发送)
|
|
369
|
-
const mediaUrls = payload.mediaUrls?.length
|
|
399
|
+
const mediaUrls = payload.mediaUrls?.length
|
|
400
|
+
? payload.mediaUrls
|
|
401
|
+
: payload.mediaUrl
|
|
402
|
+
? [payload.mediaUrl]
|
|
403
|
+
: [];
|
|
370
404
|
if (mediaUrls.length > 0) {
|
|
371
405
|
try {
|
|
372
406
|
await sendMediaBatch(ctx, mediaUrls);
|
|
@@ -392,7 +426,14 @@ async function routeAndDispatchMessage(params) {
|
|
|
392
426
|
// if (displayText !== state.accumulatedText) {
|
|
393
427
|
// runtime.log?.(`[wecom][template-card] Mid-frame masked: original=${state.accumulatedText.length}chars, masked=${displayText.length}chars`);
|
|
394
428
|
// }
|
|
395
|
-
await sendWeComReply({
|
|
429
|
+
await sendWeComReply({
|
|
430
|
+
wsClient,
|
|
431
|
+
frame,
|
|
432
|
+
text: displayText,
|
|
433
|
+
runtime,
|
|
434
|
+
finish: false,
|
|
435
|
+
streamId: state.streamId,
|
|
436
|
+
});
|
|
396
437
|
}
|
|
397
438
|
catch (err) {
|
|
398
439
|
if (err instanceof StreamExpiredError) {
|
|
@@ -411,7 +452,13 @@ async function routeAndDispatchMessage(params) {
|
|
|
411
452
|
},
|
|
412
453
|
});
|
|
413
454
|
// 模板卡片检测与发送(在关闭 thinking 流之前独立处理)
|
|
414
|
-
const cardResult = await processTemplateCardsIfNeeded({
|
|
455
|
+
const cardResult = await processTemplateCardsIfNeeded({
|
|
456
|
+
wsClient,
|
|
457
|
+
frame,
|
|
458
|
+
state,
|
|
459
|
+
account,
|
|
460
|
+
runtime,
|
|
461
|
+
});
|
|
415
462
|
if (cardResult) {
|
|
416
463
|
// 卡片已发送,用剩余文本替换累积文本
|
|
417
464
|
state.accumulatedText = cardResult.remainingText;
|
|
@@ -424,7 +471,13 @@ async function routeAndDispatchMessage(params) {
|
|
|
424
471
|
runtime.error?.(`[wecom][plugin] Failed to process message: ${String(err)}`);
|
|
425
472
|
// 即使 dispatch 抛异常,也需要处理卡片和关闭 thinking 流
|
|
426
473
|
try {
|
|
427
|
-
const cardResult = await processTemplateCardsIfNeeded({
|
|
474
|
+
const cardResult = await processTemplateCardsIfNeeded({
|
|
475
|
+
wsClient,
|
|
476
|
+
frame,
|
|
477
|
+
state,
|
|
478
|
+
account,
|
|
479
|
+
runtime,
|
|
480
|
+
});
|
|
428
481
|
if (cardResult) {
|
|
429
482
|
state.accumulatedText = cardResult.remainingText;
|
|
430
483
|
}
|
|
@@ -492,24 +545,43 @@ async function prepareWeComMessage(params) {
|
|
|
492
545
|
return null;
|
|
493
546
|
}
|
|
494
547
|
// Step 4: 下载并保存图片和文件
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
548
|
+
let imageMediaList;
|
|
549
|
+
let fileMediaList;
|
|
550
|
+
try {
|
|
551
|
+
[imageMediaList, fileMediaList] = await Promise.all([
|
|
552
|
+
downloadAndSaveImages({
|
|
553
|
+
imageUrls,
|
|
554
|
+
imageAesKeys,
|
|
555
|
+
account,
|
|
556
|
+
config,
|
|
557
|
+
runtime,
|
|
558
|
+
wsClient,
|
|
559
|
+
}),
|
|
560
|
+
downloadAndSaveFiles({
|
|
561
|
+
fileUrls,
|
|
562
|
+
fileAesKeys,
|
|
563
|
+
account,
|
|
564
|
+
config,
|
|
565
|
+
runtime,
|
|
566
|
+
wsClient,
|
|
567
|
+
}),
|
|
568
|
+
]);
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
if (err instanceof MediaOversizeError) {
|
|
572
|
+
// 附件超过 OpenClaw 配置的大小上限:向用户发送明确的中文提示并终止本次消息处理。
|
|
573
|
+
const hintText = buildMediaOversizeHintText(err);
|
|
574
|
+
runtime.error?.(`[wecom] Media oversize: kind=${err.kind}, size=${err.sizeBytes}, max=${err.maxBytes}, filename=${err.filename ?? "(none)"}`);
|
|
575
|
+
try {
|
|
576
|
+
await sendWeComReply({ wsClient, frame, text: hintText, runtime, finish: true });
|
|
577
|
+
}
|
|
578
|
+
catch (replyErr) {
|
|
579
|
+
runtime.error?.(`[wecom] Failed to send oversize hint: ${String(replyErr)}`);
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
513
585
|
const mediaList = [...imageMediaList, ...fileMediaList];
|
|
514
586
|
return {
|
|
515
587
|
frame,
|
|
@@ -532,7 +604,7 @@ async function prepareWeComMessage(params) {
|
|
|
532
604
|
* 同一会话中的消息通过串行队列保证按序执行。
|
|
533
605
|
*/
|
|
534
606
|
async function processWeComMessageNow(entry) {
|
|
535
|
-
const { frame, account, config, runtime, wsClient, text, mediaList, quoteContent, messageId, chatId, reqId } = entry;
|
|
607
|
+
const { frame, account, config, runtime, wsClient, text, mediaList, quoteContent, messageId, chatId, reqId, } = entry;
|
|
536
608
|
// Step 5: 初始化消息状态
|
|
537
609
|
setReqIdForChat(chatId, reqId, account.accountId);
|
|
538
610
|
const streamId = generateReqId("stream");
|
|
@@ -547,7 +619,7 @@ async function processWeComMessageNow(entry) {
|
|
|
547
619
|
// await sendThinkingReply({ wsClient, frame, streamId, runtime });
|
|
548
620
|
// }
|
|
549
621
|
// Step 7: 构建上下文并路由到核心处理流程(带整体超时保护)
|
|
550
|
-
const { ctxPayload, route, storePath, chatId: resolvedChatId, chatType } = buildMessageContext(frame, account, config, text, mediaList, quoteContent, runtime);
|
|
622
|
+
const { ctxPayload, route, storePath, chatId: resolvedChatId, chatType, } = buildMessageContext(frame, account, config, text, mediaList, quoteContent, runtime);
|
|
551
623
|
// 以 sessionKey 为键记录「原始大小写」的 chatId 与 chatType,
|
|
552
624
|
// 供 MCP 工具工厂(index.ts:registerTool)在构造工具闭包时取回,
|
|
553
625
|
// 进而传递给需要原始 chatId 的拦截器(如 doc-auth-error 发送 biz_msg)。
|
|
@@ -7,6 +7,34 @@
|
|
|
7
7
|
import { createPersistentReqIdStore } from "./reqid-store.js";
|
|
8
8
|
import { MESSAGE_STATE_TTL_MS, MESSAGE_STATE_CLEANUP_INTERVAL_MS, MESSAGE_STATE_MAX_SIZE, GLOBAL_WS_CLIENT_KEY, } from "./const.js";
|
|
9
9
|
// ============================================================================
|
|
10
|
+
// 全局单例状态(通过 globalThis + Symbol.for 确保跨 jiti loader 实例共享)
|
|
11
|
+
//
|
|
12
|
+
// 问题背景:openclaw 框架在不同阶段(主加载、configSchema 加载等)会创建
|
|
13
|
+
// 多个独立的 jiti loader 实例,每个实例有独立的模块缓存(parentCache),
|
|
14
|
+
// 导致本模块被多次实例化,模块级变量不共享。
|
|
15
|
+
// 使用 globalThis + Symbol.for 可以确保同一进程内只有一份状态。
|
|
16
|
+
// ============================================================================
|
|
17
|
+
/** 全局共享状态的唯一键 */
|
|
18
|
+
const SHARED_STATE_KEY = Symbol.for("wecom-openclaw-plugin:shared-state:v1");
|
|
19
|
+
/**
|
|
20
|
+
* 获取或创建全局共享状态(进程内单例)
|
|
21
|
+
*/
|
|
22
|
+
function getSharedState() {
|
|
23
|
+
const existing = globalThis[SHARED_STATE_KEY];
|
|
24
|
+
if (existing)
|
|
25
|
+
return existing;
|
|
26
|
+
const state = {
|
|
27
|
+
wsClientInstances: new Map(),
|
|
28
|
+
messageStates: new Map(),
|
|
29
|
+
cleanupTimer: null,
|
|
30
|
+
reqIdStores: new Map(),
|
|
31
|
+
};
|
|
32
|
+
globalThis[SHARED_STATE_KEY] = state;
|
|
33
|
+
return state;
|
|
34
|
+
}
|
|
35
|
+
/** 进程内唯一的共享状态 */
|
|
36
|
+
const shared = getSharedState();
|
|
37
|
+
// ============================================================================
|
|
10
38
|
// WSClient 实例管理
|
|
11
39
|
// ============================================================================
|
|
12
40
|
/** WSClient 实例管理 */
|
|
@@ -16,45 +44,45 @@ const wsClientInstances = (globalThis[GLOBAL_WS_CLIENT_KEY]
|
|
|
16
44
|
* 获取指定账户的 WSClient 实例
|
|
17
45
|
*/
|
|
18
46
|
export function getWeComWebSocket(accountId) {
|
|
19
|
-
return wsClientInstances.get(accountId) ?? null;
|
|
47
|
+
return shared.wsClientInstances.get(accountId) ?? null;
|
|
20
48
|
}
|
|
21
49
|
/**
|
|
22
50
|
* 设置指定账户的 WSClient 实例
|
|
23
51
|
*/
|
|
24
52
|
export function setWeComWebSocket(accountId, client) {
|
|
25
|
-
wsClientInstances.set(accountId, client);
|
|
53
|
+
shared.wsClientInstances.set(accountId, client);
|
|
26
54
|
}
|
|
27
55
|
/**
|
|
28
56
|
* 删除指定账户的 WSClient 实例
|
|
29
57
|
*/
|
|
30
58
|
export function deleteWeComWebSocket(accountId) {
|
|
31
|
-
wsClientInstances.delete(accountId);
|
|
59
|
+
shared.wsClientInstances.delete(accountId);
|
|
32
60
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// 消息状态管理(带 TTL 清理,防止内存泄漏)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// MessageStateEntry 接口已在上方 SharedState 定义处声明
|
|
37
65
|
/**
|
|
38
66
|
* 启动消息状态定期清理(自动 TTL 清理 + 容量限制)
|
|
39
67
|
*/
|
|
40
68
|
export function startMessageStateCleanup() {
|
|
41
|
-
if (cleanupTimer)
|
|
69
|
+
if (shared.cleanupTimer)
|
|
42
70
|
return;
|
|
43
|
-
cleanupTimer = setInterval(() => {
|
|
71
|
+
shared.cleanupTimer = setInterval(() => {
|
|
44
72
|
pruneMessageStates();
|
|
45
73
|
}, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
|
|
46
74
|
// 允许进程退出时不阻塞
|
|
47
|
-
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
48
|
-
cleanupTimer.unref();
|
|
75
|
+
if (shared.cleanupTimer && typeof shared.cleanupTimer === "object" && "unref" in shared.cleanupTimer) {
|
|
76
|
+
shared.cleanupTimer.unref();
|
|
49
77
|
}
|
|
50
78
|
}
|
|
51
79
|
/**
|
|
52
80
|
* 停止消息状态定期清理
|
|
53
81
|
*/
|
|
54
82
|
export function stopMessageStateCleanup() {
|
|
55
|
-
if (cleanupTimer) {
|
|
56
|
-
clearInterval(cleanupTimer);
|
|
57
|
-
cleanupTimer = null;
|
|
83
|
+
if (shared.cleanupTimer) {
|
|
84
|
+
clearInterval(shared.cleanupTimer);
|
|
85
|
+
shared.cleanupTimer = null;
|
|
58
86
|
}
|
|
59
87
|
}
|
|
60
88
|
/**
|
|
@@ -63,17 +91,17 @@ export function stopMessageStateCleanup() {
|
|
|
63
91
|
function pruneMessageStates() {
|
|
64
92
|
const now = Date.now();
|
|
65
93
|
// 1. 清理过期条目
|
|
66
|
-
for (const [key, entry] of messageStates) {
|
|
94
|
+
for (const [key, entry] of shared.messageStates) {
|
|
67
95
|
if (now - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
|
|
68
|
-
messageStates.delete(key);
|
|
96
|
+
shared.messageStates.delete(key);
|
|
69
97
|
}
|
|
70
98
|
}
|
|
71
99
|
// 2. 容量限制:如果仍超过最大条目数,按时间淘汰最旧的
|
|
72
|
-
if (messageStates.size > MESSAGE_STATE_MAX_SIZE) {
|
|
73
|
-
const sorted = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
74
|
-
const toRemove = sorted.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE);
|
|
100
|
+
if (shared.messageStates.size > MESSAGE_STATE_MAX_SIZE) {
|
|
101
|
+
const sorted = [...shared.messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
102
|
+
const toRemove = sorted.slice(0, shared.messageStates.size - MESSAGE_STATE_MAX_SIZE);
|
|
75
103
|
for (const [key] of toRemove) {
|
|
76
|
-
messageStates.delete(key);
|
|
104
|
+
shared.messageStates.delete(key);
|
|
77
105
|
}
|
|
78
106
|
}
|
|
79
107
|
}
|
|
@@ -81,7 +109,7 @@ function pruneMessageStates() {
|
|
|
81
109
|
* 设置消息状态
|
|
82
110
|
*/
|
|
83
111
|
export function setMessageState(messageId, state) {
|
|
84
|
-
messageStates.set(messageId, {
|
|
112
|
+
shared.messageStates.set(messageId, {
|
|
85
113
|
state,
|
|
86
114
|
createdAt: Date.now(),
|
|
87
115
|
});
|
|
@@ -90,12 +118,12 @@ export function setMessageState(messageId, state) {
|
|
|
90
118
|
* 获取消息状态
|
|
91
119
|
*/
|
|
92
120
|
export function getMessageState(messageId) {
|
|
93
|
-
const entry = messageStates.get(messageId);
|
|
121
|
+
const entry = shared.messageStates.get(messageId);
|
|
94
122
|
if (!entry)
|
|
95
123
|
return undefined;
|
|
96
124
|
// 检查 TTL
|
|
97
125
|
if (Date.now() - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
|
|
98
|
-
messageStates.delete(messageId);
|
|
126
|
+
shared.messageStates.delete(messageId);
|
|
99
127
|
return undefined;
|
|
100
128
|
}
|
|
101
129
|
return entry.state;
|
|
@@ -104,13 +132,13 @@ export function getMessageState(messageId) {
|
|
|
104
132
|
* 删除消息状态
|
|
105
133
|
*/
|
|
106
134
|
export function deleteMessageState(messageId) {
|
|
107
|
-
messageStates.delete(messageId);
|
|
135
|
+
shared.messageStates.delete(messageId);
|
|
108
136
|
}
|
|
109
137
|
/**
|
|
110
138
|
* 清空所有消息状态
|
|
111
139
|
*/
|
|
112
140
|
export function clearAllMessageStates() {
|
|
113
|
-
messageStates.clear();
|
|
141
|
+
shared.messageStates.clear();
|
|
114
142
|
}
|
|
115
143
|
// ============================================================================
|
|
116
144
|
// ReqId 持久化存储管理(按 accountId 隔离)
|
|
@@ -120,12 +148,11 @@ export function clearAllMessageStates() {
|
|
|
120
148
|
* 参考 createPersistentDedupe 模式:内存 + 磁盘双层、文件锁、原子写入、TTL 过期、防抖写入
|
|
121
149
|
* 重启后可从磁盘恢复,确保主动推送消息时能获取到 reqId
|
|
122
150
|
*/
|
|
123
|
-
const reqIdStores = new Map();
|
|
124
151
|
function getOrCreateReqIdStore(accountId) {
|
|
125
|
-
let store = reqIdStores.get(accountId);
|
|
152
|
+
let store = shared.reqIdStores.get(accountId);
|
|
126
153
|
if (!store) {
|
|
127
154
|
store = createPersistentReqIdStore(accountId);
|
|
128
|
-
reqIdStores.set(accountId, store);
|
|
155
|
+
shared.reqIdStores.set(accountId, store);
|
|
129
156
|
}
|
|
130
157
|
return store;
|
|
131
158
|
}
|
|
@@ -215,7 +242,7 @@ export function deleteSessionChatInfo(sessionKey) {
|
|
|
215
242
|
*/
|
|
216
243
|
export async function cleanupAccount(accountId) {
|
|
217
244
|
// 1. 断开 WSClient
|
|
218
|
-
const wsClient = wsClientInstances.get(accountId);
|
|
245
|
+
const wsClient = shared.wsClientInstances.get(accountId);
|
|
219
246
|
if (wsClient) {
|
|
220
247
|
try {
|
|
221
248
|
wsClient.disconnect();
|
|
@@ -223,7 +250,7 @@ export async function cleanupAccount(accountId) {
|
|
|
223
250
|
catch {
|
|
224
251
|
// 忽略断开连接时的错误
|
|
225
252
|
}
|
|
226
|
-
wsClientInstances.delete(accountId);
|
|
253
|
+
shared.wsClientInstances.delete(accountId);
|
|
227
254
|
}
|
|
228
255
|
// 2. 由于移除了磁盘存储,不再需要 flush reqId 存储
|
|
229
256
|
// 注意:不删除 store,因为重连后可能还需要
|
|
@@ -235,7 +262,7 @@ export async function cleanupAll() {
|
|
|
235
262
|
// 停止定期清理
|
|
236
263
|
stopMessageStateCleanup();
|
|
237
264
|
// 清理所有 WSClient
|
|
238
|
-
for (const [accountId, wsClient] of wsClientInstances) {
|
|
265
|
+
for (const [accountId, wsClient] of shared.wsClientInstances) {
|
|
239
266
|
try {
|
|
240
267
|
wsClient.disconnect();
|
|
241
268
|
}
|
|
@@ -243,7 +270,7 @@ export async function cleanupAll() {
|
|
|
243
270
|
// 忽略
|
|
244
271
|
}
|
|
245
272
|
}
|
|
246
|
-
wsClientInstances.clear();
|
|
273
|
+
shared.wsClientInstances.clear();
|
|
247
274
|
// 由于移除了磁盘存储,不再需要 flush 所有 reqId 存储
|
|
248
275
|
// 清空消息状态
|
|
249
276
|
clearAllMessageStates();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wecom/wecom-openclaw-plugin",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.5.7-beta.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"prebuild": "npm run clean",
|
|
24
24
|
"release": "node scripts/publish-all.mjs",
|
|
25
25
|
"release:dry": "node scripts/publish-all.mjs --dry-run",
|
|
26
|
+
"release:ci": "node scripts/publish-all.mjs --yes --restore=no",
|
|
26
27
|
"deploy:local": "bash scripts/dev-deploy.sh"
|
|
27
28
|
},
|
|
28
29
|
"keywords": [
|
|
@@ -67,5 +68,13 @@
|
|
|
67
68
|
"file-type": "^21.3.0",
|
|
68
69
|
"undici": "^7.24.6",
|
|
69
70
|
"zod": "^4.3.6"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"openclaw": ">=2026.3.28"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"openclaw": {
|
|
77
|
+
"optional": true
|
|
78
|
+
}
|
|
70
79
|
}
|
|
71
80
|
}
|
|
@@ -51,6 +51,7 @@ description: 企业微信文档管理技能。支持企业微信文档、智能
|
|
|
51
51
|
| errcode | errmsg | 含义 | 处理方式 |
|
|
52
52
|
|---------|--------|------|----------|
|
|
53
53
|
| `851002` | `incompatible doc type` | 文档品类与所调用的接口不匹配 | 根据文档 URL 重新确认品类(参见上方「URL 品类识别与接口路由」表),然后使用该品类对应的正确接口重试 |
|
|
54
|
+
| `851003` | `no authority` | 无权限调用该接口,**智能表格写入场景**下通常是企业可见范围 > 10 人的规模限制 | 若发生在需要写入数据的场景,引导用户走 Webhook 兜底方案,详见 [webhook-fallback.md](../wecom-smartsheet-data/references/webhook-fallback.md);其他接口则按权限问题排查 |
|
|
54
55
|
|
|
55
56
|
## 文档
|
|
56
57
|
|
|
@@ -41,14 +41,23 @@ description: 企业微信智能表格数据(记录)管理技能。提供智
|
|
|
41
41
|
|
|
42
42
|
添加一行或多行记录,单次建议 500 行内。
|
|
43
43
|
|
|
44
|
-
**调用前**必须先了解目标表的字段类型(通过 `smartsheet_get_fields
|
|
44
|
+
**调用前**必须先了解目标表的字段类型(通过 `smartsheet_get_fields`),并查看[单元格值格式参考](references/cell-value-formats.md),了解做法后再实施。
|
|
45
45
|
|
|
46
46
|
使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_add_records '{"docid": "DOCID", "sheet_id": "SHEETID", "records": [{"values": {"任务名称": [{"type": "text", "text": "完成需求文档"}], "优先级": [{"text": "高"}]}}]}'`
|
|
47
47
|
|
|
48
48
|
各字段类型的值格式参见 [单元格值格式参考](references/cell-value-formats.md)。
|
|
49
49
|
|
|
50
|
+
> ⚠️ **图片(IMAGE)/ 文件(ATTACHMENT)字段**:**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`),格式见下方示例。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `image_path` / `file_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
|
|
51
|
+
>
|
|
52
|
+
> 图片字段示例:`"封面": [{"image_path": "/path/to/img.png", "title": "图片标题"}]`
|
|
53
|
+
> 文件字段示例:`"文件": [{"file_path": "/path/to/file.pdf"}]`
|
|
54
|
+
|
|
55
|
+
> ⚠️ 若返回 `errcode: 851003` 或 `errmsg` 包含 `no authority`(通常是企业可见范围 > 10 人的规模限制),切换到 Webhook 兜底方案,详见 [webhook-fallback.md](references/webhook-fallback.md)。
|
|
56
|
+
|
|
50
57
|
### smartsheet_update_records
|
|
51
58
|
|
|
59
|
+
**调用前**必须先了解目标表的字段类型(通过 `smartsheet_get_fields`),并查看[单元格值格式参考](references/cell-value-formats.md),了解做法后再实施。
|
|
60
|
+
|
|
52
61
|
更新一行或多行记录,单次建议在 500 行内。需提供 record_id(通过 `smartsheet_get_records` 获取)。支持通过 `key_type` 指定 values 的 key 使用字段标题或字段 ID:
|
|
53
62
|
|
|
54
63
|
- `CELL_VALUE_KEY_TYPE_FIELD_TITLE`:key 为字段标题
|
|
@@ -58,6 +67,13 @@ description: 企业微信智能表格数据(记录)管理技能。提供智
|
|
|
58
67
|
|
|
59
68
|
**注意**:创建时间、最后编辑时间、创建人、最后编辑人字段不可更新。
|
|
60
69
|
|
|
70
|
+
> ⚠️ **图片(IMAGE)/ 文件(ATTACHMENT)字段**:**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`),格式见下方示例。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `image_path` / `file_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
|
|
71
|
+
>
|
|
72
|
+
> 图片字段示例:`"封面": [{"image_path": "/path/to/img.png", "title": "图片标题"}]`
|
|
73
|
+
> 文件字段示例:`"文件": [{"file_path": "/path/to/file.pdf"}]`
|
|
74
|
+
|
|
75
|
+
> ⚠️ 若返回 `errcode: 851003` 或 `errmsg` 包含 `no authority`(通常是企业可见范围 > 10 人的规模限制),切换到 Webhook 兜底方案,详见 [webhook-fallback.md](references/webhook-fallback.md)。注意 Webhook 只能更新通过 Webhook 写入的记录,人工创建的记录无法更新。
|
|
76
|
+
|
|
61
77
|
### smartsheet_delete_records
|
|
62
78
|
|
|
63
79
|
删除一行或多行记录,单次必须在 500 行内。**操作不可逆**。record_id 通过 `smartsheet_get_records` 获取。
|
|
@@ -69,6 +85,9 @@ description: 企业微信智能表格数据(记录)管理技能。提供智
|
|
|
69
85
|
1. **读取数据** → 使用 `wecom_mcp` tool 调用 `wecom_mcp call doc smartsheet_get_records '{"docid":"DOCID","sheet_id":"SHEETID"}'`
|
|
70
86
|
2. **写入数据** → 先 `smartsheet_get_fields` 了解列类型 → 若涉及成员(USER)字段,先通过 `wecom-contact-lookup` 的 `get_userlist` 查找人员 userid → `smartsheet_add_records` 写入
|
|
71
87
|
3. **更新数据** → 先 `smartsheet_get_records` 获取 record_id → 若涉及成员(USER)字段,先通过 `wecom-contact-lookup` 的 `get_userlist` 查找人员 userid → `smartsheet_update_records` 更新
|
|
72
|
-
4.
|
|
88
|
+
4. **写入失败 fallback** → 第 2/3 步返回 `errcode: 851003` / `no authority`(通常是企业可见范围 > 10 人的规模限制)时 → 请用户临时提供目标表的 Webhook 地址 + schema 示例 JSON(不保存到本地)→ 按 [webhook-fallback.md](references/webhook-fallback.md) 构造请求体发送
|
|
89
|
+
5. 若涉及**图片(IMAGE)或文件(ATTACHMENT)**字段,**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若返回错误,直接将错误码和错误信息告知用户,不得尝试其他上传方式。传入格式参考 [单元格值格式参考](references/cell-value-formats.md)。
|
|
90
|
+
6. **删除数据** → 先 `smartsheet_get_records` 确认 record_id → `smartsheet_delete_records` 删除
|
|
73
91
|
|
|
74
92
|
> **注意**:成员(USER)类型字段需要填写 `user_id`,不能直接使用姓名。必须先通过 `wecom-contact-lookup` 技能的 `get_userlist` 接口按姓名查找到对应的 `userid` 后再使用。
|
|
93
|
+
> **注意**:图片(IMAGE)或文件(ATTACHMENT)字段**必须且只能**直接在 cell value 中传入本地路径(`image_path` / `file_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若返回错误,直接将错误码和错误信息告知用户,不得尝试其他上传方式。格式参考 [单元格值格式参考](references/cell-value-formats.md)。
|
|
@@ -118,12 +118,15 @@
|
|
|
118
118
|
|
|
119
119
|
### 9. 图片 (IMAGE)
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
数组格式,**必须且只能**直接在 cell value 中传入本地路径(`image_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `image_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
|
|
122
|
+
|
|
123
|
+
#### 9.1. 图片本地路径传入(唯一合法方式)
|
|
122
124
|
|
|
123
125
|
```json
|
|
124
|
-
"封面": [{"
|
|
126
|
+
"封面": [{"image_path": "/path/to/img.png", "title": "图片标题"}]
|
|
125
127
|
```
|
|
126
128
|
|
|
129
|
+
|
|
127
130
|
### 10. 地理位置 (LOCATION)
|
|
128
131
|
|
|
129
132
|
数组格式:
|
|
@@ -132,6 +135,14 @@
|
|
|
132
135
|
"地点": [{"source_type": 1, "id": "地点ID", "latitude": "39.9", "longitude": "116.3", "title": "北京"}]
|
|
133
136
|
```
|
|
134
137
|
|
|
138
|
+
### 11. 文件(Attachment)
|
|
139
|
+
|
|
140
|
+
数组格式,**必须且只能**直接在 cell value 中传入本地路径(`file_path`)。**严禁**调用 `upload_doc_image`、`upload_doc_file` 或任何上传接口,**严禁**进行 base64 编码——无论文件大小、无论 API 返回何种错误,均不得绕过此规则。若 `file_path` 方式返回错误,应直接将错误码和错误信息告知用户,**不得**尝试其他上传方式。
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
"文件": [{"file_path": "/path/to/file.pdf"}]
|
|
144
|
+
```
|
|
145
|
+
|
|
135
146
|
## 完整添加记录示例
|
|
136
147
|
|
|
137
148
|
```json
|