@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
package/src/outbound.ts CHANGED
@@ -4,9 +4,63 @@ import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } f
4
4
  import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
5
5
  import { getWecomRuntime } from "./runtime.js";
6
6
  import { getWsClient, waitForWsConnection } from "./ws-adapter.js";
7
+ import { uploadAndSendMediaBuffer } from "./media/index.js";
7
8
 
8
9
  import { resolveWecomTarget } from "./target.js";
9
10
 
11
+ // ─── MIME 类型映射表(扩展名 → Content-Type)──────────────────────────
12
+
13
+ const MIME_BY_EXT: Record<string, string> = {
14
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
15
+ webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
16
+ amr: "audio/amr", mp4: "video/mp4", mov: "video/quicktime",
17
+ pdf: "application/pdf", doc: "application/msword",
18
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
19
+ xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
20
+ ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
21
+ txt: "text/plain", csv: "text/csv", tsv: "text/tab-separated-values", md: "text/markdown", json: "application/json",
22
+ xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
23
+ zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
24
+ tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip",
25
+ rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
26
+ };
27
+
28
+ // ─── 共享的媒体加载逻辑 ────────────────────────────────────────────
29
+
30
+ /**
31
+ * 从 URL 或本地文件路径加载媒体文件,返回 buffer、contentType、filename。
32
+ * 供 Bot WS 和 Agent 两种发送模式共用。
33
+ */
34
+ async function loadMediaBuffer(mediaUrl: string): Promise<{
35
+ buffer: Buffer;
36
+ contentType: string;
37
+ filename: string;
38
+ }> {
39
+ const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);
40
+
41
+ if (isRemoteUrl) {
42
+ const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30_000) });
43
+ if (!res.ok) {
44
+ throw new Error(`Failed to download media: ${res.status}`);
45
+ }
46
+ const buffer = Buffer.from(await res.arrayBuffer());
47
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
48
+ const urlPath = new URL(mediaUrl).pathname;
49
+ const filename = urlPath.split("/").pop() || "media";
50
+ return { buffer, contentType, filename };
51
+ }
52
+
53
+ // 本地文件路径
54
+ const fs = await import("node:fs/promises");
55
+ const path = await import("node:path");
56
+ const buffer = await fs.readFile(mediaUrl);
57
+ const filename = path.basename(mediaUrl);
58
+ const ext = path.extname(mediaUrl).slice(1).toLowerCase();
59
+ const contentType = MIME_BY_EXT[ext] || "application/octet-stream";
60
+ console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
61
+ return { buffer, contentType, filename };
62
+ }
63
+
10
64
  function resolveAgentConfigOrThrow(params: {
11
65
  cfg: ChannelOutboundContext["cfg"];
12
66
  accountId?: string | null;
@@ -166,8 +220,68 @@ export const wecomOutbound: ChannelOutboundAdapter = {
166
220
  };
167
221
  },
168
222
  sendMedia: async ({ cfg, to, text, mediaUrl, accountId }: ChannelOutboundContext) => {
169
- // signal removed - not supported in current SDK
223
+ if (!mediaUrl) {
224
+ throw new Error("WeCom outbound requires mediaUrl.");
225
+ }
226
+
227
+ // ── Bot WebSocket 模式 ──
228
+ const resolvedAccount = resolveWecomAccount({ cfg, accountId });
229
+ const botAccount = resolvedAccount.bot;
230
+ if (botAccount?.connectionMode === "websocket" && botAccount.configured) {
231
+ const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
232
+ // 如果目标是 Agent 会话(wecom-agent:),跳过 WS Bot,走 Agent outbound
233
+ if (!rawTo.startsWith("wecom-agent:")) {
234
+ const wsTarget = resolveWecomTarget(to);
235
+ const chatId = wsTarget?.touser || wsTarget?.chatid;
236
+ if (!chatId) {
237
+ throw new Error(`[wecom-outbound] Bot WS sendMedia 无法解析目标 chatId (to=${String(to)})`);
238
+ }
170
239
 
240
+ // 确保 WS 连接可用
241
+ let wsClient = getWsClient(botAccount.accountId);
242
+ if (!wsClient?.isConnected) {
243
+ console.log(`[wecom-outbound] Bot WS 未连接,等待重连... (accountId=${botAccount.accountId})`);
244
+ const reconnected = await waitForWsConnection(botAccount.accountId, 10_000);
245
+ if (!reconnected) {
246
+ throw new Error(`[wecom-outbound] Bot WS 等待重连超时,无法发送媒体 (accountId=${botAccount.accountId})`);
247
+ }
248
+ wsClient = getWsClient(botAccount.accountId);
249
+ }
250
+ if (!wsClient?.isConnected) {
251
+ throw new Error(`[wecom-outbound] Bot WS 重连后仍不可用 (accountId=${botAccount.accountId})`);
252
+ }
253
+
254
+ // 加载媒体并通过 WSClient 上传发送
255
+ const { buffer, contentType, filename } = await loadMediaBuffer(mediaUrl);
256
+ console.log(`[wecom-outbound] Bot WS sendMedia: chatId=${chatId} filename=${filename} contentType=${contentType} size=${buffer.length}`);
257
+
258
+ const result = await uploadAndSendMediaBuffer({
259
+ wsClient,
260
+ buffer,
261
+ contentType,
262
+ fileName: filename,
263
+ chatId,
264
+ log: (msg) => console.log(`[wecom-outbound] ${msg}`),
265
+ errorLog: (msg) => console.error(`[wecom-outbound] ${msg}`),
266
+ });
267
+
268
+ if (result.rejected) {
269
+ throw new Error(`WeCom Bot WS 媒体被拒绝: ${result.rejectReason}`);
270
+ }
271
+ if (!result.ok) {
272
+ throw new Error(`WeCom Bot WS 媒体发送失败: ${result.error}`);
273
+ }
274
+
275
+ console.log(`[wecom-outbound] Bot WS sendMedia 成功: type=${result.finalType}${result.downgraded ? ` (降级: ${result.downgradeNote})` : ""}`);
276
+ return {
277
+ channel: "wecom",
278
+ messageId: `ws-bot-media-${Date.now()}`,
279
+ timestamp: Date.now(),
280
+ };
281
+ }
282
+ }
283
+
284
+ // ── Agent 模式 ──
171
285
  const agent = resolveAgentConfigOrThrow({ cfg, accountId });
172
286
  const target = resolveWecomTarget(to);
173
287
  if (!target) {
@@ -180,52 +294,8 @@ export const wecomOutbound: ChannelOutboundAdapter = {
180
294
  `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
181
295
  );
182
296
  }
183
- if (!mediaUrl) {
184
- throw new Error("WeCom outbound requires mediaUrl.");
185
- }
186
-
187
- let buffer: Buffer;
188
- let contentType: string;
189
- let filename: string;
190
-
191
- // 判断是 URL 还是本地文件路径
192
- const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);
193
297
 
194
- if (isRemoteUrl) {
195
- const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30000) });
196
- if (!res.ok) {
197
- throw new Error(`Failed to download media: ${res.status}`);
198
- }
199
- buffer = Buffer.from(await res.arrayBuffer());
200
- contentType = res.headers.get("content-type") || "application/octet-stream";
201
- const urlPath = new URL(mediaUrl).pathname;
202
- filename = urlPath.split("/").pop() || "media";
203
- } else {
204
- // 本地文件路径
205
- const fs = await import("node:fs/promises");
206
- const path = await import("node:path");
207
-
208
- buffer = await fs.readFile(mediaUrl);
209
- filename = path.basename(mediaUrl);
210
-
211
- // 根据扩展名推断 content-type
212
- const ext = path.extname(mediaUrl).slice(1).toLowerCase();
213
- const mimeTypes: Record<string, string> = {
214
- jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
215
- webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
216
- amr: "audio/amr", mp4: "video/mp4", pdf: "application/pdf", doc: "application/msword",
217
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
218
- xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
219
- ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
220
- txt: "text/plain", csv: "text/csv", tsv: "text/tab-separated-values", md: "text/markdown", json: "application/json",
221
- xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
222
- zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
223
- tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip",
224
- rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
225
- };
226
- contentType = mimeTypes[ext] || "application/octet-stream";
227
- console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
228
- }
298
+ const { buffer, contentType, filename } = await loadMediaBuffer(mediaUrl);
229
299
 
230
300
  let mediaType: "image" | "voice" | "video" | "file" = "file";
231
301
  if (contentType.startsWith("image/")) mediaType = "image";
package/src/timeout.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * 超时控制工具模块
3
+ *
4
+ * 为异步操作提供统一的超时保护机制
5
+ */
6
+
7
+ /**
8
+ * 为 Promise 添加超时保护
9
+ *
10
+ * @param promise - 原始 Promise
11
+ * @param timeoutMs - 超时时间(毫秒)
12
+ * @param message - 超时错误消息
13
+ * @returns 带超时保护的 Promise
14
+ */
15
+ export function withTimeout<T>(
16
+ promise: Promise<T>,
17
+ timeoutMs: number,
18
+ message?: string,
19
+ ): Promise<T> {
20
+ if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
21
+ return promise;
22
+ }
23
+
24
+ let timeoutId: ReturnType<typeof setTimeout>;
25
+
26
+ const timeoutPromise = new Promise<never>((_, reject) => {
27
+ timeoutId = setTimeout(() => {
28
+ reject(new TimeoutError(message ?? `Operation timed out after ${timeoutMs}ms`));
29
+ }, timeoutMs);
30
+ });
31
+
32
+ return Promise.race([promise, timeoutPromise]).finally(() => {
33
+ clearTimeout(timeoutId);
34
+ });
35
+ }
36
+
37
+ /**
38
+ * 超时错误类型
39
+ */
40
+ export class TimeoutError extends Error {
41
+ constructor(message: string) {
42
+ super(message);
43
+ this.name = "TimeoutError";
44
+ }
45
+ }
@@ -31,6 +31,7 @@ export type {
31
31
  WecomBotInboundBase,
32
32
  WecomBotInboundText,
33
33
  WecomBotInboundVoice,
34
+ WecomBotInboundVideo,
34
35
  WecomBotInboundStreamRefresh,
35
36
  WecomBotInboundEvent,
36
37
  WecomBotInboundMessage,
@@ -41,6 +41,12 @@ export type WecomBotInboundVoice = WecomBotInboundBase & {
41
41
  quote?: WecomInboundQuote;
42
42
  };
43
43
 
44
+ export type WecomBotInboundVideo = WecomBotInboundBase & {
45
+ msgtype: "video";
46
+ video?: { url?: string; aeskey?: string };
47
+ quote?: WecomInboundQuote;
48
+ };
49
+
44
50
  export type WecomBotInboundStreamRefresh = WecomBotInboundBase & {
45
51
  msgtype: "stream";
46
52
  stream?: { id?: string };
@@ -62,7 +68,7 @@ export type WecomBotInboundEvent = WecomBotInboundBase & {
62
68
  * 支持引用文本、图片、混合类型、语音、文件等。
63
69
  */
64
70
  export type WecomInboundQuote = {
65
- msgtype?: "text" | "image" | "mixed" | "voice" | "file";
71
+ msgtype?: "text" | "image" | "mixed" | "voice" | "file" | "video";
66
72
  /** 引用文本内容 */
67
73
  text?: { content?: string };
68
74
  /** 引用图片 URL */
@@ -79,11 +85,14 @@ export type WecomInboundQuote = {
79
85
  voice?: { content?: string };
80
86
  /** 引用文件 */
81
87
  file?: { url?: string };
88
+ /** 引用视频 */
89
+ video?: { url?: string };
82
90
  };
83
91
 
84
92
  export type WecomBotInboundMessage =
85
93
  | WecomBotInboundText
86
94
  | WecomBotInboundVoice
95
+ | WecomBotInboundVideo
87
96
  | WecomBotInboundStreamRefresh
88
97
  | WecomBotInboundEvent
89
98
  | (WecomBotInboundBase & { quote?: WecomInboundQuote } & Record<string, unknown>);
package/src/ws-adapter.ts CHANGED
@@ -32,6 +32,12 @@ import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState } from "./monitor
32
32
  import { shouldProcessBotInboundMessage, buildInboundBody } from "./monitor.js";
33
33
  import { monitorState } from "./monitor/state.js";
34
34
  import { getWecomRuntime } from "./runtime.js";
35
+ import { fetchAndSaveMcpConfig } from "./mcp-config.js";
36
+
37
+ // ─── Constants ─────────────────────────────────────────────────────────
38
+
39
+ /** "思考中" 占位消息,让用户立即看到机器人正在响应 */
40
+ const THINKING_MESSAGE = "<think></think>";
35
41
 
36
42
  // ─── WSClient Instance Registry ────────────────────────────────────────
37
43
 
@@ -190,6 +196,10 @@ function convertSdkMessageToInbound(body: BaseMessage): WecomBotInboundMessage {
190
196
  const fileBody = body as FileMessage;
191
197
  return { ...base, msgtype: "file" as any, file: fileBody.file, quote: fileBody.quote as any } as any;
192
198
  }
199
+ if (msgtype === "video") {
200
+ // SDK 没有导出 VideoMessage 类型,直接从 BaseMessage 取 video 字段
201
+ return { ...base, msgtype: "video" as any, video: (body as any).video, quote: (body as any).quote } as any;
202
+ }
193
203
  if (msgtype === "mixed") {
194
204
  const mixedBody = body as MixedMessage;
195
205
  return { ...base, msgtype: "mixed" as any, mixed: mixedBody.mixed, quote: mixedBody.quote as any } as any;
@@ -265,6 +275,16 @@ function setupMessageHandler(params: {
265
275
  s.wsMode = true;
266
276
  });
267
277
 
278
+ // 立即发送"思考中"占位消息,让用户看到即时反馈
279
+ const sendThinking = (target.account.config as any).sendThinkingMessage ?? true;
280
+ if (sendThinking) {
281
+ wsClient.replyStream(frame, streamId, THINKING_MESSAGE, false).catch((err) => {
282
+ target.runtime.error?.(
283
+ `[${accountId}] ws-thinking: failed to send thinking message: ${String(err)}`,
284
+ );
285
+ });
286
+ }
287
+
268
288
  // 注册流式回复监听器
269
289
  watchStreamReply({
270
290
  wsClient,
@@ -448,6 +468,8 @@ export function startWsClient(params: StartWsClientParams): () => void {
448
468
  });
449
469
  wsClient.on("authenticated", () => {
450
470
  runtime.log?.(`[${accountId}] ws: authenticated successfully`);
471
+ // 认证成功后拉取 MCP 配置(非阻塞,失败仅记日志)
472
+ void fetchAndSaveMcpConfig(wsClient, accountId, runtime);
451
473
  });
452
474
  wsClient.on("disconnected", (reason: string) => {
453
475
  runtime.log?.(`[${accountId}] ws: disconnected - ${reason}`);