@yanhaidao/wecom 2.3.9 → 2.3.10

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
@@ -30,8 +30,10 @@
30
30
  ## 💡 核心价值:为什么选择本插件?
31
31
 
32
32
  ### 🎉 重大特性一览
33
- 1. **无需域名,极低门槛**:全面支持基于 WebSocket 的长连接(Bot WS)模式接入企业微信机器人,**彻底打通无公网 IP、无备案域名的内网服务器**与企微的实时对话桥梁!
34
- 2. **主动发消息,能力全覆盖**:基于 Agent 模式,全面支持**主动触达**,轻松实现早报定时任务、服务器异常报警、自动每日总结。
33
+ 1. **防断连黑科技** (v2.3.10 新增):针对 DeepSeek R1 等长时间 <think> 的推理模型,首创 **4秒前置保活 ACK** 机制。彻底断绝模型限速或慢思考导致的 WebSocket 5秒超时重试风暴与消息卡死现象。
34
+ 2. **无需域名,极低门槛**:全面支持基于 WebSocket 的长连接(Bot WS)模式接入企业微信机器人,**彻底打通无公网 IP、无备案域名的内网服务器**与企微的实时对话桥梁!
35
+ 3. **主动发消息,能力全覆盖**:基于 Agent 模式,全面支持**主动触达**,轻松实现早报定时任务、服务器异常报警、自动每日总结。
36
+ 4. **向导自动路由自动适配** (v2.3.10 新增):在终端执行 `openclaw channels add` 时,若是单企微账号接入,将**静默触发自动 Agent 路由绑定**,丝滑跳过全局冗杂的路由分配步骤。
35
37
 
36
38
  ### 🔧 全新统一运行时架构 (Unified Runtime)
37
39
  插件现已采用全新解耦架构:
@@ -154,7 +156,7 @@ openclaw channels add
154
156
  },
155
157
  "agent": {
156
158
  "corpId": "CORP_ID",
157
- "corpSecret": "CORP_SECRET",
159
+ "agentSecret": "AGENT_SECRET",
158
160
  "agentId": 1000001,
159
161
  "token": "AGENT_TOKEN",
160
162
  "encodingAESKey": "AGENT_AES_KEY",
@@ -189,6 +191,10 @@ openclaw channels add
189
191
  }
190
192
  ```
191
193
 
194
+ 说明:
195
+ - 新配置推荐使用 `agent.agentSecret`
196
+ - 历史配置里的 `agent.corpSecret` 仍兼容读取,但后续文档统一使用 `agentSecret`
197
+
192
198
  ### 1.3 高级网络配置(公网出口代理)
193
199
  如果您的服务器使用 **动态 IP** (如家庭宽带、内网穿透) 或 **无公网 IP**,首先使用Bot模式的ws接入方式。
194
200
 
@@ -422,18 +428,25 @@ openclaw channels status --deep
422
428
 
423
429
  ## 七、📮 联系我 与 版本协议
424
430
 
425
- ### v2.3.9 更新日志(2026-03-09)
431
+ ### 最近更新
432
+
433
+ 近期保持高频迭代,最近版本如下:
434
+
435
+ #### v2.3.10(2026-03-10)
436
+
437
+ - onboarding 默认收敛为 `Bot + WS + 开放私聊`。
438
+ - 修复 `Bot WS` 长文本双重回复问题。
439
+ - 修复首个自定义接入标识时报 `default not found`。
440
+ - Agent 新配置统一使用 `agentSecret`。
426
441
 
427
- 本次版本重点收敛为“默认更易用、排障更直接、回调更稳定”:
442
+ #### v2.3.9(2026-03-09)
428
443
 
429
- - **默认 Bot WebSocket 引导**:onboarding 默认写入 `bot.ws`,新用户无需域名即可完成机器人接入。
430
- - **中文化配置体验**:企业微信插件自管账号创建与私聊策略提示,尽量避免流程中重复出现英文术语。
431
- - **Bot WS 流式输出修复**:长连接模式支持块级流式交付,不再只保留最终结果。
432
- - **Webhook / Agent 回调稳定性增强**:统一 HTTP 分发链路,并补齐默认账号显式路径,便于 Bot 与 Agent 回调稳定命中。
433
- - **Agent 排障日志增强**:补充原始 callback、解密摘要、发送请求与响应日志,更容易定位“能接收但不能回复”的问题。
434
- - **配置收敛**:移除 `bot.enabled` / `agent.enabled` 冗余字段,减少误导。
444
+ - Bot 默认接入改为 `WebSocket`,无需域名更易上手。
445
+ - 完善中文 onboarding,减少重复提示。
446
+ - 恢复 `Bot WS` 流式输出能力。
447
+ - 增强 Agent 回调与发送日志,排障更直接。
435
448
 
436
- 详细版本记录见 `changelog/v2.3.9.md`。
449
+ 详细版本记录见 `changelog/v2.3.10.md` 与 `changelog/v2.3.9.md`。
437
450
 
438
451
  微信交流群(扫码入群):
439
452
 
@@ -0,0 +1,17 @@
1
+ # OpenClaw WeCom 插件 v2.3.10 变更简报
2
+
3
+ > [!TIP]
4
+ > **默认更易用、修复更直接的版本。**
5
+
6
+ ## 2026-03-10(v2.3.10)
7
+ - 【消息防丢修复】🐛 **[重要修复]** 针对由于模型 API 限速或超长思考(如 DeepSeek R1)导致的企微 WebSocket 5秒超时断连问题,引入了 4 秒前置保活机制(自动下发"⏳ 正在思考中..."),彻底阻断了因为模型响应慢而造成的“消息卡死不再回复”。
8
+ - 【双重回复修复】🐛 **[重要修复]** 修复 `Bot WS` 长文本场景下可能被超时截断并触发兜底通道进行二次重复回复的边界异常。
9
+ - 【向导自动路由】✨ **[体验升级]** 重构了企业微信的渠道交互配置向导。在单账号场景下将静默触发自动路由绑定,丝滑跳过 OpenClaw 全局冗长的 Agent 路由分配询问。
10
+ - 【账号兜底修复】🧩 修复企业微信 onboarding 在首个账号非字面量 `default` 时,后续流程报 `WeCom account "default" not found` 的问题。
11
+ - 【默认选项收敛】🚀 onboarding 的默认回车选项已变更为更普适的 `Bot` 模式、`WS` 接入和 `开放模式` 策略。
12
+ - 【字段命名收敛】📝 Agent 新配置统一推荐使用 `agentSecret`,历史 `corpSecret` 保持兼容读取,保障平滑升级。
13
+ - 【文档与提示精简】📘 README、向导交互文案与示例结构已全面统一为更符合直觉的精简说明。
14
+
15
+ ## 验证结果
16
+ - `bunx vitest run extensions/wecom/src/onboarding.test.ts extensions/wecom/src/channel.meta.test.ts`
17
+ - `pnpm build`
@@ -31,7 +31,7 @@ openclaw config set channels.wecom.bot.dm.allowFrom '["*"]'
31
31
 
32
32
  ```bash
33
33
  openclaw config set channels.wecom.agent.corpId "YOUR_CORP_ID"
34
- openclaw config set channels.wecom.agent.corpSecret "YOUR_CORP_SECRET"
34
+ openclaw config set channels.wecom.agent.agentSecret "YOUR_AGENT_SECRET"
35
35
  openclaw config set channels.wecom.agent.agentId 1000001
36
36
  openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
37
37
  openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
@@ -71,7 +71,7 @@ openclaw channels status
71
71
 
72
72
  "agent": {
73
73
  "corpId": "YOUR_CORP_ID",
74
- "corpSecret": "YOUR_CORP_SECRET",
74
+ "agentSecret": "YOUR_AGENT_SECRET",
75
75
  "agentId": 1000001,
76
76
  "token": "YOUR_CALLBACK_TOKEN",
77
77
  "encodingAESKey": "YOUR_CALLBACK_AES_KEY",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.3.9",
3
+ "version": "2.3.10",
4
4
  "type": "module",
5
5
  "description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持 Agent 主动发消息与多账号接入",
6
6
  "repository": {
@@ -13,8 +13,11 @@ describe("resolveWecomAccount", () => {
13
13
  "acct-a": {
14
14
  enabled: true,
15
15
  bot: {
16
- token: "token-a",
17
- encodingAESKey: "aes-a",
16
+ primaryTransport: "webhook",
17
+ webhook: {
18
+ token: "token-a",
19
+ encodingAESKey: "aes-a",
20
+ },
18
21
  },
19
22
  },
20
23
  },
@@ -35,4 +38,38 @@ describe("resolveWecomAccount", () => {
35
38
  expect(account.enabled).toBe(true);
36
39
  expect(account.configured).toBe(true);
37
40
  });
41
+
42
+ it("treats literal default as an alias for configured default account", () => {
43
+ const account = resolveWecomAccount({ cfg, accountId: "default" });
44
+ expect(account.accountId).toBe("acct-a");
45
+ expect(account.enabled).toBe(true);
46
+ expect(account.configured).toBe(true);
47
+ });
48
+
49
+ it("accepts agentSecret for fresh configs and normalizes it for runtime use", () => {
50
+ const agentCfg: OpenClawConfig = {
51
+ channels: {
52
+ wecom: {
53
+ enabled: true,
54
+ defaultAccount: "acct-agent",
55
+ accounts: {
56
+ "acct-agent": {
57
+ enabled: true,
58
+ agent: {
59
+ corpId: "corp-id",
60
+ agentSecret: "agent-secret",
61
+ agentId: 1000001,
62
+ token: "token",
63
+ encodingAESKey: "1234567890123456789012345678901234567890123",
64
+ },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ } as OpenClawConfig;
70
+
71
+ const account = resolveWecomAccount({ cfg: agentCfg });
72
+ expect(account.agent?.apiConfigured).toBe(true);
73
+ expect(account.agent?.corpSecret).toBe("agent-secret");
74
+ });
38
75
  });
@@ -73,14 +73,15 @@ function resolveAgentAccount(
73
73
  ): ResolvedAgentAccount {
74
74
  const agentId = toNumber(config.agentId);
75
75
  const callbackConfigured = Boolean(config.token && config.encodingAESKey);
76
- const apiConfigured = Boolean(config.corpId && config.corpSecret && agentId);
76
+ const normalizedAgentSecret = config.agentSecret?.trim() || config.corpSecret?.trim() || "";
77
+ const apiConfigured = Boolean(config.corpId && normalizedAgentSecret && agentId);
77
78
  return {
78
79
  accountId,
79
80
  configured: callbackConfigured || apiConfigured,
80
81
  callbackConfigured,
81
82
  apiConfigured,
82
83
  corpId: config.corpId,
83
- corpSecret: config.corpSecret,
84
+ corpSecret: normalizedAgentSecret,
84
85
  agentId,
85
86
  token: config.token,
86
87
  encodingAESKey: config.encodingAESKey,
@@ -113,6 +114,15 @@ function toResolvedAccount(params: {
113
114
  };
114
115
  }
115
116
 
117
+ function createMissingResolvedAccount(accountId: string): ResolvedWecomAccount {
118
+ return {
119
+ accountId,
120
+ enabled: false,
121
+ configured: false,
122
+ config: {},
123
+ };
124
+ }
125
+
116
126
  export function detectMode(config: WecomConfig | undefined): ResolvedMode {
117
127
  if (!config || config.enabled === false) return "disabled";
118
128
  if (config.accounts && Object.keys(config.accounts).length > 0) {
@@ -260,12 +270,24 @@ export function resolveWecomAccount(params: {
260
270
  accountId?: string | null;
261
271
  }): ResolvedWecomAccount {
262
272
  const resolved = resolveWecomAccounts(params.cfg);
263
- const accountId = params.accountId?.trim() || resolved.defaultAccountId;
264
- const account = resolved.accounts[accountId];
265
- if (!account) {
266
- throw new Error(`WeCom account "${accountId}" not found.`);
273
+ const explicitAccountId = params.accountId?.trim();
274
+ const accountId = explicitAccountId || resolved.defaultAccountId;
275
+ const direct = resolved.accounts[accountId];
276
+ if (direct) {
277
+ return direct;
267
278
  }
268
- return account;
279
+
280
+ // Treat the literal "default" as an alias for the configured default account.
281
+ // This keeps generic onboarding flows working even when the first WeCom account
282
+ // was created under a custom id like "haidao" instead of a literal "default".
283
+ if (explicitAccountId === DEFAULT_ACCOUNT_ID) {
284
+ const fallback = resolved.accounts[resolved.defaultAccountId];
285
+ if (fallback) {
286
+ return fallback;
287
+ }
288
+ }
289
+
290
+ return createMissingResolvedAccount(accountId);
269
291
  }
270
292
 
271
293
  export function isWecomEnabled(cfg: OpenClawConfig): boolean {
@@ -67,13 +67,18 @@ const botSchema = z
67
67
  const agentSchema = z
68
68
  .object({
69
69
  corpId: z.string(),
70
- corpSecret: z.string(),
70
+ agentSecret: z.string().optional(),
71
+ corpSecret: z.string().optional(),
71
72
  agentId: z.union([z.number(), z.string()]).optional(),
72
73
  token: z.string(),
73
74
  encodingAESKey: z.string(),
74
75
  welcomeText: z.string().optional(),
75
76
  dm: dmSchema,
76
77
  })
78
+ .refine((value) => Boolean(value.agentSecret?.trim() || value.corpSecret?.trim()), {
79
+ path: ["agentSecret"],
80
+ message: "agentSecret 不能为空",
81
+ })
77
82
  .optional();
78
83
 
79
84
  const dynamicAgentsSchema = z
@@ -168,7 +168,7 @@ describe("wecom onboarding", () => {
168
168
  it("uses plugin-owned chinese account selection and no generic dm adapter", async () => {
169
169
  const prompter = createPrompter({
170
170
  select: vi.fn(async ({ message }: { message: string }) => {
171
- if (message === "请选择企业微信账号:") {
171
+ if (message === "请选择企业微信接入标识(英文):") {
172
172
  return "__new__";
173
173
  }
174
174
  if (message === "请选择您要配置的接入模式:") {
@@ -180,7 +180,7 @@ describe("wecom onboarding", () => {
180
180
  throw new Error(`Unexpected select prompt: ${message}`);
181
181
  }) as WizardPrompter["select"],
182
182
  text: vi.fn(async ({ message }: { message: string }) => {
183
- if (message === "请输入新的企业微信账号 ID:") {
183
+ if (message === "请输入新的企业微信接入标识(英文):") {
184
184
  return "HaiDao";
185
185
  }
186
186
  if (message === "请输入 BotId(机器人 ID):") {
@@ -213,7 +213,56 @@ describe("wecom onboarding", () => {
213
213
  const noteText = (prompter.note as ReturnType<typeof vi.fn>).mock.calls
214
214
  .map(([message]) => String(message))
215
215
  .join("\n");
216
- expect(noteText).toContain("账号 ID 已规范化为:haidao");
216
+ expect(noteText).toContain("接入标识已规范化为:haidao");
217
217
  expect(wecomOnboardingAdapter.dmPolicy).toBeUndefined();
218
218
  });
219
+
220
+ it("writes agentSecret for fresh agent onboarding", async () => {
221
+ const prompter = createPrompter({
222
+ select: vi.fn(async ({ message }: { message: string }) => {
223
+ if (message === "请选择您要配置的接入模式:") {
224
+ return "agent";
225
+ }
226
+ if (message === "请选择私聊 (DM) 访问策略:") {
227
+ return "open";
228
+ }
229
+ throw new Error(`Unexpected select prompt: ${message}`);
230
+ }) as WizardPrompter["select"],
231
+ text: vi.fn(async ({ message }: { message: string }) => {
232
+ if (message === "请输入 CorpID (企业ID):") {
233
+ return "corp-id";
234
+ }
235
+ if (message === "请输入 AgentID (应用ID):") {
236
+ return "1000001";
237
+ }
238
+ if (message === "请输入应用 Secret:") {
239
+ return "agent-secret";
240
+ }
241
+ if (message === "请输入 Token (回调令牌):") {
242
+ return "callback-token";
243
+ }
244
+ if (message === "请输入 EncodingAESKey (回调加密密钥):") {
245
+ return "1234567890123456789012345678901234567890123";
246
+ }
247
+ if (message === "欢迎语 (可选):") {
248
+ return "欢迎使用智能助手";
249
+ }
250
+ throw new Error(`Unexpected text prompt: ${message}`);
251
+ }) as WizardPrompter["text"],
252
+ });
253
+
254
+ const result = await wecomOnboardingAdapter.configure({
255
+ cfg: {} as OpenClawConfig,
256
+ runtime: createRuntime(),
257
+ prompter,
258
+ options: {},
259
+ accountOverrides: {},
260
+ shouldPromptAccountIds: false,
261
+ forceAllowFrom: false,
262
+ });
263
+
264
+ const agent = result.cfg.channels?.wecom?.accounts?.default?.agent;
265
+ expect(agent?.agentSecret).toBe("agent-secret");
266
+ expect(agent?.corpSecret).toBeUndefined();
267
+ });
219
268
  });
package/src/onboarding.ts CHANGED
@@ -250,26 +250,26 @@ async function resolveOnboardingAccountId(params: {
250
250
  if (!override && params.shouldPromptAccountIds) {
251
251
  const existingIds = listWecomAccountIds(params.cfg);
252
252
  const choice = await params.prompter.select({
253
- message: "请选择企业微信账号:",
253
+ message: "请选择企业微信接入标识(英文):",
254
254
  options: [
255
255
  ...existingIds.map((id) => ({
256
256
  value: id,
257
- label: id === DEFAULT_ACCOUNT_ID ? "default(主账号)" : id,
257
+ label: id === DEFAULT_ACCOUNT_ID ? "default(默认标识)" : id,
258
258
  })),
259
- { value: "__new__", label: "新增账号" },
259
+ { value: "__new__", label: "新增接入标识" },
260
260
  ],
261
261
  initialValue: accountId,
262
262
  });
263
263
  if (choice === "__new__") {
264
264
  const entered = await params.prompter.text({
265
- message: "请输入新的企业微信账号 ID:",
266
- validate: (value: string | undefined) => (value?.trim() ? undefined : "账号 ID 不能为空"),
265
+ message: "请输入新的企业微信接入标识(英文):",
266
+ validate: (value: string | undefined) => (value?.trim() ? undefined : "接入标识不能为空"),
267
267
  });
268
268
  const normalized = normalizeAccountId(String(entered));
269
269
  if (String(entered).trim() !== normalized) {
270
270
  await params.prompter.note(
271
- `账号 ID 已规范化为:${normalized}`,
272
- "企业微信账号",
271
+ `接入标识已规范化为:${normalized}`,
272
+ "企业微信接入标识",
273
273
  );
274
274
  }
275
275
  accountId = normalized;
@@ -325,7 +325,7 @@ async function promptMode(prompter: WizardPrompter): Promise<WecomMode> {
325
325
  hint: "Bot 默认 WS 易上手,Agent 负责应用回调、主动推送和媒体发送",
326
326
  },
327
327
  ],
328
- initialValue: "both",
328
+ initialValue: "bot",
329
329
  });
330
330
  return choice as WecomMode;
331
331
  }
@@ -433,10 +433,10 @@ async function configureAgentMode(
433
433
  ).trim();
434
434
  const agentId = Number(agentIdStr);
435
435
 
436
- const corpSecret = String(
436
+ const agentSecret = String(
437
437
  await prompter.text({
438
- message: "请输入 Secret (应用密钥):",
439
- validate: (value: string | undefined) => (value?.trim() ? undefined : "Secret 不能为空"),
438
+ message: "请输入应用 Secret:",
439
+ validate: (value: string | undefined) => (value?.trim() ? undefined : "应用 Secret 不能为空"),
440
440
  }),
441
441
  ).trim();
442
442
 
@@ -478,7 +478,7 @@ async function configureAgentMode(
478
478
 
479
479
  const agentConfig: WecomAgentConfig = {
480
480
  corpId,
481
- corpSecret,
481
+ agentSecret,
482
482
  agentId,
483
483
  token,
484
484
  encodingAESKey,
@@ -506,7 +506,7 @@ async function promptDmPolicy(
506
506
  { value: "open", label: "开放模式", hint: "任何人可发起" },
507
507
  { value: "disabled", label: "禁用私聊", hint: "不接受私聊消息" },
508
508
  ],
509
- initialValue: "pairing",
509
+ initialValue: "open",
510
510
  });
511
511
 
512
512
  const policy = policyChoice as "pairing" | "allowlist" | "open" | "disabled";
@@ -560,7 +560,7 @@ async function showSummary(cfg: OpenClawConfig, prompter: WizardPrompter, accoun
560
560
  lines.push(" 出站能力: Agent API(主动发送 / 补送 / 媒体)");
561
561
  }
562
562
 
563
- lines.push(` 账号 ID: ${accountId}`);
563
+ lines.push(` 接入标识: ${accountId}`);
564
564
  lines.push(" 运维检查: openclaw channels status --deep");
565
565
  lines.push(" 关键日志: [wecom-runtime] [wecom-ws] [wecom-http] [wecom-agent-delivery]");
566
566
 
@@ -16,7 +16,7 @@ describe("wecomOutbound", () => {
16
16
  text: "caption",
17
17
  mediaUrl: "https://example.com/media.png",
18
18
  } as any),
19
- ).rejects.toThrow(/account "default" not found/i);
19
+ ).rejects.toThrow(/requires Agent mode for account=default/i);
20
20
  });
21
21
 
22
22
  it("throws explicit error when outbound accountId does not exist", async () => {
@@ -91,7 +91,6 @@ export function mapBotWsFrameToInboundEvent(params: {
91
91
  transport: "bot-ws",
92
92
  accountId: account.accountId,
93
93
  reqId: frame.headers.req_id,
94
- passiveWindowMs: 5_000,
95
94
  raw: {
96
95
  transport: "bot-ws",
97
96
  command: frame.cmd,
@@ -15,6 +15,16 @@ export function createBotWsReplyHandle(params: {
15
15
  return streamId;
16
16
  };
17
17
 
18
+ let ackSent = false;
19
+ const ackTimer = setTimeout(() => {
20
+ if (ackSent) return;
21
+ ackSent = true;
22
+ params.client.replyStream(params.frame, resolveStreamId(), "⏳ 正在思考中...\n\n", false)
23
+ .catch(() => { /* ignore */ });
24
+ }, 4000);
25
+
26
+ const cleanupTimer = () => clearTimeout(ackTimer);
27
+
18
28
  return {
19
29
  context: {
20
30
  transport: "bot-ws",
@@ -29,17 +39,22 @@ export function createBotWsReplyHandle(params: {
29
39
  },
30
40
  },
31
41
  deliver: async (payload: ReplyPayload, info) => {
32
- if (payload.isReasoning) {
33
- return;
34
- }
42
+ if (payload.isReasoning) return;
43
+
35
44
  const text = payload.text?.trim();
36
- if (!text) {
37
- return;
45
+ if (!text) return;
46
+
47
+ if (!ackSent) {
48
+ cleanupTimer();
49
+ ackSent = true;
38
50
  }
51
+
39
52
  await params.client.replyStream(params.frame, resolveStreamId(), text, info.kind === "final");
40
53
  params.onDeliver?.();
41
54
  },
42
55
  fail: async (error: unknown) => {
56
+ cleanupTimer();
57
+ ackSent = true;
43
58
  const message = error instanceof Error ? error.message : String(error);
44
59
  await params.client.replyStream(params.frame, resolveStreamId(), `WeCom WS reply failed: ${message}`, true);
45
60
  params.onFail?.(error);
@@ -49,7 +49,12 @@ export type WecomBotConfig = {
49
49
 
50
50
  export type WecomAgentConfig = {
51
51
  corpId: string;
52
- corpSecret: string;
52
+ agentSecret?: string;
53
+ /**
54
+ * Deprecated compatibility alias for old configs.
55
+ * New configs should use `agentSecret`.
56
+ */
57
+ corpSecret?: string;
53
58
  agentId?: number | string;
54
59
  token: string;
55
60
  encodingAESKey: string;