@yanhaidao/wecom 2.3.12 → 2.3.13

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
@@ -102,6 +102,13 @@
102
102
 
103
103
  > 项目保持高频迭代,核心改进一览:
104
104
 
105
+ #### v2.3.13(2026-03-13)
106
+
107
+ - 🛠 **[重要修复]** `Bot WS` 现在会把“引用 + 提问”中的引用内容一起带入 Agent 上下文,不再只保留用户当前这句提问。
108
+ - 🌊 **[重要修复]** `Bot WS` 流式回复改为按“累计全文刷新”发送,修复企业微信客户端里长回答断断续续、像被拆成多段的问题。
109
+ - 🧩 `Bot WS` 这次显式对齐企业微信 `stream.id` 的刷新语义:后续更新会覆盖为当前完整内容,而不是只发送最新增量片段。
110
+ - ✅ 新增 `bot-ws` 引用上下文与累计流式发送回归测试,避免后续重构回退。
111
+
105
112
  #### v2.3.12(2026-03-12)
106
113
 
107
114
  - 🛠 **[重要修复]** Bot WS 流式回复超 6 分钟后的 `846608 stream message update expired` 现在被识别为终态错误,不再导致进程退出。
@@ -109,6 +116,8 @@
109
116
  - 🚀 Bot WS 模式下主动文本消息优先走 WS 长连接;Agent 仅兜底文件/媒体或未启用 WS 的场景。
110
117
  - 🧯 `sdk-adapter` 为 WebSocket frame 异步处理补上显式兜底捕获,漏网异常记录为 runtime issue 而非崩溃。
111
118
  - ⏱ 回复窗口过期时占位符保活立即停止。
119
+ - 🛠 **[重要修复]** Bot WS 模式下接收图片/文件现在使用消息体独立 `aeskey` 解密,修复之前保存密文导致 `Failed to optimize image` 的问题。
120
+ - 🛠 **[重要修复]** 解决 Agent 模式下纯数字 UserID 被误判为部门 ID 导致的 81013 错误。在 `wecom-agent:` 作用域下,纯数字目标现在优先解析为用户。
112
121
 
113
122
  #### v2.3.11(2026-03-11)
114
123
 
@@ -473,6 +482,9 @@ openclaw channels status --deep
473
482
  **Q5: 为什么发视频给 Bot 没反应?**
474
483
  > **A:** 官方 Bot 接口**不支持接收视频**。如果您需要处理视频内容,必须配置 Agent 小微应用,由于 Agent 下行具备富媒体流承接功能,本插件会自动从底层拦截将其解码并传输给底层大模型看。
475
484
 
485
+ **Q6: 支持个人微信吗?**
486
+ > **A:** 支持企业微信场景下的“微信插件入口”(个人微信扫码进入企业应用对话),这不等同于“个人微信网页版协议”。您可以在个人微信中直接与企业号/应用对话,无需打开企业微信 App。
487
+
476
488
  ---
477
489
 
478
490
  <a id="sec-9"></a>
@@ -7,10 +7,12 @@
7
7
  - 【6 分钟过期修复】🛠 **[重要修复]** `Bot WS` 现在会把企业微信 `846608 stream message update expired (>6 minutes)` 识别为回复窗口已结束的终态错误,不再继续把该错误向上抛出导致进程退出。
8
8
  - 【主动推送路由收敛】🚀 **[重要修复]** 当账号实际运行在 `Bot WS` 模式时,定时消息、heartbeat 和其他主动文本发送现在优先走 `wsClient.sendMessage()`,不再默认绕去 Agent。
9
9
  - 【WS/Agent 职责切分】🧩 `Agent` 现在主要承担两类兜底:一是账号没有启用 `Bot WS` 时的主动消息发送;二是 Bot 两种模式都不支持的文件/媒体发送。
10
+ - 【UserID 纯数字解析修复】🛠 **[重要修复]** 解决 Agent 模式下纯数字 UserID 被误判为部门 ID 导致的图片发送失败(81013 错误)。在 `wecom-agent:` 作用域下,纯数字目标现在优先解析为用户。
10
11
  - 【未处理拒绝隔离】🧯 `sdk-adapter` 为每个 WebSocket frame 的异步处理补上显式兜底捕获;即使后续再出现漏网异常,也会记录到 runtime issue,而不是变成 `unhandledRejection` 直接带崩 OpenClaw。
11
12
  - 【占位保活收敛】⏱ 当回复窗口已经过期时,WS 占位符保活会立即停止,避免过期后继续发送流式更新。
12
13
  - 【Ack 超时兜底】⏱ SDK 5 秒回执超时 (`Reply ack timeout`) 现在也被识别为终态错误,超时后立即停止占位保活并走 `onFail` 回调,不再产生 `unhandledRejection`。
13
14
  - 【回归测试补齐】✅ 新增针对 `846608` 过期更新和 `frame handler` reject 的回归测试,确保此类异常保持非致命。
15
+ - 【Bot WS 图片/文件解密修复】🛠 **[重要修复]** `Bot WS` 模式下接收到的图片和文件现在会使用消息体中的独立 `aeskey` 进行 AES-256-CBC 解密,修复之前直接保存密文导致 `Failed to optimize image` 的问题。`media-service.ts` 新增 `downloadEncryptedMedia()` 方法,`normalizeFirstAttachment()` 自动检测 `aesKey` 并走解密路径。
14
16
 
15
17
  ## 验证结果
16
18
  - `pnpm exec vitest -c extensions/wecom/vitest.config.ts extensions/wecom/src/transport/bot-ws/reply.test.ts extensions/wecom/src/transport/bot-ws/sdk-adapter.test.ts`
@@ -0,0 +1,19 @@
1
+ # OpenClaw WeCom 插件 v2.3.13 变更简报
2
+
3
+ > [!TIP]
4
+ > **Bot WS 引用上下文与流式展示修复版本**:`v2.3.13` 重点补齐企业微信 `Bot WS` 对引用消息的上下文注入,并修复长回答在客户端里显示为“断断续续碎片”的问题。
5
+
6
+ ## 2026-03-13(v2.3.13)
7
+ - 【引用上下文补齐】🛠 **[重要修复]** `Bot WS` 现在会复用与 `Bot Webhook` 一致的入站正文拼装逻辑。用户通过“引用 + 提问”触发机器人时,引用内容会一并进入 Agent 上下文,不再只保留当前这句提问。
8
+ - 【WS 流式刷新修复】🌊 **[重要修复]** `Bot WS replyStream` 现在按“累计全文刷新”发送,而不是只发送最新增量片段,修复企业微信客户端中长回答显示断断续续、像被拆成多块的问题。
9
+ - 【协议语义对齐】🧩 这次调整显式对齐企业微信 `stream.id` 的刷新语义:同一条流式消息的后续更新会覆盖为“当前完整内容”,从而保持最终展示稳定、可连续阅读。
10
+ - 【回归测试补齐】✅ 新增 `bot-ws` 引用上下文与累计流式发送测试,避免后续重构再次把引用消息或流式展示打回退。
11
+
12
+ ## 验证结果
13
+ - `pnpm exec vitest run --config extensions/wecom/vitest.config.ts extensions/wecom/src/transport/bot-ws/inbound.test.ts extensions/wecom/src/transport/bot-ws/reply.test.ts extensions/wecom/src/transport/bot-ws/sdk-adapter.test.ts`
14
+ - `pnpm exec tsc -p extensions/wecom/tsconfig.json --noEmit`
15
+
16
+ ## 升级提示
17
+ - 无需新增配置,升级到 `v2.3.13` 后自动生效。
18
+ - 如果企业微信 `WS` 推送里的 `quote.text.content` 仍然是 `[该消息类型暂不能展示]`,说明上游没有下发真实引用原文;OpenClaw 会把该占位文本带入上下文,但无法恢复企业微信未提供的原始内容。
19
+ - 如果你之前观察到长回复在企业微信里呈现为“分段断裂”或“后段覆盖前段”,升级后会改为同一条流式消息持续刷新完整内容。
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.3.12",
3
+ "version": "2.3.13",
4
4
  "type": "module",
5
- "description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持 Agent 主动发消息与多账号接入",
5
+ "description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持加密媒体解密、Agent 主动发消息与多账号接入",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/YanHaidao/wecom.git"
@@ -566,32 +566,31 @@ async function processAgentMessage(params: {
566
566
  }
567
567
  return;
568
568
  }
569
-
570
- const ctxPayload = core.channel.reply.finalizeInboundContext({
571
- Body: body,
572
- RawBody: finalContent,
573
- CommandBody: finalContent,
574
- Attachments: attachments.length > 0 ? attachments : undefined,
575
- From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
576
- To: `wecom:${peerId}`,
577
- SessionKey: route.sessionKey,
578
- AccountId: route.accountId,
579
- ChatType: isGroup ? "group" : "direct",
580
- ConversationLabel: fromLabel,
581
- SenderName: fromUser,
582
- SenderId: fromUser,
583
- Provider: "wecom",
584
- Surface: "webchat",
585
- OriginatingChannel: "wecom",
586
- // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
587
- // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
588
- // - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
589
- OriginatingTo: buildAgentSessionTarget(fromUser, agent.accountId),
590
- CommandAuthorized: authz.commandAuthorized ?? true,
591
- MediaPath: mediaPath,
592
- MediaType: mediaType,
593
- MediaUrl: mediaPath,
594
- });
569
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
570
+ Body: body,
571
+ RawBody: finalContent,
572
+ CommandBody: finalContent,
573
+ Attachments: attachments.length > 0 ? attachments : undefined,
574
+ From: isGroup ? `wecom:group:${peerId}` : `wecom:user:${fromUser}`,
575
+ To: `wecom:user:${peerId}`,
576
+ SessionKey: route.sessionKey,
577
+ AccountId: route.accountId,
578
+ ChatType: isGroup ? "group" : "direct",
579
+ ConversationLabel: fromLabel,
580
+ SenderName: fromUser,
581
+ SenderId: fromUser,
582
+ Provider: "wecom",
583
+ Surface: "webchat",
584
+ OriginatingChannel: "wecom",
585
+ // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
586
+ // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
587
+ // - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
588
+ OriginatingTo: buildAgentSessionTarget(fromUser, agent.accountId),
589
+ CommandAuthorized: authz.commandAuthorized ?? true,
590
+ MediaPath: mediaPath,
591
+ MediaType: mediaType,
592
+ MediaUrl: mediaPath,
593
+ });
595
594
 
596
595
  // 记录会话
597
596
  await core.channel.session.recordInboundSession({
@@ -51,7 +51,8 @@ export function generateAgentId(chatType: "dm" | "group", peerId: string, accoun
51
51
  export function buildAgentSessionTarget(userId: string, accountId?: string): string {
52
52
  const normalizedUserId = String(userId).trim();
53
53
  const sanitizedAccountId = sanitizeDynamicIdPart(accountId ?? "default") || "default";
54
- return `wecom-agent:${sanitizedAccountId}:${normalizedUserId}`;
54
+ // Always use explicit user: prefix to avoid ambiguity with numeric party IDs
55
+ return `wecom-agent:${sanitizedAccountId}:user:${normalizedUserId}`;
55
56
  }
56
57
 
57
58
  /**
@@ -2,6 +2,7 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
2
 
3
3
  import type { NormalizedMediaAttachment } from "./media-types.js";
4
4
  import type { UnifiedInboundEvent } from "../types/index.js";
5
+ import { decryptWecomMediaWithMeta } from "../media.js";
5
6
 
6
7
  export class WecomMediaService {
7
8
  constructor(private readonly core: PluginRuntime) {}
@@ -15,6 +16,21 @@ export class WecomMediaService {
15
16
  };
16
17
  }
17
18
 
19
+ /**
20
+ * Download and decrypt WeCom AES-encrypted media.
21
+ * Bot-ws: each message carries a unique per-URL aeskey in the message body.
22
+ * Bot-webhook: uses the account-level EncodingAESKey.
23
+ * Both use AES-256-CBC with PKCS#7 padding (32-byte block), IV = key[:16].
24
+ */
25
+ async downloadEncryptedMedia(params: { url: string; aesKey: string }): Promise<NormalizedMediaAttachment> {
26
+ const decrypted = await decryptWecomMediaWithMeta(params.url, params.aesKey);
27
+ return {
28
+ buffer: decrypted.buffer,
29
+ contentType: decrypted.sourceContentType,
30
+ filename: decrypted.sourceFilename,
31
+ };
32
+ }
33
+
18
34
  async saveInboundAttachment(event: UnifiedInboundEvent, attachment: NormalizedMediaAttachment): Promise<string> {
19
35
  const saved = await this.core.channel.media.saveMediaBuffer(
20
36
  attachment.buffer,
@@ -31,6 +47,10 @@ export class WecomMediaService {
31
47
  if (!first?.remoteUrl) {
32
48
  return undefined;
33
49
  }
50
+ // Bot-ws media is AES-encrypted; use decryption when aesKey is present
51
+ if (first.aesKey) {
52
+ return this.downloadEncryptedMedia({ url: first.remoteUrl, aesKey: first.aesKey });
53
+ }
34
54
  return this.downloadRemoteMedia({ url: first.remoteUrl });
35
55
  }
36
56
  }
package/src/target.ts CHANGED
@@ -35,12 +35,12 @@ export interface ScopedWecomTarget {
35
35
  * 2. 检查显式类型前缀 (party:, tag:, group:, user:)。
36
36
  * 3. 启发式回退 (无前缀时):
37
37
  * - 以 "wr" 或 "wc" 开头 -> Chat ID (群聊)
38
- * - 纯数字 -> Party ID (部门)
38
+ * - 纯数字 -> 默认 Party ID (部门);如果 preferUserForDigits 为 true 则视为 User ID
39
39
  * - 其他 -> User ID (用户)
40
40
  *
41
41
  * @param raw - The raw target string (e.g. "party:1", "zhangsan", "wecom:wr123")
42
42
  */
43
- export function resolveWecomTarget(raw: string | undefined): WecomTarget | undefined {
43
+ export function resolveWecomTarget(raw: string | undefined, options?: { preferUserForDigits?: boolean }): WecomTarget | undefined {
44
44
  if (!raw?.trim()) return undefined;
45
45
 
46
46
  // 1. Remove standard namespace prefixes (移除标准命名空间前缀)
@@ -78,6 +78,9 @@ export function resolveWecomTarget(raw: string | undefined): WecomTarget | undef
78
78
  // 纯数字优先被视为部门 ID (Parties),方便运维配置 (如 "1" 代表根部门)
79
79
  // 如果必须要发送给纯数字 ID 的用户,请使用显式前缀 "user:1001"
80
80
  if (/^\d+$/.test(clean)) {
81
+ if (options?.preferUserForDigits) {
82
+ return { touser: clean };
83
+ }
81
84
  return { toparty: clean };
82
85
  }
83
86
 
@@ -93,7 +96,9 @@ export function resolveScopedWecomTarget(raw: string | undefined, defaultAccount
93
96
  if (agentScoped) {
94
97
  const accountId = agentScoped[1]?.trim() || defaultAccountId;
95
98
  const rawTarget = agentScoped[2]?.trim() || "";
96
- const target = resolveWecomTarget(rawTarget);
99
+ // Agent scoped targets are almost always users in a conversation context.
100
+ // In this scope, we prefer treating numeric IDs as User IDs to avoid 81013 errors.
101
+ const target = resolveWecomTarget(rawTarget, { preferUserForDigits: true });
97
102
  return target ? { accountId, target, rawTarget } : undefined;
98
103
  }
99
104
 
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { mapBotWsFrameToInboundEvent } from "./inbound.js";
4
+ import type { ResolvedBotAccount } from "../../types/index.js";
5
+
6
+ function createBotAccount(): ResolvedBotAccount {
7
+ return {
8
+ accountId: "haidao",
9
+ configured: true,
10
+ primaryTransport: "ws",
11
+ wsConfigured: true,
12
+ webhookConfigured: false,
13
+ config: {},
14
+ ws: {
15
+ botId: "bot-id",
16
+ secret: "secret",
17
+ },
18
+ token: "",
19
+ encodingAESKey: "",
20
+ receiveId: "",
21
+ botId: "bot-id",
22
+ secret: "secret",
23
+ };
24
+ }
25
+
26
+ describe("mapBotWsFrameToInboundEvent", () => {
27
+ it("includes quote content in text events", () => {
28
+ const event = mapBotWsFrameToInboundEvent({
29
+ account: createBotAccount(),
30
+ frame: {
31
+ cmd: "aibot_msg_callback",
32
+ headers: { req_id: "req-1" },
33
+ body: {
34
+ msgid: "msg-1",
35
+ msgtype: "text",
36
+ chattype: "group",
37
+ chatid: "group-1",
38
+ from: { userid: "user-1" },
39
+ text: { content: "@daodao 这个线索价值" },
40
+ quote: {
41
+ msgtype: "text",
42
+ text: { content: "原始引用内容" },
43
+ },
44
+ },
45
+ },
46
+ });
47
+
48
+ expect(event.text).toBe("@daodao 这个线索价值\n\n> 原始引用内容");
49
+ });
50
+ });
@@ -1,6 +1,12 @@
1
1
  import type { BaseMessage, EventMessage, WsFrame } from "@wecom/aibot-node-sdk";
2
2
 
3
- import type { ResolvedBotAccount, UnifiedInboundEvent, WecomInboundKind } from "../../types/index.js";
3
+ import { buildInboundBody } from "../bot-webhook/message-shape.js";
4
+ import type {
5
+ ResolvedBotAccount,
6
+ UnifiedInboundEvent,
7
+ WecomBotInboundMessage,
8
+ WecomInboundKind,
9
+ } from "../../types/index.js";
4
10
 
5
11
  function resolveInboundKind(message: BaseMessage | EventMessage): WecomInboundKind {
6
12
  if (message.msgtype === "event") {
@@ -24,26 +30,10 @@ function resolveInboundKind(message: BaseMessage | EventMessage): WecomInboundKi
24
30
  }
25
31
 
26
32
  function resolveEventText(message: BaseMessage | EventMessage, account: ResolvedBotAccount): string {
27
- if (message.msgtype === "text") {
28
- return message.text?.content ?? "";
29
- }
30
- if (message.msgtype === "voice") {
31
- return message.voice?.content ?? "";
32
- }
33
- if (message.msgtype === "mixed") {
34
- return (message.mixed?.msg_item ?? [])
35
- .map((item: { msgtype: string; text?: { content?: string } }) =>
36
- item.msgtype === "text" ? item.text?.content ?? "" : "[image]",
37
- )
38
- .filter(Boolean)
39
- .join("\n");
40
- }
41
- if (message.msgtype === "image") {
42
- return "[image]";
43
- }
44
- if (message.msgtype === "file") {
45
- return "[file]";
33
+ if (message.msgtype !== "event") {
34
+ return buildInboundBody(message as WecomBotInboundMessage);
46
35
  }
36
+
47
37
  const event = message as EventMessage;
48
38
  if (event.event?.eventtype === "enter_chat" && account.config.welcomeText) {
49
39
  return account.config.welcomeText;
@@ -83,6 +83,53 @@ describe("createBotWsReplyHandle", () => {
83
83
  expect(replyStream).not.toHaveBeenCalled();
84
84
  });
85
85
 
86
+ it("sends cumulative content for block streaming updates", async () => {
87
+ const replyStream = vi.fn().mockResolvedValue(undefined);
88
+ const handle = createBotWsReplyHandle({
89
+ client: {
90
+ replyStream,
91
+ } as unknown as ReplyHandleParams["client"],
92
+ frame: {
93
+ headers: { req_id: "req-blocks" },
94
+ body: {},
95
+ } as unknown as ReplyHandleParams["frame"],
96
+ accountId: "default",
97
+ autoSendPlaceholder: false,
98
+ });
99
+
100
+ await handle.deliver({ text: "第一段" }, { kind: "block" });
101
+ await handle.deliver({ text: "第二段" }, { kind: "block" });
102
+ await handle.deliver({ text: "收尾" }, { kind: "final" });
103
+
104
+ expect(replyStream).toHaveBeenNthCalledWith(
105
+ 1,
106
+ expect.objectContaining({
107
+ headers: { req_id: "req-blocks" },
108
+ }),
109
+ expect.any(String),
110
+ "第一段",
111
+ false,
112
+ );
113
+ expect(replyStream).toHaveBeenNthCalledWith(
114
+ 2,
115
+ expect.objectContaining({
116
+ headers: { req_id: "req-blocks" },
117
+ }),
118
+ expect.any(String),
119
+ "第一段\n第二段",
120
+ false,
121
+ );
122
+ expect(replyStream).toHaveBeenNthCalledWith(
123
+ 3,
124
+ expect.objectContaining({
125
+ headers: { req_id: "req-blocks" },
126
+ }),
127
+ expect.any(String),
128
+ "第一段\n第二段\n收尾",
129
+ true,
130
+ );
131
+ });
132
+
86
133
  it("swallows expired stream update errors during delivery", async () => {
87
134
  const expiredError = {
88
135
  headers: { req_id: "req-expired" },
@@ -44,6 +44,7 @@ export function createBotWsReplyHandle(params: {
44
44
  onFail?: (error: unknown) => void;
45
45
  }): ReplyHandle {
46
46
  let streamId: string | undefined;
47
+ let accumulatedText = "";
47
48
  const resolveStreamId = () => {
48
49
  streamId ||= generateReqId("stream");
49
50
  return streamId;
@@ -107,9 +108,27 @@ export function createBotWsReplyHandle(params: {
107
108
  const text = payload.text?.trim();
108
109
  if (!text) return;
109
110
 
111
+ if (info.kind === "block") {
112
+ accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
113
+ }
114
+
115
+ const outboundText =
116
+ info.kind === "final"
117
+ ? accumulatedText
118
+ ? text
119
+ ? `${accumulatedText}\n${text}`
120
+ : accumulatedText
121
+ : text
122
+ : accumulatedText || text;
123
+
110
124
  settleStream();
111
125
  try {
112
- await params.client.replyStream(params.frame, resolveStreamId(), text, info.kind === "final");
126
+ await params.client.replyStream(
127
+ params.frame,
128
+ resolveStreamId(),
129
+ outboundText,
130
+ info.kind === "final",
131
+ );
113
132
  } catch (error) {
114
133
  if (isTerminalReplyError(error)) {
115
134
  params.onFail?.(error);