@wecom/wecom-openclaw-plugin 2026.4.23 → 2026.4.27-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.
@@ -7,6 +7,24 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
7
7
  import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
8
8
  import type { WSClient } from "@wecom/aibot-node-sdk";
9
9
  import type { ResolvedWeComAccount } from "./utils.js";
10
+ /**
11
+ * 附件超过 OpenClaw 配置的 `agents.defaults.mediaMaxMb` 上限时抛出。
12
+ *
13
+ * 本错误由插件层主动判定并抛出,不依赖 OpenClaw 核心层错误消息的字符串匹配,
14
+ * 上层(monitor)可通过 `instanceof MediaOversizeError` 精确识别并向用户提示。
15
+ */
16
+ export declare class MediaOversizeError extends Error {
17
+ readonly kind: "image" | "file";
18
+ readonly filename?: string;
19
+ readonly sizeBytes: number;
20
+ readonly maxBytes: number;
21
+ constructor(params: {
22
+ kind: "image" | "file";
23
+ filename?: string;
24
+ sizeBytes: number;
25
+ maxBytes: number;
26
+ });
27
+ }
10
28
  /**
11
29
  * 下载并保存所有图片到本地,每张图片的下载带超时保护
12
30
  */
@@ -8,6 +8,30 @@ import { getWeComRuntime } from "./runtime.js";
8
8
  import { IMAGE_DOWNLOAD_TIMEOUT_MS, FILE_DOWNLOAD_TIMEOUT_MS, DEFAULT_MEDIA_MAX_MB } from "./const.js";
9
9
  import { withTimeout } from "./timeout.js";
10
10
  // ============================================================================
11
+ // 媒体超限错误
12
+ // ============================================================================
13
+ /**
14
+ * 附件超过 OpenClaw 配置的 `agents.defaults.mediaMaxMb` 上限时抛出。
15
+ *
16
+ * 本错误由插件层主动判定并抛出,不依赖 OpenClaw 核心层错误消息的字符串匹配,
17
+ * 上层(monitor)可通过 `instanceof MediaOversizeError` 精确识别并向用户提示。
18
+ */
19
+ export class MediaOversizeError extends Error {
20
+ kind;
21
+ filename;
22
+ sizeBytes;
23
+ maxBytes;
24
+ constructor(params) {
25
+ super(`Media oversize: kind=${params.kind}, size=${params.sizeBytes}, max=${params.maxBytes}` +
26
+ (params.filename ? `, filename=${params.filename}` : ""));
27
+ this.name = "MediaOversizeError";
28
+ this.kind = params.kind;
29
+ this.filename = params.filename;
30
+ this.sizeBytes = params.sizeBytes;
31
+ this.maxBytes = params.maxBytes;
32
+ }
33
+ }
34
+ // ============================================================================
11
35
  // 图片格式检测辅助函数(基于 file-type 包)
12
36
  // ============================================================================
13
37
  /**
@@ -66,11 +90,25 @@ export async function downloadAndSaveImages(params) {
66
90
  runtime.log?.(`[wecom] WARN: Downloaded data is not a valid image format`);
67
91
  }
68
92
  }
93
+ // 大小校验由插件层主动进行,超限抛出 MediaOversizeError,由 monitor 统一提示用户。
94
+ // 不再把 maxBytes 传给核心层 saveMediaBuffer,避免重复校验产生无结构化错误。
95
+ if (imageBuffer.length > maxBytes) {
96
+ throw new MediaOversizeError({
97
+ kind: "image",
98
+ filename: originalFilename,
99
+ sizeBytes: imageBuffer.length,
100
+ maxBytes,
101
+ });
102
+ }
69
103
  const saved = await core.channel.media.saveMediaBuffer(imageBuffer, imageContentType, "inbound", maxBytes, originalFilename);
70
104
  mediaList.push({ path: saved.path, contentType: saved.contentType });
71
105
  runtime.log?.(`[wecom][plugin] Image saved: path=${saved.path}, contentType=${saved.contentType}`);
72
106
  }
73
107
  catch (err) {
108
+ // 媒体超限错误需要上抛给 monitor,用于向用户发送明确的提示文案。
109
+ if (err instanceof MediaOversizeError) {
110
+ throw err;
111
+ }
74
112
  runtime.error?.(`[wecom] Failed to download image: ${String(err)}`);
75
113
  }
76
114
  }
@@ -110,11 +148,25 @@ export async function downloadAndSaveFiles(params) {
110
148
  fileBuffer = fetched.buffer;
111
149
  fileContentType = fetched.contentType ?? "application/octet-stream";
112
150
  }
151
+ // 大小校验由插件层主动进行,超限抛出 MediaOversizeError,由 monitor 统一提示用户。
152
+ // 不再把 maxBytes 传给核心层 saveMediaBuffer,避免重复校验产生无结构化错误。
153
+ if (fileBuffer.length > maxBytes) {
154
+ throw new MediaOversizeError({
155
+ kind: "file",
156
+ filename: originalFilename,
157
+ sizeBytes: fileBuffer.length,
158
+ maxBytes,
159
+ });
160
+ }
113
161
  const saved = await core.channel.media.saveMediaBuffer(fileBuffer, fileContentType, "inbound", maxBytes, originalFilename);
114
162
  mediaList.push({ path: saved.path, contentType: saved.contentType });
115
163
  runtime.log?.(`[wecom][plugin] File saved: path=${saved.path}, contentType=${saved.contentType}`);
116
164
  }
117
165
  catch (err) {
166
+ // 媒体超限错误需要上抛给 monitor,用于向用户发送明确的提示文案。
167
+ if (err instanceof MediaOversizeError) {
168
+ throw err;
169
+ }
118
170
  runtime.error?.(`[wecom] Failed to download file: ${String(err)}`);
119
171
  }
120
172
  }
@@ -42,6 +42,10 @@ export interface MessageBody {
42
42
  url?: string;
43
43
  aeskey?: string;
44
44
  };
45
+ video?: {
46
+ url?: string;
47
+ aeskey?: string;
48
+ };
45
49
  quote?: {
46
50
  msgtype: string;
47
51
  text?: {
@@ -58,6 +62,10 @@ export interface MessageBody {
58
62
  url?: string;
59
63
  aeskey?: string;
60
64
  };
65
+ video?: {
66
+ url?: string;
67
+ aeskey?: string;
68
+ };
61
69
  };
62
70
  event?: {
63
71
  eventtype?: string;
@@ -175,6 +175,13 @@ export function parseMessageContent(body) {
175
175
  fileAesKeys.set(body.file.url, body.file.aeskey);
176
176
  }
177
177
  }
178
+ // 处理视频消息(沿用 file 下载/解密通路,作为文件附件透传)
179
+ if (body.msgtype === "video" && body.video?.url) {
180
+ fileUrls.push(body.video.url);
181
+ if (body.video.aeskey) {
182
+ fileAesKeys.set(body.video.url, body.video.aeskey);
183
+ }
184
+ }
178
185
  }
179
186
  // 处理引用消息
180
187
  if (body.quote) {
@@ -198,6 +205,13 @@ export function parseMessageContent(body) {
198
205
  fileAesKeys.set(body.quote.file.url, body.quote.file.aeskey);
199
206
  }
200
207
  }
208
+ else if (body.quote.msgtype === "video" && body.quote.video?.url) {
209
+ // 引用的视频消息:沿用文件下载通路
210
+ fileUrls.push(body.quote.video.url);
211
+ if (body.quote.video.aeskey) {
212
+ fileAesKeys.set(body.quote.video.url, body.quote.video.aeskey);
213
+ }
214
+ }
201
215
  }
202
216
  return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
203
217
  }
@@ -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 { downloadAndSaveImages, downloadAndSaveFiles } from "./media-handler.js";
27
- import { uploadAndSendMedia } from "./media-uploader.js";
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
- import { processDynamicRouting } from "./dynamic-routing.js";
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 || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
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 ? `${finishText}\n\n${state.mediaErrorSummary}` : state.mediaErrorSummary;
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({ wsClient, frame, text: finishText, runtime, finish: true, streamId: state.streamId });
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 块可能重复触发)
@@ -351,7 +375,13 @@ async function routeAndDispatchMessage(params) {
351
375
  onReplyStart: async () => {
352
376
  if (!isShowThink && state.streamId && !state.accumulatedText) {
353
377
  try {
354
- await sendThinkingReply({ wsClient, frame, streamId: state.streamId, runtime, state });
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 ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
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({ wsClient, frame, text: displayText, runtime, finish: false, streamId: state.streamId });
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({ wsClient, frame, state, account, runtime });
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({ wsClient, frame, state, account, runtime });
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
- const [imageMediaList, fileMediaList] = await Promise.all([
496
- downloadAndSaveImages({
497
- imageUrls,
498
- imageAesKeys,
499
- account,
500
- config,
501
- runtime,
502
- wsClient,
503
- }),
504
- downloadAndSaveFiles({
505
- fileUrls,
506
- fileAesKeys,
507
- account,
508
- config,
509
- runtime,
510
- wsClient,
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)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wecom/wecom-openclaw-plugin",
3
- "version": "2026.4.23",
3
+ "version": "2026.4.27-beta.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",