@yanhaidao/wecom 2.3.10 → 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/src/outbound.ts CHANGED
@@ -2,9 +2,10 @@ import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/pl
2
2
 
3
3
  import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
4
4
  import { WecomAgentDeliveryService } from "./capability/agent/index.js";
5
- import { getWecomRuntime } from "./runtime.js";
5
+ import { getBotWsPushHandle, getWecomRuntime } from "./runtime.js";
6
+ import { resolveScopedWecomTarget } from "./target.js";
6
7
 
7
- function resolveAgentConfigOrThrow(params: {
8
+ function resolveOutboundAccountOrThrow(params: {
8
9
  cfg: ChannelOutboundContext["cfg"];
9
10
  accountId?: string | null;
10
11
  }) {
@@ -26,10 +27,17 @@ function resolveAgentConfigOrThrow(params: {
26
27
  );
27
28
  }
28
29
  }
29
- const account = resolveWecomAccount({
30
+ return resolveWecomAccount({
30
31
  cfg: params.cfg,
31
32
  accountId: params.accountId,
32
- }).agent;
33
+ });
34
+ }
35
+
36
+ function resolveAgentConfigOrThrow(params: {
37
+ cfg: ChannelOutboundContext["cfg"];
38
+ accountId?: string | null;
39
+ }) {
40
+ const account = resolveOutboundAccountOrThrow(params).agent;
33
41
  if (!account?.apiConfigured) {
34
42
  throw new Error(
35
43
  `WeCom outbound requires Agent mode for account=${params.accountId ?? "default"}. Configure channels.wecom.accounts.<accountId>.agent (or legacy channels.wecom.agent).`,
@@ -45,6 +53,81 @@ function resolveAgentConfigOrThrow(params: {
45
53
  return account;
46
54
  }
47
55
 
56
+ function isExplicitAgentTarget(raw: string | undefined): boolean {
57
+ return /^wecom-agent:/i.test(String(raw ?? "").trim());
58
+ }
59
+
60
+ function resolveBotWsChatTarget(params: {
61
+ to: string | undefined;
62
+ accountId: string;
63
+ }): string | undefined {
64
+ const scoped = resolveScopedWecomTarget(params.to, params.accountId);
65
+ if (!scoped) {
66
+ return undefined;
67
+ }
68
+ if (scoped.accountId && scoped.accountId !== params.accountId) {
69
+ throw new Error(
70
+ `WeCom outbound account mismatch: target belongs to account=${scoped.accountId}, current account=${params.accountId}.`,
71
+ );
72
+ }
73
+ if (scoped.target.chatid) {
74
+ return scoped.target.chatid;
75
+ }
76
+ if (scoped.target.touser) {
77
+ return scoped.target.touser;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ function shouldPreferBotWsOutbound(params: {
83
+ cfg: ChannelOutboundContext["cfg"];
84
+ accountId?: string | null;
85
+ to: string | undefined;
86
+ }): { preferred: boolean; accountId: string } {
87
+ const account = resolveOutboundAccountOrThrow({
88
+ cfg: params.cfg,
89
+ accountId: params.accountId,
90
+ });
91
+ return {
92
+ preferred: !isExplicitAgentTarget(params.to) && Boolean(account.bot?.configured && account.bot.primaryTransport === "ws" && account.bot.wsConfigured),
93
+ accountId: account.accountId,
94
+ };
95
+ }
96
+
97
+ async function sendTextViaBotWs(params: {
98
+ cfg: ChannelOutboundContext["cfg"];
99
+ accountId?: string | null;
100
+ to: string | undefined;
101
+ text: string;
102
+ }): Promise<boolean> {
103
+ const { preferred, accountId } = shouldPreferBotWsOutbound(params);
104
+ if (!preferred) {
105
+ return false;
106
+ }
107
+ const chatId = resolveBotWsChatTarget({
108
+ to: params.to,
109
+ accountId,
110
+ });
111
+ if (!chatId) {
112
+ return false;
113
+ }
114
+ const handle = getBotWsPushHandle(accountId);
115
+ if (!handle) {
116
+ throw new Error(
117
+ `WeCom outbound account=${accountId} is configured for Bot WS active push, but no live WS runtime is registered.`,
118
+ );
119
+ }
120
+ if (!handle.isConnected()) {
121
+ throw new Error(
122
+ `WeCom outbound account=${accountId} is configured for Bot WS active push, but the WS transport is not connected.`,
123
+ );
124
+ }
125
+ console.log(`[wecom-outbound] Sending Bot WS active message to target=${String(params.to ?? "")} chatId=${chatId} (len=${params.text.length})`);
126
+ await handle.sendMarkdown(chatId, params.text);
127
+ console.log(`[wecom-outbound] Successfully sent Bot WS active message to ${chatId}`);
128
+ return true;
129
+ }
130
+
48
131
  export const wecomOutbound: ChannelOutboundAdapter = {
49
132
  deliveryMode: "direct",
50
133
  chunkerMode: "text",
@@ -59,9 +142,6 @@ export const wecomOutbound: ChannelOutboundAdapter = {
59
142
  sendText: async ({ cfg, to, text, accountId }: ChannelOutboundContext) => {
60
143
  // signal removed - not supported in current SDK
61
144
 
62
- const agent = resolveAgentConfigOrThrow({ cfg, accountId });
63
- const deliveryService = new WecomAgentDeliveryService(agent);
64
-
65
145
  // 体验优化:/new /reset 的“New session started”回执在 OpenClaw 核心里是英文固定文案,
66
146
  // 且通过 routeReply 走 wecom outbound(Agent 主动发送)。
67
147
  // 在 WeCom“双模式”场景下,这会造成:
@@ -93,12 +173,23 @@ export const wecomOutbound: ChannelOutboundAdapter = {
93
173
 
94
174
  console.log(`[wecom-outbound] Sending text to target=${String(to ?? "")} (len=${outgoingText.length})`);
95
175
 
176
+ let sentViaBotWs = false;
96
177
  try {
97
- await deliveryService.sendText({
178
+ sentViaBotWs = await sendTextViaBotWs({
179
+ cfg,
180
+ accountId,
98
181
  to,
99
182
  text: outgoingText,
100
183
  });
101
- console.log(`[wecom-outbound] Successfully sent text to ${String(to ?? "")}`);
184
+ if (!sentViaBotWs) {
185
+ const agent = resolveAgentConfigOrThrow({ cfg, accountId });
186
+ const deliveryService = new WecomAgentDeliveryService(agent);
187
+ await deliveryService.sendText({
188
+ to,
189
+ text: outgoingText,
190
+ });
191
+ console.log(`[wecom-outbound] Successfully sent Agent text to ${String(to ?? "")}`);
192
+ }
102
193
  } catch (err) {
103
194
  console.error(`[wecom-outbound] Failed to send text to ${String(to ?? "")}:`, err);
104
195
  throw err;
@@ -106,13 +197,17 @@ export const wecomOutbound: ChannelOutboundAdapter = {
106
197
 
107
198
  return {
108
199
  channel: "wecom",
109
- messageId: `agent-${Date.now()}`,
200
+ messageId: `${sentViaBotWs ? "bot-ws" : "agent"}-${Date.now()}`,
110
201
  timestamp: Date.now(),
111
202
  };
112
203
  },
113
204
  sendMedia: async ({ cfg, to, text, mediaUrl, accountId }: ChannelOutboundContext) => {
114
205
  // signal removed - not supported in current SDK
115
206
 
207
+ const { preferred } = shouldPreferBotWsOutbound({ cfg, accountId, to });
208
+ if (preferred) {
209
+ console.log(`[wecom-outbound] Bot WS active push does not support outbound media; falling back to Agent for target=${String(to ?? "")}`);
210
+ }
116
211
  const agent = resolveAgentConfigOrThrow({ cfg, accountId });
117
212
  const deliveryService = new WecomAgentDeliveryService(agent);
118
213
  if (!mediaUrl) {
package/src/runtime.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  export {
2
2
  getAccountRuntimeSnapshot,
3
+ getBotWsPushHandle,
3
4
  getWecomRuntime,
4
5
  registerAccountRuntime,
6
+ registerBotWsPushHandle,
5
7
  setWecomRuntime,
8
+ unregisterBotWsPushHandle,
6
9
  unregisterAccountRuntime,
7
10
  } from "./app/index.js";
@@ -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;
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it, vi, afterEach } from "vitest";
2
+
3
+ import { createBotWsReplyHandle } from "./reply.js";
4
+
5
+ type ReplyHandleParams = Parameters<typeof createBotWsReplyHandle>[0];
6
+
7
+ describe("createBotWsReplyHandle", () => {
8
+ afterEach(() => {
9
+ vi.useRealTimers();
10
+ });
11
+
12
+ it("uses configured placeholder content for immediate ws ack", async () => {
13
+ const replyStream = vi.fn().mockResolvedValue(undefined);
14
+ createBotWsReplyHandle({
15
+ client: {
16
+ replyStream,
17
+ } as unknown as ReplyHandleParams["client"],
18
+ frame: {
19
+ headers: { req_id: "req-1" },
20
+ body: {},
21
+ } as unknown as ReplyHandleParams["frame"],
22
+ accountId: "default",
23
+ placeholderContent: "正在思考...",
24
+ });
25
+
26
+ expect(replyStream).toHaveBeenCalledWith(
27
+ expect.objectContaining({
28
+ headers: { req_id: "req-1" },
29
+ }),
30
+ expect.any(String),
31
+ "正在思考...",
32
+ false,
33
+ );
34
+ });
35
+
36
+ it("keeps placeholder alive until the first real ws chunk arrives", async () => {
37
+ vi.useFakeTimers();
38
+
39
+ const replyStream = vi.fn().mockResolvedValue(undefined);
40
+ const handle = createBotWsReplyHandle({
41
+ client: {
42
+ replyStream,
43
+ } as unknown as ReplyHandleParams["client"],
44
+ frame: {
45
+ headers: { req_id: "req-keepalive" },
46
+ body: {},
47
+ } as unknown as ReplyHandleParams["frame"],
48
+ accountId: "default",
49
+ placeholderContent: "正在思考...",
50
+ });
51
+
52
+ await vi.advanceTimersByTimeAsync(3000);
53
+ expect(replyStream).toHaveBeenCalledTimes(2);
54
+
55
+ await handle.deliver({ text: "最终回复" }, { kind: "final" });
56
+ await vi.advanceTimersByTimeAsync(6000);
57
+
58
+ expect(replyStream).toHaveBeenCalledTimes(3);
59
+ expect(replyStream).toHaveBeenLastCalledWith(
60
+ expect.objectContaining({
61
+ headers: { req_id: "req-keepalive" },
62
+ }),
63
+ expect.any(String),
64
+ "最终回复",
65
+ true,
66
+ );
67
+ });
68
+
69
+ it("does not auto-send placeholder when disabled", () => {
70
+ const replyStream = vi.fn().mockResolvedValue(undefined);
71
+ createBotWsReplyHandle({
72
+ client: {
73
+ replyStream,
74
+ } as unknown as ReplyHandleParams["client"],
75
+ frame: {
76
+ headers: { req_id: "req-2" },
77
+ body: {},
78
+ } as unknown as ReplyHandleParams["frame"],
79
+ accountId: "default",
80
+ autoSendPlaceholder: false,
81
+ });
82
+
83
+ expect(replyStream).not.toHaveBeenCalled();
84
+ });
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
+
133
+ it("swallows expired stream update errors during delivery", async () => {
134
+ const expiredError = {
135
+ headers: { req_id: "req-expired" },
136
+ errcode: 846608,
137
+ errmsg: "stream message update expired (>6 minutes), cannot update",
138
+ };
139
+ const replyStream = vi.fn().mockRejectedValue(expiredError);
140
+ const onFail = vi.fn();
141
+ const handle = createBotWsReplyHandle({
142
+ client: {
143
+ replyStream,
144
+ } as unknown as ReplyHandleParams["client"],
145
+ frame: {
146
+ headers: { req_id: "req-expired" },
147
+ body: {},
148
+ } as unknown as ReplyHandleParams["frame"],
149
+ accountId: "default",
150
+ autoSendPlaceholder: false,
151
+ onFail,
152
+ });
153
+
154
+ await expect(handle.deliver({ text: "最终回复" }, { kind: "final" })).resolves.toBeUndefined();
155
+
156
+ expect(replyStream).toHaveBeenCalledTimes(1);
157
+ expect(onFail).toHaveBeenCalledWith(expiredError);
158
+ });
159
+
160
+ it.each([
161
+ [{ headers: { req_id: "req-invalid" }, errcode: 846605, errmsg: "invalid req_id" }],
162
+ [{ headers: { req_id: "req-expired" }, errcode: 846608, errmsg: "stream message update expired (>6 minutes), cannot update" }],
163
+ ])("does not retry error reply when the ws reply window is already closed", async (error) => {
164
+ const replyStream = vi.fn().mockResolvedValue(undefined);
165
+ const onFail = vi.fn();
166
+ const handle = createBotWsReplyHandle({
167
+ client: {
168
+ replyStream,
169
+ } as unknown as ReplyHandleParams["client"],
170
+ frame: {
171
+ headers: { req_id: String(error.headers.req_id) },
172
+ body: {},
173
+ } as unknown as ReplyHandleParams["frame"],
174
+ accountId: "default",
175
+ autoSendPlaceholder: false,
176
+ onFail,
177
+ });
178
+
179
+ await handle.fail?.(error);
180
+
181
+ expect(replyStream).not.toHaveBeenCalled();
182
+ expect(onFail).toHaveBeenCalledTimes(1);
183
+ });
184
+ });