@mocrane/wecom 2026.3.8-4 → 2026.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +25 -22
  2. package/clawdbot.plugin.json +1 -0
  3. package/index.ts +38 -1
  4. package/openclaw.plugin.json +1 -0
  5. package/package.json +7 -4
  6. package/skills/wecom-contact-lookup/SKILL.md +162 -0
  7. package/skills/wecom-doc/SKILL.md +363 -0
  8. package/skills/wecom-doc/references/doc-api.md +224 -0
  9. package/skills/wecom-doc-manager/SKILL.md +64 -0
  10. package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
  11. package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
  12. package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
  13. package/skills/wecom-edit-todo/SKILL.md +249 -0
  14. package/skills/wecom-get-todo-detail/SKILL.md +143 -0
  15. package/skills/wecom-get-todo-list/SKILL.md +127 -0
  16. package/skills/wecom-meeting-create/SKILL.md +158 -0
  17. package/skills/wecom-meeting-create/references/example-full.md +30 -0
  18. package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
  19. package/skills/wecom-meeting-create/references/example-security.md +22 -0
  20. package/skills/wecom-meeting-manage/SKILL.md +136 -0
  21. package/skills/wecom-meeting-query/SKILL.md +330 -0
  22. package/skills/wecom-preflight/SKILL.md +141 -0
  23. package/skills/wecom-schedule/SKILL.md +159 -0
  24. package/skills/wecom-schedule/references/api-check-availability.md +56 -0
  25. package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
  26. package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
  27. package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
  28. package/skills/wecom-schedule/references/ref-reminders.md +24 -0
  29. package/skills/wecom-smartsheet-data/SKILL.md +71 -0
  30. package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
  31. package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
  32. package/skills/wecom-smartsheet-schema/SKILL.md +92 -0
  33. package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
  34. package/src/agent/handler.ts +105 -14
  35. package/src/channel.ts +7 -4
  36. package/src/compat/plugin-sdk-shim.ts +152 -0
  37. package/src/mcp/index.ts +7 -0
  38. package/src/mcp/schema.ts +108 -0
  39. package/src/mcp/tool.ts +247 -0
  40. package/src/mcp/transport.ts +583 -0
  41. package/src/mcp-config.ts +182 -0
  42. package/src/media/const.ts +24 -0
  43. package/src/media/index.ts +15 -0
  44. package/src/media/uploader.ts +240 -0
  45. package/src/monitor.ts +362 -40
  46. package/src/onboarding.ts +45 -6
  47. package/src/outbound.ts +116 -46
  48. package/src/timeout.ts +45 -0
  49. package/src/types/index.ts +1 -0
  50. package/src/types/message.ts +10 -1
  51. package/src/ws-adapter.ts +22 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * MCP 配置拉取与持久化模块
3
+ *
4
+ * 负责:
5
+ * - 通过 WSClient 发送 aibot_get_mcp_config 请求
6
+ * - 解析服务端响应,提取 MCP 配置 (url、type、is_authed)
7
+ * - 将配置写入 ~/.openclaw/wecomConfig/config.json 的 mcpConfig 字段
8
+ */
9
+
10
+ import os from "os";
11
+ import path from "path";
12
+ import type { WSClient } from "@wecom/aibot-node-sdk";
13
+ import { generateReqId } from "@wecom/aibot-node-sdk";
14
+ import { resolveFileIoHelpers } from "./compat/plugin-sdk-shim.js";
15
+ import type { WecomRuntimeEnv } from "./monitor/types.js";
16
+ import { withTimeout } from "./timeout.js";
17
+
18
+ // ============================================================================
19
+ // 常量
20
+ // ============================================================================
21
+
22
+ /** 获取 MCP 配置的 WebSocket 命令 */
23
+ const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
24
+
25
+ /** MCP 配置拉取超时时间(毫秒) */
26
+ const MCP_CONFIG_FETCH_TIMEOUT_MS = 15_000;
27
+
28
+ // ============================================================================
29
+ // 类型
30
+ // ============================================================================
31
+
32
+ /**
33
+ * MCP 配置响应体
34
+ */
35
+ export interface McpConfigBody {
36
+ /** MCP Server 的 StreamableHttp URL */
37
+ url: string;
38
+ /** 连接类型,如 "doc" */
39
+ type?: string;
40
+ /** 是否已授权 */
41
+ is_authed?: boolean;
42
+ }
43
+
44
+ // ============================================================================
45
+ // MCP 配置拉取
46
+ // ============================================================================
47
+
48
+ /**
49
+ * 通过 WSClient 发送 aibot_get_mcp_config 命令,获取 MCP 配置
50
+ *
51
+ * @param wsClient - 已认证的 WSClient 实例
52
+ * @returns MCP 配置 (url、type、is_authed)
53
+ * @throws 响应错误码非 0 或缺少 url 字段时抛出错误
54
+ */
55
+ export async function fetchMcpConfig(
56
+ wsClient: WSClient,
57
+ ): Promise<McpConfigBody> {
58
+ const reqId = generateReqId("mcp_config");
59
+
60
+ // 通过 reply 方法发送自定义命令
61
+ const response = await withTimeout(
62
+ wsClient.reply(
63
+ { headers: { req_id: reqId } },
64
+ { biz_type: "doc" },
65
+ MCP_GET_CONFIG_CMD,
66
+ ),
67
+ MCP_CONFIG_FETCH_TIMEOUT_MS,
68
+ `MCP config fetch timed out after ${MCP_CONFIG_FETCH_TIMEOUT_MS}ms`,
69
+ );
70
+
71
+ // 校验响应错误码
72
+ if (response.errcode && response.errcode !== 0) {
73
+ throw new Error(
74
+ `MCP config request failed: errcode=${response.errcode}, errmsg=${response.errmsg ?? "unknown"}`,
75
+ );
76
+ }
77
+
78
+ // 提取并校验 body
79
+ const body = response.body as McpConfigBody | undefined;
80
+ if (!body?.url) {
81
+ throw new Error(
82
+ "MCP config response missing required 'url' field",
83
+ );
84
+ }
85
+
86
+ return {
87
+ url: body.url,
88
+ type: "doc",
89
+ is_authed: body.is_authed,
90
+ };
91
+ }
92
+
93
+ // ============================================================================
94
+ // 配置持久化
95
+ // ============================================================================
96
+
97
+ /**
98
+ * 将 MCP 配置写入 ~/.openclaw/wecomConfig/config.json 的 mcpConfig 字段
99
+ *
100
+ * 使用 OpenClaw SDK 提供的文件锁和原子写入,保证并发安全。
101
+ * 配置格式: { mcpConfig: { [type]: { type, url } } }
102
+ */
103
+ async function saveMcpConfigToPluginJson(
104
+ config: McpConfigBody,
105
+ runtime: WecomRuntimeEnv,
106
+ ): Promise<void> {
107
+ const wecomConfigDir = path.join(os.homedir(), ".openclaw", "wecomConfig");
108
+ const wecomConfigPath = path.join(wecomConfigDir, "config.json");
109
+
110
+ const lockOptions = {
111
+ stale: 60_000,
112
+ retries: {
113
+ retries: 6,
114
+ factor: 1.35,
115
+ minTimeout: 8,
116
+ maxTimeout: 1200,
117
+ randomize: true,
118
+ },
119
+ };
120
+
121
+ const { withFileLock, readJsonFileWithFallback, writeJsonFileAtomically } =
122
+ await resolveFileIoHelpers();
123
+
124
+ await withFileLock(wecomConfigPath, lockOptions, async () => {
125
+ // 读取现有配置(不存在时使用空对象)
126
+ const { value: pluginJson } = await readJsonFileWithFallback<Record<string, unknown>>(
127
+ wecomConfigPath,
128
+ {},
129
+ );
130
+
131
+ // 确保 mcpConfig 字段存在且为对象
132
+ if (!pluginJson.mcpConfig || typeof pluginJson.mcpConfig !== "object") {
133
+ pluginJson.mcpConfig = {};
134
+ }
135
+
136
+ // 使用 type 作为键存储配置
137
+ const typeKey = config.type || "default";
138
+ (pluginJson.mcpConfig as Record<string, unknown>)[typeKey] = {
139
+ type: config.type,
140
+ url: config.url,
141
+ };
142
+
143
+ // 原子写入
144
+ await writeJsonFileAtomically(wecomConfigPath, pluginJson);
145
+
146
+ runtime.log?.(`[WeCom] MCP config saved to ${wecomConfigPath}`);
147
+ });
148
+ }
149
+
150
+ // ============================================================================
151
+ // 组合入口
152
+ // ============================================================================
153
+
154
+ /**
155
+ * 拉取 MCP 配置并持久化到 ~/.openclaw/wecomConfig/config.json
156
+ *
157
+ * 认证成功后调用。失败仅记录日志,不影响 WebSocket 消息正常收发。
158
+ *
159
+ * @param wsClient - 已认证的 WSClient 实例
160
+ * @param accountId - 账户 ID(用于日志)
161
+ * @param runtime - 运行时环境(用于日志)
162
+ */
163
+ export async function fetchAndSaveMcpConfig(
164
+ wsClient: WSClient,
165
+ accountId: string,
166
+ runtime: WecomRuntimeEnv,
167
+ ): Promise<void> {
168
+ try {
169
+ runtime.log?.(`[${accountId}] Fetching MCP config...`);
170
+
171
+ const config = await fetchMcpConfig(wsClient);
172
+ runtime.log?.(
173
+ `[${accountId}] MCP config fetched: url=${config.url}, type=${config.type ?? "N/A"}, is_authed=${config.is_authed ?? "N/A"}`,
174
+ );
175
+
176
+ await saveMcpConfigToPluginJson(config, runtime);
177
+ } catch (err) {
178
+ runtime.error?.(
179
+ `[${accountId}] Failed to fetch/save MCP config: ${String(err)}`,
180
+ );
181
+ }
182
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * WeCom 媒体文件大小限制常量
3
+ *
4
+ * 对标企业微信智能机器人临时素材上传限制。
5
+ * 超出限制时,uploader 会按规则降级(如图片→文件)或拒绝。
6
+ */
7
+
8
+ /** 图片最大字节数 (10 MB),超出则降级为 file 类型 */
9
+ export const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
10
+
11
+ /** 视频最大字节数 (10 MB),超出则降级为 file 类型 */
12
+ export const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
13
+
14
+ /** 语音最大字节数 (2 MB),超出则降级为 file 类型 */
15
+ export const VOICE_MAX_BYTES = 2 * 1024 * 1024;
16
+
17
+ /** 文件最大字节数 (20 MB),超出则拒绝发送 */
18
+ export const FILE_MAX_BYTES = 20 * 1024 * 1024;
19
+
20
+ /** 绝对大小上限,等于 FILE_MAX_BYTES */
21
+ export const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
22
+
23
+ /** 语音类型支持的 MIME 集合(企微仅支持 AMR 格式语音消息) */
24
+ export const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 媒体上传模块公共导出
3
+ */
4
+ export { uploadAndSendMediaBuffer } from "./uploader.js";
5
+ export type { UploadAndSendMediaBufferOptions, UploadAndSendMediaResult } from "./uploader.js";
6
+ export { detectWeComMediaType, applyFileSizeLimits } from "./uploader.js";
7
+ export type { FileSizeCheckResult } from "./uploader.js";
8
+ export {
9
+ IMAGE_MAX_BYTES,
10
+ VIDEO_MAX_BYTES,
11
+ VOICE_MAX_BYTES,
12
+ FILE_MAX_BYTES,
13
+ ABSOLUTE_MAX_BYTES,
14
+ VOICE_SUPPORTED_MIMES,
15
+ } from "./const.js";
@@ -0,0 +1,240 @@
1
+ /**
2
+ * WeCom WS 模式媒体上传工具模块
3
+ *
4
+ * 接收 deliver callback 已加载的 Buffer,执行:
5
+ * detectWeComMediaType → applyFileSizeLimits → wsClient.uploadMedia → wsClient.sendMediaMessage
6
+ *
7
+ * 不含 resolveMediaFile(由 deliver callback 提供 Buffer)。
8
+ */
9
+
10
+ import type { WeComMediaType, WSClient } from "@wecom/aibot-node-sdk";
11
+ import {
12
+ IMAGE_MAX_BYTES,
13
+ VIDEO_MAX_BYTES,
14
+ VOICE_MAX_BYTES,
15
+ ABSOLUTE_MAX_BYTES,
16
+ VOICE_SUPPORTED_MIMES,
17
+ } from "./const.js";
18
+
19
+ // ============================================================================
20
+ // 类型定义
21
+ // ============================================================================
22
+
23
+ /** 文件大小检查结果 */
24
+ export interface FileSizeCheckResult {
25
+ /** 最终确定的企微媒体类型(可能被降级) */
26
+ finalType: WeComMediaType;
27
+ /** 是否需要拒绝(超过绝对限制) */
28
+ shouldReject: boolean;
29
+ /** 拒绝原因(仅 shouldReject=true 时有值) */
30
+ rejectReason?: string;
31
+ /** 是否发生了降级 */
32
+ downgraded: boolean;
33
+ /** 降级说明(仅 downgraded=true 时有值) */
34
+ downgradeNote?: string;
35
+ }
36
+
37
+ /** uploadAndSendMediaBuffer 的参数 */
38
+ export interface UploadAndSendMediaBufferOptions {
39
+ /** WSClient 实例 */
40
+ wsClient: WSClient;
41
+ /** 文件数据(deliver callback 已读取) */
42
+ buffer: Buffer;
43
+ /** MIME 类型(deliver callback 已检测) */
44
+ contentType: string;
45
+ /** 文件名(deliver callback 已提取) */
46
+ fileName: string;
47
+ /** 目标会话 ID(单聊为 userid,群聊为 chatid) */
48
+ chatId: string;
49
+ /** 日志函数 */
50
+ log?: (msg: string) => void;
51
+ /** 错误日志函数 */
52
+ errorLog?: (msg: string) => void;
53
+ }
54
+
55
+ /** uploadAndSendMediaBuffer 的返回结果 */
56
+ export interface UploadAndSendMediaResult {
57
+ /** 是否发送成功 */
58
+ ok: boolean;
59
+ /** 最终的企微媒体类型 */
60
+ finalType?: WeComMediaType;
61
+ /** 是否被拒绝(文件过大) */
62
+ rejected?: boolean;
63
+ /** 拒绝原因 */
64
+ rejectReason?: string;
65
+ /** 是否发生了降级 */
66
+ downgraded?: boolean;
67
+ /** 降级说明 */
68
+ downgradeNote?: string;
69
+ /** 错误信息 */
70
+ error?: string;
71
+ }
72
+
73
+ // ============================================================================
74
+ // MIME → 企微媒体类型映射
75
+ // ============================================================================
76
+
77
+ /**
78
+ * 根据 MIME 类型检测企微媒体类型
79
+ */
80
+ export function detectWeComMediaType(mimeType: string): WeComMediaType {
81
+ const mime = mimeType.toLowerCase();
82
+
83
+ if (mime.startsWith("image/")) return "image";
84
+ if (mime.startsWith("video/")) return "video";
85
+ if (mime.startsWith("audio/") || mime === "application/ogg") return "voice";
86
+
87
+ return "file";
88
+ }
89
+
90
+ // ============================================================================
91
+ // 文件大小检查与降级
92
+ // ============================================================================
93
+
94
+ /**
95
+ * 检查文件大小并执行降级策略
96
+ *
97
+ * 降级规则:
98
+ * - voice 非 AMR 格式 → 降级为 file
99
+ * - image 超过 10MB → 降级为 file
100
+ * - video 超过 10MB → 降级为 file
101
+ * - voice 超过 2MB → 降级为 file
102
+ * - file 超过 20MB → 拒绝发送
103
+ */
104
+ export function applyFileSizeLimits(
105
+ fileSize: number,
106
+ detectedType: WeComMediaType,
107
+ contentType?: string,
108
+ ): FileSizeCheckResult {
109
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
110
+
111
+ // 绝对上限 20MB
112
+ if (fileSize > ABSOLUTE_MAX_BYTES) {
113
+ return {
114
+ finalType: detectedType,
115
+ shouldReject: true,
116
+ rejectReason: `文件大小 ${fileSizeMB}MB 超过企业微信最大限制 20MB,无法发送`,
117
+ downgraded: false,
118
+ };
119
+ }
120
+
121
+ switch (detectedType) {
122
+ case "image":
123
+ if (fileSize > IMAGE_MAX_BYTES) {
124
+ return {
125
+ finalType: "file",
126
+ shouldReject: false,
127
+ downgraded: true,
128
+ downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
129
+ };
130
+ }
131
+ break;
132
+
133
+ case "video":
134
+ if (fileSize > VIDEO_MAX_BYTES) {
135
+ return {
136
+ finalType: "file",
137
+ shouldReject: false,
138
+ downgraded: true,
139
+ downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
140
+ };
141
+ }
142
+ break;
143
+
144
+ case "voice":
145
+ // 企微语音仅支持 AMR 格式
146
+ if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
147
+ return {
148
+ finalType: "file",
149
+ shouldReject: false,
150
+ downgraded: true,
151
+ downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
152
+ };
153
+ }
154
+ if (fileSize > VOICE_MAX_BYTES) {
155
+ return {
156
+ finalType: "file",
157
+ shouldReject: false,
158
+ downgraded: true,
159
+ downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
160
+ };
161
+ }
162
+ break;
163
+
164
+ case "file":
165
+ break;
166
+ }
167
+
168
+ return {
169
+ finalType: detectedType,
170
+ shouldReject: false,
171
+ downgraded: false,
172
+ };
173
+ }
174
+
175
+ // ============================================================================
176
+ // 核心:接收 Buffer → 上传 → 发送
177
+ // ============================================================================
178
+
179
+ /**
180
+ * 接收已有 Buffer,通过 WSClient 上传临时素材并发送媒体消息
181
+ *
182
+ * 流程:detectWeComMediaType → applyFileSizeLimits → uploadMedia → sendMediaMessage
183
+ */
184
+ export async function uploadAndSendMediaBuffer(
185
+ options: UploadAndSendMediaBufferOptions,
186
+ ): Promise<UploadAndSendMediaResult> {
187
+ const { wsClient, buffer, contentType, fileName, chatId, log, errorLog } = options;
188
+
189
+ try {
190
+ // 1. 检测企微媒体类型
191
+ const detectedType = detectWeComMediaType(contentType);
192
+ log?.(`media-upload: type=${detectedType} contentType=${contentType} size=${buffer.length} fileName=${fileName}`);
193
+
194
+ // 2. 文件大小检查与降级
195
+ const sizeCheck = applyFileSizeLimits(buffer.length, detectedType, contentType);
196
+
197
+ if (sizeCheck.shouldReject) {
198
+ errorLog?.(`media-upload: rejected — ${sizeCheck.rejectReason}`);
199
+ return {
200
+ ok: false,
201
+ rejected: true,
202
+ rejectReason: sizeCheck.rejectReason,
203
+ finalType: sizeCheck.finalType,
204
+ };
205
+ }
206
+
207
+ const finalType = sizeCheck.finalType;
208
+
209
+ if (sizeCheck.downgraded) {
210
+ log?.(`media-upload: downgraded ${detectedType}→${finalType} — ${sizeCheck.downgradeNote}`);
211
+ }
212
+
213
+ // 3. 分片上传获取 media_id
214
+ log?.(`media-upload: uploading ${finalType} (${buffer.length} bytes)...`);
215
+ const uploadResult = await wsClient.uploadMedia(buffer, {
216
+ type: finalType,
217
+ filename: fileName,
218
+ });
219
+ log?.(`media-upload: uploaded media_id=${uploadResult.media_id}`);
220
+
221
+ // 4. 通过 sendMediaMessage 主动发送
222
+ const videoOptions = finalType === "video" ? { title: fileName } : undefined;
223
+ await wsClient.sendMediaMessage(chatId, finalType, uploadResult.media_id, videoOptions);
224
+ log?.(`media-upload: sent to chatId=${chatId} type=${finalType}`);
225
+
226
+ return {
227
+ ok: true,
228
+ finalType,
229
+ downgraded: sizeCheck.downgraded,
230
+ downgradeNote: sizeCheck.downgradeNote,
231
+ };
232
+ } catch (err) {
233
+ const errMsg = String(err);
234
+ errorLog?.(`media-upload: failed — ${errMsg}`);
235
+ return {
236
+ ok: false,
237
+ error: errMsg,
238
+ };
239
+ }
240
+ }