@wecom/wecom-openclaw-plugin 2026.3.31-beta.4 → 2026.3.31-beta.5

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/README.md CHANGED
@@ -31,17 +31,24 @@
31
31
  - 🔗 **Dual-mode**: Bot (WebSocket / Webhook) and Agent (HTTP webhook) can run independently or together
32
32
  - 💬 Supports both direct messages (DM) and group chat
33
33
  - 📤 Proactive messaging to specific users, groups, departments, or tags
34
- - 🖼️ Receives and processes image, voice, video, and file messages with automatic downloading
34
+ - 🖼️ Receives and processes image, voice, video, file, and **mixed (图文混排)** messages with automatic downloading
35
+ - 🗣️ Voice-to-text: automatically extracts transcribed text from voice messages
36
+ - 💬 Quote message support: processes quoted text, image, voice, and file messages
35
37
  - ⏳ Streaming replies with "thinking" placeholder messages (Bot mode)
36
38
  - 🔐 Agent mode: AES-256-CBC encrypted XML callbacks with SHA1 signature verification
37
39
  - 📝 Markdown formatting support for replies
38
- - 🃏 Template card messages (text_notice, news_notice, button_interaction, vote_interaction, multiple_interaction)
40
+ - 🃏 Template card messages (text_notice, news_notice, button_interaction, vote_interaction, multiple_interaction) with **event callback handling**
39
41
  - 🔒 Built-in access control: DM Policy (pairing / open / allowlist / disabled) and Group Policy (open / allowlist / disabled)
42
+ - 🔑 Command authorization: per-account command permission control with access group support
40
43
  - 👥 Multi-account support: run multiple WeCom accounts with independent bot/agent configs
41
- - 🧩 MCP tool integration (`wecom_mcp`) for calling WeCom MCP Server
44
+ - 🧩 MCP tool integration (`wecom_mcp`) with interceptor pipeline (biz-error, media, smartpage-create, smartpage-export)
45
+ - 🎯 **15 built-in Skills**: contact lookup, doc management, todo, meeting, schedule, messaging, smartsheet, template cards, and more
42
46
  - 🔀 Dynamic Agent routing: auto-create isolated agents per user/group
43
47
  - 📁 Local file sending with configurable media path allowlist (`mediaLocalRoots`)
44
- - ⚡ Auto heartbeat keep-alive and reconnection (up to 10 reconnect attempts)
48
+ - 📊 Smart media size limits with auto-downgrade (image 10MB → file, video 10MB → file, voice 2MB/AMR-only → file, max 20MB)
49
+ - 🔄 **Bot-first, Agent-fallback** outbound delivery: auto fallback to Agent HTTP API when Bot WS is unavailable
50
+ - ⚡ Auto heartbeat keep-alive and reconnection (up to 10 reconnect attempts, 5 auth failure retries)
51
+ - 🛡️ Anti-kick protection: suppresses auto-restart on server-side disconnection to prevent mutual kicking loops
45
52
  - 🧙 Interactive CLI setup wizard
46
53
 
47
54
  ---
@@ -152,11 +159,20 @@ The plugin supports two connection modes that can be used independently or toget
152
159
  | Config Path | Description | Default |
153
160
  |---|---|---|
154
161
  | `channels.wecom.mediaLocalRoots` | Extra local paths allowed for media sending (supports `~`) | `[]` |
155
- | `channels.wecom.media.maxBytes` | Max media file size in bytes | `5242880` (5MB) |
162
+ | `channels.wecom.media.maxBytes` | Max media file size in bytes | `20971520` (20MB) |
156
163
  | `channels.wecom.media.tempDir` | Temp directory for media processing | — |
157
164
  | `channels.wecom.media.retentionHours` | Media file retention hours | — |
158
165
  | `channels.wecom.media.cleanupOnStart` | Clean temp media on startup | — |
159
166
 
167
+ **Media Size Limits & Auto-Downgrade:**
168
+
169
+ | Media Type | Max Size | Downgrade Behavior |
170
+ |---|---|---|
171
+ | Image | 10 MB | Exceeds → sent as file |
172
+ | Video | 10 MB | Exceeds → sent as file |
173
+ | Voice | 2 MB (AMR only) | Non-AMR format or exceeds → sent as file |
174
+ | File | 20 MB | Exceeds → rejected (cannot send) |
175
+
160
176
  #### Network Settings
161
177
 
162
178
  | Config Path | Description | Default |
@@ -175,13 +191,16 @@ Agent mode uses HTTP webhook callbacks with XML encrypted messages. You need to
175
191
  #### Prerequisites
176
192
 
177
193
  1. Create a self-built app in [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame#apps)
178
- 2. In the app settings, go to "API Receive" (API接收) and configure:
179
- - **URL**: `https://<your-gateway-host>/plugins/wecom/agent/<accountId>`(例如 `/plugins/wecom/agent/default`);单账号模式也可使用 `/plugins/wecom/agent`
180
- - **Token**: auto-generated or custom
181
- - **EncodingAESKey**: auto-generated or custom
182
- 3. Note down the **CorpID**, **CorpSecret**, **Token**, and **EncodingAESKey**
194
+ 2. Note down the **CorpID**, **CorpSecret** (from app settings), and **AgentId**
195
+ 3. In the app settings, go to "API Receive" (API接收):
196
+ - Note down the **Token** and **EncodingAESKey** (auto-generated or custom)
197
+ - **Do NOT click save yet** — WeCom will verify the callback URL immediately when you save
198
+
199
+ #### Setup Steps
183
200
 
184
- #### Quick Setup
201
+ > **Important**: You must configure the Gateway **before** saving the callback URL in WeCom admin console. WeCom sends a verification request (GET with `echostr`) immediately when you save, and the Gateway needs the `token` and `encodingAESKey` to decrypt and respond correctly.
202
+
203
+ **Step 1: Configure Gateway**
185
204
 
186
205
  ```shell
187
206
  openclaw config set channels.wecom.agent.corpId <YOUR_CORP_ID>
@@ -193,6 +212,13 @@ openclaw config set channels.wecom.enabled true
193
212
  openclaw gateway restart
194
213
  ```
195
214
 
215
+ **Step 2: Save callback URL in WeCom admin console**
216
+
217
+ Go back to the "API Receive" settings and enter the callback URL:
218
+ - **URL**: `https://<your-gateway-host>/plugins/wecom/agent/<accountId>` (e.g. `/plugins/wecom/agent/default`); single-account mode can also use `/plugins/wecom/agent`
219
+
220
+ Now click save — the verification should pass.
221
+
196
222
  #### JSON Configuration
197
223
 
198
224
  ```json
@@ -255,6 +281,7 @@ The plugin uses a **Bot-first, Agent-fallback** strategy for outbound message de
255
281
  This means:
256
282
  - **Agent-only accounts** (no Bot configured) can still send proactive messages, cron deliveries, and broadcasts
257
283
  - **Target formats** like `party:1`, `tag:Ops`, `user:zhangsan` are fully supported in both paths
284
+ - **Media fallback**: when Bot WS is unavailable, media files are downloaded, uploaded to WeCom via Agent API, then sent; if upload fails, falls back to text + URL
258
285
  - No manual switching needed — the plugin handles fallback transparently
259
286
 
260
287
  ### Using Both Modes Together
@@ -320,7 +347,7 @@ Use `accounts` to configure multiple WeCom accounts, each with optional bot and/
320
347
  }
321
348
  ```
322
349
 
323
- > **Note**: In multi-account mode, `routing.failClosedOnDefaultRoute` defaults to `true` — accounts without explicit `bindings` will not fall back to the default agent. Configure bindings for each account:
350
+ > **Note**: In multi-account mode, accounts without explicit `bindings` will not fall back to the default agent. Configure bindings for each account:
324
351
  > ```json
325
352
  > {
326
353
  > "bindings": [
@@ -355,12 +382,6 @@ Dynamic Agent routing automatically creates isolated agents per user or group, e
355
382
  | `channels.wecom.dynamicAgents.groupEnabled` | Enable dynamic agent for group chats | `true` |
356
383
  | `channels.wecom.dynamicAgents.adminUsers` | Admin users (bypass dynamic routing, use main agent) | `[]` |
357
384
 
358
- ### Routing Configuration
359
-
360
- | Config Path | Description | Default |
361
- |---|---|---|
362
- | `channels.wecom.routing.failClosedOnDefaultRoute` | Reject unbound accounts from falling back to default agent | `true` (multi-account) / `false` (single account) |
363
-
364
385
  ---
365
386
 
366
387
  ## 🔒 Access Control
@@ -465,17 +486,19 @@ The plugin supports scheduled message delivery via OpenClaw's built-in Cron serv
465
486
  The `delivery.to` field supports the following target formats:
466
487
 
467
488
  | Format | Target | Example |
468
- |--------|--------|---------|
489
+ |--------|--------|--------|
469
490
  | `party:<id>` | Department (all members) | `party:1` (root dept = all employees) |
470
- | `dept:<id>` | Department (alias) | `dept:5` |
491
+ | `dept:<id>` | Department (alias for party) | `dept:5` |
471
492
  | `tag:<id>` | Tag group | `tag:Ops` |
472
493
  | `user:<id>` | Specific user | `user:zhangsan` |
473
494
  | `group:<id>` | External group chat | `group:wr123abc` |
474
- | `chat:<id>` | Group chat (alias) | `chat:wc456def` |
495
+ | `chat:<id>` | Group chat (alias for group) | `chat:wc456def` |
475
496
  | Pure number | Auto-detected as department | `1` → `party:1` |
476
497
  | `wr...` / `wc...` | Auto-detected as group chat | `wr123` → `chatid` |
477
498
  | Other string | Auto-detected as user | `zhangsan` → `touser` |
478
499
 
500
+ > **Namespace prefixes** (`wecom:`, `qywx:`, `wework:`, `wechatwork:`, `wecom-agent:`) are automatically stripped before parsing.
501
+
479
502
  ### Method 1: CLI (Recommended — takes effect immediately)
480
503
 
481
504
  ```shell
@@ -483,20 +506,26 @@ openclaw cron add \
483
506
  --name "daily-report" \
484
507
  --agent main \
485
508
  --cron "0 9 * * 1-5" \
509
+ --tz "Asia/Shanghai" \
486
510
  --message "Good morning! Here is your daily briefing." \
487
- --deliver \
488
- --delivery-channel wecom \
489
- --delivery-to "party:1"
511
+ --announce \
512
+ --channel wecom \
513
+ --to "party:1"
490
514
  ```
491
515
 
516
+ > **Note**: `--announce` enables delivery mode (broadcasts the AI response to the target chat). Use `--no-deliver` to keep output internal. The deprecated `--deliver` flag is an alias for `--announce`.
517
+
492
518
  Common CLI commands:
493
519
 
494
520
  ```shell
495
521
  openclaw cron list # List all cron jobs
522
+ openclaw cron show <id> # Show job details
496
523
  openclaw cron enable <id> # Enable a job
497
524
  openclaw cron disable <id> # Disable a job
498
525
  openclaw cron remove <id> # Remove a job
499
526
  openclaw cron run <id> # Manually trigger a job
527
+ openclaw cron runs --id <id> # View run history
528
+ openclaw cron edit <id> --message "New prompt" # Edit a job
500
529
  ```
501
530
 
502
531
  ### Method 2: Edit `jobs.json` (requires gateway restart)
@@ -517,10 +546,7 @@ File path: `~/.openclaw/cron/jobs.json`
517
546
  "wakeMode": "now",
518
547
  "payload": {
519
548
  "kind": "agentTurn",
520
- "message": "Generate today's briefing and send it.",
521
- "deliver": true,
522
- "channel": "wecom",
523
- "to": "party:1"
549
+ "message": "Generate today's briefing and send it."
524
550
  },
525
551
  "delivery": {
526
552
  "mode": "announce",
package/dist/index.js CHANGED
@@ -5,57 +5,6 @@ import { setWeComRuntime } from "./src/runtime.js";
5
5
  import { CHANNEL_ID, WEBHOOK_PATHS } from "./src/const.js";
6
6
  import { createWecomAgentWebhookHandler } from "./src/agent/webhook.js";
7
7
  import { handleWecomWebhookRequest } from "./src/webhook/index.js";
8
- // ============================================================================
9
- // 需要自动注入 tools.alsoAllow 的工具名列表
10
- // ============================================================================
11
- const REQUIRED_ALSO_ALLOW_TOOLS = ["wecom_mcp"];
12
- /**
13
- * 确保 tools.alsoAllow 中包含插件所需的工具名。
14
- *
15
- * 逻辑:
16
- * 1. 读取当前配置
17
- * 2. 检查 tools.alsoAllow 中是否已包含所需工具
18
- * 3. 如有缺失,合并写入配置文件
19
- *
20
- * 幂等操作——重复调用不会产生副作用。
21
- * 若 tools.allow 已显式设置(与 alsoAllow 互斥),则跳过注入并打印提示。
22
- */
23
- async function ensureToolsAlsoAllow(api) {
24
- try {
25
- const cfg = api.runtime.config.loadConfig();
26
- const tools = cfg.tools ?? {};
27
- // 如果用户显式配置了 tools.allow(全量白名单),则 alsoAllow 与之互斥,
28
- // 此时不应自动追加 alsoAllow,避免产生校验冲突
29
- if (tools.allow && tools.allow.length > 0) {
30
- const missing = REQUIRED_ALSO_ALLOW_TOOLS.filter((t) => !tools.allow.includes(t));
31
- if (missing.length > 0) {
32
- console.warn(`[wecom] tools.allow 已显式设置,无法自动注入 alsoAllow。` +
33
- `请手动将 ${JSON.stringify(missing)} 加入 tools.allow。`);
34
- }
35
- return;
36
- }
37
- const existing = tools.alsoAllow ?? [];
38
- const missing = REQUIRED_ALSO_ALLOW_TOOLS.filter((t) => !existing.includes(t));
39
- if (missing.length === 0) {
40
- // 所有工具已在白名单中,无需操作
41
- return;
42
- }
43
- const merged = [...existing, ...missing];
44
- const nextConfig = {
45
- ...cfg,
46
- tools: {
47
- ...tools,
48
- alsoAllow: merged,
49
- },
50
- };
51
- await api.runtime.config.writeConfigFile(nextConfig);
52
- console.log(`[wecom] 已自动将 ${JSON.stringify(missing)} 加入 tools.alsoAllow`);
53
- }
54
- catch (err) {
55
- // 配置写入失败不应阻断 Gateway 启动
56
- console.error(`[wecom] 自动注入 tools.alsoAllow 失败:`, err instanceof Error ? err.message : String(err));
57
- }
58
- }
59
8
  const plugin = {
60
9
  id: "wecom-openclaw-plugin",
61
10
  name: "企业微信",
@@ -91,11 +40,6 @@ const plugin = {
91
40
  match: "prefix",
92
41
  });
93
42
  }
94
- // ── Gateway 启动时自动确保 tools.alsoAllow 包含 wecom_mcp ──────────
95
- // 在 gateway_start 阶段检测并写入,保证插件安装/更新后首次启动即生效
96
- // api.on("gateway_start", async () => {
97
- // await ensureToolsAlsoAllow(api);
98
- // });
99
43
  // 注入媒体发送指令和文件大小限制提示词(仅对企业微信 channel 生效)
100
44
  api.on("before_prompt_build", (_event, ctx) => {
101
45
  // 只在企业微信 channel 的会话中注入,避免影响其他 channel 插件
@@ -9,7 +9,7 @@ import { extractMsgType, extractFromUser, extractContent, extractChatId, extract
9
9
  import { sendText, downloadMedia, uploadMedia, sendMedia as sendAgentMedia } from "./api-client.js";
10
10
  import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
11
11
  import { processDynamicRouting } from "../dynamic-routing.js";
12
- import { DEFAULT_MEDIA_MAX_MB } from "../const.js";
12
+ import { CHANNEL_ID, DEFAULT_MEDIA_MAX_MB } from "../const.js";
13
13
  function resolveWecomMediaMaxBytes(config) {
14
14
  return config.channels?.wecom?.media?.maxBytes ?? DEFAULT_MEDIA_MAX_MB * 1024 * 1024;
15
15
  }
@@ -404,9 +404,9 @@ async function processAgentMessage(params) {
404
404
  ConversationLabel: fromLabel,
405
405
  SenderName: fromUser,
406
406
  SenderId: fromUser,
407
- Provider: "wecom",
407
+ Provider: CHANNEL_ID,
408
408
  Surface: "webchat",
409
- OriginatingChannel: "wecom",
409
+ OriginatingChannel: CHANNEL_ID,
410
410
  // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
411
411
  // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
412
412
  // - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
@@ -432,7 +432,13 @@ export const wecomPlugin = {
432
432
  WEBHOOK_PATHS.AGENT_PLUGIN,
433
433
  WEBHOOK_PATHS.AGENT,
434
434
  ]
435
- : [WEBHOOK_PATHS.AGENT_PLUGIN, WEBHOOK_PATHS.AGENT];
435
+ : [
436
+ // 单账号模式:同时注册 /default 路径以支持显式指定
437
+ WEBHOOK_PATHS.AGENT_PLUGIN,
438
+ WEBHOOK_PATHS.AGENT,
439
+ `${WEBHOOK_PATHS.AGENT_PLUGIN}/${DEFAULT_ACCOUNT_ID}`,
440
+ `${WEBHOOK_PATHS.AGENT}/${DEFAULT_ACCOUNT_ID}`,
441
+ ];
436
442
  for (const p of paths) {
437
443
  registerAgentWebhookTarget({
438
444
  agent,
@@ -76,21 +76,11 @@ function isGroupSenderAllowed(params) {
76
76
  * @returns 检查结果,包含是否允许继续处理
77
77
  */
78
78
  export function checkGroupPolicy(params) {
79
- const { chatId, senderId, account, config, runtime } = params;
80
- const wecomConfig = (config.channels?.[CHANNEL_ID] ?? {});
81
- const defaultGroupPolicy = config.channels?.[CHANNEL_ID]?.groupPolicy;
82
- const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
83
- // const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
84
- // providerConfigPresent: config.channels?.[CHANNEL_ID] !== undefined,
85
- // groupPolicy: wecomConfig.groupPolicy,
86
- // defaultGroupPolicy,
87
- // });
88
- // warnMissingProviderGroupPolicyFallbackOnce({
89
- // providerMissingFallbackApplied,
90
- // providerKey: CHANNEL_ID,
91
- // accountId: account.accountId,
92
- // log: (msg) => runtime.log?.(msg),
93
- // });
79
+ const { chatId, senderId, account, runtime } = params;
80
+ // 使用 account.config(已经过多账号合并),而非顶层 config.channels.wecom
81
+ // 避免多账户模式下 groupAllowFrom / groups 等字段取不到账号级配置
82
+ const wecomConfig = account.config;
83
+ const groupPolicy = wecomConfig.groupPolicy ?? "open";
94
84
  const groupAllowFrom = wecomConfig.groupAllowFrom ?? [];
95
85
  const groupAllowed = isWeComGroupAllowed({
96
86
  groupPolicy,
@@ -22,7 +22,7 @@ import { getWeComRuntime } from "./runtime.js";
22
22
  import { getDefaultMediaLocalRoots, resolveStateDir } from "./openclaw-compat.js";
23
23
  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";
24
24
  import { parseMessageContent } from "./message-parser.js";
25
- import { sendWeComReply, sendWeComReplyNonBlocking, StreamExpiredError } from "./message-sender.js";
25
+ import { sendWeComReply, StreamExpiredError } from "./message-sender.js";
26
26
  import { downloadAndSaveImages, downloadAndSaveFiles } from "./media-handler.js";
27
27
  import { uploadAndSendMedia } from "./media-uploader.js";
28
28
  import { maskTemplateCardBlocks } from "./template-card-parser.js";
@@ -32,14 +32,6 @@ import { checkDmPolicy } from "./dm-policy.js";
32
32
  import { setWeComWebSocket, setMessageState, deleteMessageState, setReqIdForChat, 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
- /**
36
- * 去除文本中的 `<think>...</think>` 标签(支持跨行),返回剩余可见文本。
37
- * 用于判断大模型回复中是否包含实际用户可见内容(而非仅有 thinking 推理过程)。
38
- */
39
- function stripThinkTags(text) {
40
- return text;
41
- // return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
42
- }
43
35
  // ============================================================================
44
36
  // 媒体本地路径白名单扩展
45
37
  // ============================================================================
@@ -270,7 +262,7 @@ async function finishThinkingStream(ctx) {
270
262
  const { wsClient, frame, state, runtime } = ctx;
271
263
  const body = frame.body;
272
264
  const chatId = body.chatid || body.from.userid;
273
- const visibleText = stripThinkTags(state.accumulatedText);
265
+ const visibleText = state.accumulatedText;
274
266
  let finishText = state.accumulatedText;
275
267
  if (visibleText) {
276
268
  finishText = state.accumulatedText;
@@ -348,20 +340,6 @@ async function routeAndDispatchMessage(params) {
348
340
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
349
341
  ctx: ctxPayload,
350
342
  cfg: config,
351
- // replyResolver: async (ctx, opts) => {
352
- // const startTime = Date.now();
353
- // const TEN_MINUTES = 7 * 60 * 1000;
354
- //
355
- // console.log('开始输出内容');
356
- //
357
- // while (Date.now() - startTime < TEN_MINUTES) {
358
- // // 每隔一段时间发送一个 block reply
359
- // await opts?.onBlockReply?.({ text: `输出内容 ${new Date().toISOString()}` });
360
- // await new Promise(resolve => setTimeout(resolve, 30 * 1000)); // 每5秒输出一次
361
- // }
362
- //
363
- // return { text: "10分钟输出完成" }; // 最终回复
364
- // },
365
343
  dispatcherOptions: {
366
344
  onReplyStart: async () => {
367
345
  if (!isShowThink && state.streamId && !state.accumulatedText) {
@@ -375,10 +353,10 @@ async function routeAndDispatchMessage(params) {
375
353
  }
376
354
  },
377
355
  deliver: async (payload, info) => {
378
- // runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, text=${payload.text ?? ''}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
379
- // 累积文本(快速指令等非流式回复只走 deliver,不走 onPartialReply)
356
+ runtime.log?.(`[openclaw -> plugin] kind=${info.kind}, text=${payload.text ?? ''}, mediaUrl=${payload.mediaUrl ?? ''}, mediaUrls=${JSON.stringify(payload.mediaUrls ?? [])}`);
357
+ // 累积文本
380
358
  if (payload.text) {
381
- state.accumulatedText = payload.text;
359
+ state.accumulatedText += `${payload.text || ''}`;
382
360
  }
383
361
  // 发送媒体(统一走主动发送)
384
362
  const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : [];
@@ -398,41 +376,30 @@ async function routeAndDispatchMessage(params) {
398
376
  runtime.error?.(`[wecom] sendMediaBatch threw: ${errMsg}`);
399
377
  }
400
378
  }
401
- // 中间帧流式更新已迁移到 onPartialReply(token 级别,更细粒度)
402
- // deliver 只负责文本累积和媒体发送
403
- },
404
- onError: (err, info) => {
405
- runtime.error?.(`[wecom] ${info.kind} reply failed: ${String(err)}`);
406
- },
407
- },
408
- // onPartialReply:token 级别的流式 UI 刷新
409
- // 使用 SDK 的 replyStreamNonBlocking:上一条同 reqId 的消息未收到 ack 时跳过本次发送,
410
- // 避免流式中间帧排队积压导致延迟。丢帧在流式场景下可接受(text 是累积文本,最终由 finishThinkingStream 兜底)。
411
- replyOptions: {
412
- onPartialReply: (data) => {
413
- // 累积文本
414
- if (data.text) {
415
- state.accumulatedText = (data.text || '');
416
- }
417
- if (!state.streamId || !data.text || state.streamExpired)
418
- return;
379
+ // 中间帧:有可见文本时流式更新(流式过期后跳过,等 deliver 完成后主动发送)
419
380
  // 使用 maskTemplateCardBlocks 遮罩正在构建中的模板卡片代码块,
420
381
  // 避免 JSON 源码在流式输出过程中暴露给终端用户
421
- const displayText = maskTemplateCardBlocks(data.text, (...args) => runtime.log?.(...args));
422
- if (displayText !== data.text) {
423
- // runtime.log?.(`[wecom][template-card] Partial-reply masked: original=${data.text.length}chars, masked=${displayText.length}chars`);
424
- }
425
- // fire-and-forget:不阻塞 onPartialReply 回调
426
- sendWeComReplyNonBlocking({
427
- wsClient, frame, text: displayText, runtime,
428
- streamId: state.streamId,
429
- }).catch((err) => {
430
- if (err instanceof StreamExpiredError) {
431
- state.streamExpired = true;
432
- runtime.log?.(`[wecom] Stream expired during partial reply, will fallback to proactive send`);
382
+ if (state.accumulatedText && !state.streamExpired) {
383
+ try {
384
+ const displayText = maskTemplateCardBlocks(state.accumulatedText, (...args) => runtime.log?.(...args));
385
+ // if (displayText !== state.accumulatedText) {
386
+ // runtime.log?.(`[wecom][template-card] Mid-frame masked: original=${state.accumulatedText.length}chars, masked=${displayText.length}chars`);
387
+ // }
388
+ await sendWeComReply({ wsClient, frame, text: displayText, runtime, finish: false, streamId: state.streamId });
433
389
  }
434
- // 其他错误静默忽略,不影响主流程(deliver + finishThinkingStream 兜底)
435
- });
390
+ catch (err) {
391
+ if (err instanceof StreamExpiredError) {
392
+ state.streamExpired = true;
393
+ runtime.log?.(`[wecom] Stream expired during intermediate reply, will fallback to proactive send`);
394
+ }
395
+ else {
396
+ throw err;
397
+ }
398
+ }
399
+ }
400
+ },
401
+ onError: (err, info) => {
402
+ runtime.error?.(`[wecom] ${info.kind} reply failed: ${String(err)}`);
436
403
  },
437
404
  },
438
405
  });
@@ -628,7 +595,6 @@ function createSdkLogger(runtime, accountId) {
628
595
  export async function monitorWeComProvider(options) {
629
596
  const { account, config, runtime, abortSignal, setStatus } = options;
630
597
  runtime.log?.(`[${account.accountId}] [${PLUGIN_VERSION}] Initializing WSClient with SDK...`);
631
- runtime.error?.(`[${account.accountId}] [diag] monitor boot marker: build=20260325-event-debug-1`);
632
598
  // 启动消息状态定期清理
633
599
  startMessageStateCleanup();
634
600
  return new Promise((resolve, reject) => {
@@ -802,7 +768,6 @@ export async function monitorWeComProvider(options) {
802
768
  const eventBody = frame.body;
803
769
  const eventType = eventBody.event?.eventtype;
804
770
  runtime.log?.(`[${account.accountId}] Received event callback: eventtype=${eventType ?? ""}, msgid=${eventBody.msgid ?? ""}`);
805
- runtime.error?.(`[${account.accountId}] [diag] event-listener fired: eventtype=${eventType ?? ""}, msgid=${eventBody.msgid ?? ""}`);
806
771
  if (eventType !== "template_card_event") {
807
772
  return;
808
773
  }
@@ -835,7 +800,6 @@ export async function monitorWeComProvider(options) {
835
800
  }
836
801
  });
837
802
  runtime.log?.(`[${account.accountId}] Event listeners attached: message + event(template_card_event)`);
838
- runtime.error?.(`[${account.accountId}] [diag] listeners-ready marker`);
839
803
  // 启动前预热 reqId 缓存,确保完成后再建立连接,避免 getSync 在预热完成前返回 undefined
840
804
  warmupReqIdStore(account.accountId, (...args) => runtime.log?.(...args))
841
805
  .then((count) => {
@@ -15,6 +15,7 @@ import { monitorState } from "./state.js";
15
15
  import { registerWecomWebhookTarget, hasActiveTargets } from "./target.js";
16
16
  import { startAgentForStream } from "./monitor.js";
17
17
  import { hasMultiAccounts } from "../accounts.js";
18
+ import { DEFAULT_ACCOUNT_ID } from "../openclaw-compat.js";
18
19
  import { getWeComRuntime } from "../runtime.js";
19
20
  // ============================================================================
20
21
  // 全局状态
@@ -51,7 +52,14 @@ function resolveBotRegistrationPaths(params) {
51
52
  WEBHOOK_PATHS.BOT_ALT,
52
53
  ]);
53
54
  }
54
- return uniquePaths([WEBHOOK_PATHS.BOT_PLUGIN, WEBHOOK_PATHS.BOT, WEBHOOK_PATHS.BOT_ALT]);
55
+ // 单账号模式:同时注册 /default 路径以支持显式指定
56
+ return uniquePaths([
57
+ WEBHOOK_PATHS.BOT_PLUGIN,
58
+ WEBHOOK_PATHS.BOT,
59
+ WEBHOOK_PATHS.BOT_ALT,
60
+ `${WEBHOOK_PATHS.BOT_PLUGIN}/${DEFAULT_ACCOUNT_ID}`,
61
+ `${WEBHOOK_PATHS.BOT_ALT}/${DEFAULT_ACCOUNT_ID}`,
62
+ ]);
55
63
  }
56
64
  // ============================================================================
57
65
  // 公共 API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wecom/wecom-openclaw-plugin",
3
- "version": "2026.3.31-beta.4",
3
+ "version": "2026.3.31-beta.5",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",