@yanhaidao/wecom 2.2.3 → 2.2.4

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/onboarding.ts CHANGED
@@ -30,7 +30,7 @@ function setWecomEnabled(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig
30
30
  channels: {
31
31
  ...cfg.channels,
32
32
  wecom: {
33
- ...cfg.channels?.wecom,
33
+ ...(cfg.channels?.wecom ?? {}),
34
34
  enabled,
35
35
  },
36
36
  },
@@ -43,7 +43,7 @@ function setWecomBotConfig(cfg: OpenClawConfig, bot: WecomBotConfig): OpenClawCo
43
43
  channels: {
44
44
  ...cfg.channels,
45
45
  wecom: {
46
- ...cfg.channels?.wecom,
46
+ ...(cfg.channels?.wecom ?? {}),
47
47
  enabled: true,
48
48
  bot,
49
49
  },
@@ -57,7 +57,7 @@ function setWecomAgentConfig(cfg: OpenClawConfig, agent: WecomAgentConfig): Open
57
57
  channels: {
58
58
  ...cfg.channels,
59
59
  wecom: {
60
- ...cfg.channels?.wecom,
60
+ ...(cfg.channels?.wecom ?? {}),
61
61
  enabled: true,
62
62
  agent,
63
63
  },
@@ -40,24 +40,13 @@ describe("wecomOutbound", () => {
40
40
  },
41
41
  };
42
42
 
43
- const chatResult = await wecomOutbound.sendText({
44
- cfg,
45
- to: "wr123",
46
- text: "hello",
47
- } as any);
48
-
49
- expect(api.sendText).toHaveBeenCalledWith(
50
- expect.objectContaining({
51
- chatId: "wr123",
52
- toUser: undefined,
53
- text: "hello",
54
- }),
43
+ // Chat ID (wr/wc) is intentionally NOT supported for Agent outbound.
44
+ await expect(wecomOutbound.sendText({ cfg, to: "wr123", text: "hello" } as any)).rejects.toThrow(
45
+ /不支持向群 chatId 发送/,
55
46
  );
56
- expect(chatResult.channel).toBe("wecom");
57
- expect(chatResult.messageId).toBe("agent-123");
58
-
59
- (api.sendText as any).mockClear();
47
+ expect(api.sendText).not.toHaveBeenCalled();
60
48
 
49
+ // Test: User ID (Default)
61
50
  const userResult = await wecomOutbound.sendText({
62
51
  cfg,
63
52
  to: "userid123",
@@ -67,12 +56,45 @@ describe("wecomOutbound", () => {
67
56
  expect.objectContaining({
68
57
  chatId: undefined,
69
58
  toUser: "userid123",
59
+ toParty: undefined,
60
+ toTag: undefined,
70
61
  text: "hi",
71
62
  }),
72
63
  );
73
64
  expect(userResult.messageId).toBe("agent-123");
74
65
 
66
+ (api.sendText as any).mockClear();
67
+
68
+ // Test: User ID explicit
69
+ await wecomOutbound.sendText({ cfg, to: "user:zhangsan", text: "hi" } as any);
70
+ expect(api.sendText).toHaveBeenCalledWith(
71
+ expect.objectContaining({ toUser: "zhangsan", toParty: undefined }),
72
+ );
73
+
74
+ (api.sendText as any).mockClear();
75
+
76
+ // Test: Party ID (Numeric)
77
+ await wecomOutbound.sendText({ cfg, to: "1001", text: "hi party" } as any);
78
+ expect(api.sendText).toHaveBeenCalledWith(
79
+ expect.objectContaining({ toUser: undefined, toParty: "1001" }),
80
+ );
81
+
82
+ (api.sendText as any).mockClear();
83
+
84
+ // Test: Party ID Explicit
85
+ await wecomOutbound.sendText({ cfg, to: "party:2002", text: "hi party 2" } as any);
86
+ expect(api.sendText).toHaveBeenCalledWith(
87
+ expect.objectContaining({ toUser: undefined, toParty: "2002" }),
88
+ );
89
+
90
+ (api.sendText as any).mockClear();
91
+
92
+ // Test: Tag ID Explicit
93
+ await wecomOutbound.sendText({ cfg, to: "tag:1", text: "hi tag" } as any);
94
+ expect(api.sendText).toHaveBeenCalledWith(
95
+ expect.objectContaining({ toUser: undefined, toTag: "1" }),
96
+ );
97
+
75
98
  now.mockRestore();
76
99
  });
77
100
  });
78
-
package/src/outbound.ts CHANGED
@@ -4,11 +4,7 @@ import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } f
4
4
  import { resolveWecomAccounts } from "./config/index.js";
5
5
  import { getWecomRuntime } from "./runtime.js";
6
6
 
7
- function normalizeWecomOutboundTarget(raw: string | undefined): string | undefined {
8
- const trimmed = raw?.trim() ?? "";
9
- if (!trimmed) return undefined;
10
- return trimmed.replace(/^(wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
11
- }
7
+ import { resolveWecomTarget } from "./target.js";
12
8
 
13
9
  function resolveAgentConfigOrThrow(cfg: ChannelOutboundContext["cfg"]) {
14
10
  const account = resolveWecomAccounts(cfg).agent;
@@ -17,8 +13,8 @@ function resolveAgentConfigOrThrow(cfg: ChannelOutboundContext["cfg"]) {
17
13
  "WeCom outbound requires Agent mode. Configure channels.wecom.agent (corpId/corpSecret/agentId/token/encodingAESKey).",
18
14
  );
19
15
  }
20
- // DEBUG: 输出使用的 Agent 配置信息
21
- console.log(`[wecom-outbound] Using agent config: corpId=${account.corpId}, agentId=${account.agentId}, corpSecret=${account.corpSecret?.slice(0, 8)}...`);
16
+ // 注意:不要在日志里输出 corpSecret 等敏感信息
17
+ console.log(`[wecom-outbound] Using agent config: corpId=${account.corpId}, agentId=${account.agentId}`);
22
18
  return account;
23
19
  }
24
20
 
@@ -33,24 +29,39 @@ export const wecomOutbound: ChannelOutboundAdapter = {
33
29
  return [text];
34
30
  }
35
31
  },
36
- sendText: async ({ cfg, to, text, signal }: ChannelOutboundContext) => {
37
- if (signal?.aborted) {
38
- throw new Error("Outbound delivery aborted");
39
- }
32
+ sendText: async ({ cfg, to, text }: ChannelOutboundContext) => {
33
+ // signal removed - not supported in current SDK
40
34
 
41
35
  const agent = resolveAgentConfigOrThrow(cfg);
42
- const targetId = normalizeWecomOutboundTarget(to);
43
- if (!targetId) {
44
- throw new Error("WeCom outbound requires a target (userid or chatid).");
36
+ const target = resolveWecomTarget(to);
37
+ if (!target) {
38
+ throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
45
39
  }
46
40
 
47
- const isChat = /^(wr|wc)/i.test(targetId);
48
- await sendAgentText({
49
- agent,
50
- toUser: isChat ? undefined : targetId,
51
- chatId: isChat ? targetId : undefined,
52
- text,
53
- });
41
+ const { touser, toparty, totag, chatid } = target;
42
+ if (chatid) {
43
+ throw new Error(
44
+ `企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${chatid})。` +
45
+ `该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
46
+ `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
47
+ );
48
+ }
49
+ console.log(`[wecom-outbound] Sending text to target=${JSON.stringify(target)} (len=${text.length})`);
50
+
51
+ try {
52
+ await sendAgentText({
53
+ agent,
54
+ toUser: touser,
55
+ toParty: toparty,
56
+ toTag: totag,
57
+ chatId: chatid,
58
+ text,
59
+ });
60
+ console.log(`[wecom-outbound] Successfully sent text to ${JSON.stringify(target)}`);
61
+ } catch (err) {
62
+ console.error(`[wecom-outbound] Failed to send text to ${JSON.stringify(target)}:`, err);
63
+ throw err;
64
+ }
54
65
 
55
66
  return {
56
67
  channel: "wecom",
@@ -58,15 +69,20 @@ export const wecomOutbound: ChannelOutboundAdapter = {
58
69
  timestamp: Date.now(),
59
70
  };
60
71
  },
61
- sendMedia: async ({ cfg, to, text, mediaUrl, signal }: ChannelOutboundContext) => {
62
- if (signal?.aborted) {
63
- throw new Error("Outbound delivery aborted");
64
- }
72
+ sendMedia: async ({ cfg, to, text, mediaUrl }: ChannelOutboundContext) => {
73
+ // signal removed - not supported in current SDK
65
74
 
66
75
  const agent = resolveAgentConfigOrThrow(cfg);
67
- const targetId = normalizeWecomOutboundTarget(to);
68
- if (!targetId) {
69
- throw new Error("WeCom outbound requires a target (userid or chatid).");
76
+ const target = resolveWecomTarget(to);
77
+ if (!target) {
78
+ throw new Error("WeCom outbound requires a target (userid, partyid, tagid or chatid).");
79
+ }
80
+ if (target.chatid) {
81
+ throw new Error(
82
+ `企业微信(WeCom)Agent 主动发送不支持向群 chatId 发送(chatId=${target.chatid})。` +
83
+ `该路径在实际环境中经常失败(例如 86008:无权限访问该会话/会话由其他应用创建)。` +
84
+ `请改为发送给用户(userid / user:xxx),或由 Bot 模式在群内交付。`,
85
+ );
70
86
  }
71
87
  if (!mediaUrl) {
72
88
  throw new Error("WeCom outbound requires mediaUrl.");
@@ -121,20 +137,30 @@ export const wecomOutbound: ChannelOutboundAdapter = {
121
137
  filename,
122
138
  });
123
139
 
124
- const isChat = /^(wr|wc)/i.test(targetId);
125
- await sendAgentMedia({
126
- agent,
127
- toUser: isChat ? undefined : targetId,
128
- chatId: isChat ? targetId : undefined,
129
- mediaId,
130
- mediaType,
131
- ...(mediaType === "video" && text?.trim()
132
- ? {
133
- title: text.trim().slice(0, 64),
134
- description: text.trim().slice(0, 512),
135
- }
136
- : {}),
137
- });
140
+ const { touser, toparty, totag, chatid } = target;
141
+ console.log(`[wecom-outbound] Sending media (${mediaType}) to ${JSON.stringify(target)} (mediaId=${mediaId})`);
142
+
143
+ try {
144
+ await sendAgentMedia({
145
+ agent,
146
+ toUser: touser,
147
+ toParty: toparty,
148
+ toTag: totag,
149
+ chatId: chatid,
150
+ mediaId,
151
+ mediaType,
152
+ ...(mediaType === "video" && text?.trim()
153
+ ? {
154
+ title: text.trim().slice(0, 64),
155
+ description: text.trim().slice(0, 512),
156
+ }
157
+ : {}),
158
+ });
159
+ console.log(`[wecom-outbound] Successfully sent media to ${JSON.stringify(target)}`);
160
+ } catch (err) {
161
+ console.error(`[wecom-outbound] Failed to send media to ${JSON.stringify(target)}:`, err);
162
+ throw err;
163
+ }
138
164
 
139
165
  return {
140
166
  channel: "wecom",
@@ -75,3 +75,11 @@ export function extractContent(msg: WecomAgentInboundMessage): string {
75
75
  return `[${msgType || "未知消息类型"}]`;
76
76
  }
77
77
  }
78
+
79
+ /**
80
+ * 从 XML 中提取媒体 ID (Image, Voice, Video)
81
+ * 根据官方文档,MediaId 在 Agent 回调中直接位于根节点
82
+ */
83
+ export function extractMediaId(msg: WecomAgentInboundMessage): string | undefined {
84
+ return msg.MediaId ? String(msg.MediaId) : undefined;
85
+ }
package/src/target.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * WeCom Target Resolver (企业微信目标解析器)
3
+ *
4
+ * 解析 OpenClaw 的 `to` 字段(原始目标字符串),将其转换为企业微信支持的具体接收对象。
5
+ * 支持显式前缀 (party:, tag: 等) 和基于规则的启发式推断。
6
+ *
7
+ * **关于“目标发送”与“消息记录”的对应关系 (Target vs Inbound):**
8
+ * - **发送 (Outbound)**: 支持一对多广播 (Party/Tag)。
9
+ * 例如发送给 `party:1`,消息会触达该部门下所有成员。
10
+ * - **接收 (Inbound)**: 总是来自具体的 **用户 (User)** 或 **群聊 (Chat)**。
11
+ * 当成员回复部门广播消息时,可以视为一个新的单聊会话或在该成员的现有单聊中回复。
12
+ * 因此,Outbound Target (如 Party) 与 Inbound Source (User) 不需要也不可能 1:1 强匹配。
13
+ * 广播是“发后即忘” (Fire-and-Forget) 的通知模式,而回复是具体的会话模式。
14
+ */
15
+
16
+ export interface WecomTarget {
17
+ touser?: string;
18
+ toparty?: string;
19
+ totag?: string;
20
+ chatid?: string;
21
+ }
22
+
23
+ /**
24
+ * Parses a raw target string into a WeComTarget object.
25
+ * 解析原始目标字符串为 WeComTarget 对象。
26
+ *
27
+ * 逻辑:
28
+ * 1. 移除标准命名空间前缀 (wecom:, qywx: 等)。
29
+ * 2. 检查显式类型前缀 (party:, tag:, group:, user:)。
30
+ * 3. 启发式回退 (无前缀时):
31
+ * - 以 "wr" 或 "wc" 开头 -> Chat ID (群聊)
32
+ * - 纯数字 -> Party ID (部门)
33
+ * - 其他 -> User ID (用户)
34
+ *
35
+ * @param raw - The raw target string (e.g. "party:1", "zhangsan", "wecom:wr123")
36
+ */
37
+ export function resolveWecomTarget(raw: string | undefined): WecomTarget | undefined {
38
+ if (!raw?.trim()) return undefined;
39
+
40
+ // 1. Remove standard namespace prefixes (移除标准命名空间前缀)
41
+ let clean = raw.trim().replace(/^(wecom|wechatwork|wework|qywx):/i, "");
42
+
43
+ // 2. Explicit Type Prefixes (显式类型前缀)
44
+ if (/^party:/i.test(clean)) {
45
+ return { toparty: clean.replace(/^party:/i, "").trim() };
46
+ }
47
+ if (/^dept:/i.test(clean)) {
48
+ return { toparty: clean.replace(/^dept:/i, "").trim() };
49
+ }
50
+ if (/^tag:/i.test(clean)) {
51
+ return { totag: clean.replace(/^tag:/i, "").trim() };
52
+ }
53
+ if (/^group:/i.test(clean)) {
54
+ return { chatid: clean.replace(/^group:/i, "").trim() };
55
+ }
56
+ if (/^chat:/i.test(clean)) {
57
+ return { chatid: clean.replace(/^chat:/i, "").trim() };
58
+ }
59
+ if (/^user:/i.test(clean)) {
60
+ return { touser: clean.replace(/^user:/i, "").trim() };
61
+ }
62
+
63
+ // 3. Heuristics (启发式规则)
64
+
65
+ // Chat ID typically starts with 'wr' or 'wc'
66
+ // 群聊 ID 通常以 'wr' (外部群) 或 'wc' 开头
67
+ if (/^(wr|wc)/i.test(clean)) {
68
+ return { chatid: clean };
69
+ }
70
+
71
+ // Pure digits are likely Department IDs (Parties)
72
+ // 纯数字优先被视为部门 ID (Parties),方便运维配置 (如 "1" 代表根部门)
73
+ // 如果必须要发送给纯数字 ID 的用户,请使用显式前缀 "user:1001"
74
+ if (/^\d+$/.test(clean)) {
75
+ return { toparty: clean };
76
+ }
77
+
78
+ // Default to User (默认为用户)
79
+ return { touser: clean };
80
+ }
@@ -2,7 +2,7 @@
2
2
  * WeCom 账号类型定义
3
3
  */
4
4
 
5
- import type { WecomBotConfig, WecomAgentConfig, WecomDmConfig } from "./config.js";
5
+ import type { WecomBotConfig, WecomAgentConfig, WecomDmConfig, WecomNetworkConfig } from "./config.js";
6
6
 
7
7
  /**
8
8
  * 解析后的 Bot 账号
@@ -22,6 +22,8 @@ export type ResolvedBotAccount = {
22
22
  receiveId: string;
23
23
  /** 原始配置 */
24
24
  config: WecomBotConfig;
25
+ /** 网络配置(来自 channels.wecom.network) */
26
+ network?: WecomNetworkConfig;
25
27
  };
26
28
 
27
29
  /**
@@ -46,6 +48,8 @@ export type ResolvedAgentAccount = {
46
48
  encodingAESKey: string;
47
49
  /** 原始配置 */
48
50
  config: WecomAgentConfig;
51
+ /** 网络配置(来自 channels.wecom.network) */
52
+ network?: WecomNetworkConfig;
49
53
  };
50
54
 
51
55
  /**
@@ -4,6 +4,8 @@
4
4
 
5
5
  /** DM 策略配置 - 与其他渠道保持一致,仅用 allowFrom */
6
6
  export type WecomDmConfig = {
7
+ /** DM 策略: 'open' 允许所有人, 'pairing' 需要配对, 'allowlist' 仅允许列表, 'disabled' 禁用 */
8
+ policy?: 'open' | 'pairing' | 'allowlist' | 'disabled';
7
9
  /** 允许的用户列表,为空表示允许所有人 */
8
10
  allowFrom?: Array<string | number>;
9
11
  };
@@ -21,6 +23,11 @@ export type WecomNetworkConfig = {
21
23
  timeoutMs?: number;
22
24
  retries?: number;
23
25
  retryDelayMs?: number;
26
+ /**
27
+ * 出口代理(用于企业可信 IP 固定出口场景)。
28
+ * 示例: "http://proxy.company.local:3128"
29
+ */
30
+ egressProxyUrl?: string;
24
31
  };
25
32
 
26
33
  /**
@@ -0,0 +1,9 @@
1
+
2
+ declare global {
3
+ var Buffer: any;
4
+ namespace NodeJS {
5
+ interface Timeout { }
6
+ }
7
+ }
8
+
9
+ export { };
@@ -6,6 +6,17 @@
6
6
  /**
7
7
  * Bot 模式入站消息基础结构 (JSON)
8
8
  */
9
+ /**
10
+ * **WecomBotInboundBase (Bot 入站消息基类)**
11
+ *
12
+ * Bot 模式下 JSON 格式回调的基础字段。
13
+ * @property msgid 消息 ID
14
+ * @property aibotid 机器人 ID
15
+ * @property chattype 会话类型: "single" | "group"
16
+ * @property chatid 群聊 ID (仅群组时存在)
17
+ * @property response_url 下行回复 URL (用于被动响应转主动推送)
18
+ * @property from 发送者信息
19
+ */
9
20
  export type WecomBotInboundBase = {
10
21
  msgid?: string;
11
22
  aibotid?: string;
@@ -14,6 +25,8 @@ export type WecomBotInboundBase = {
14
25
  response_url?: string;
15
26
  from?: { userid?: string; corpid?: string };
16
27
  msgtype?: string;
28
+ /** 附件数量 (部分消息存在) */
29
+ attachment_count?: number;
17
30
  };
18
31
 
19
32
  export type WecomBotInboundText = WecomBotInboundBase & {
@@ -42,10 +55,19 @@ export type WecomBotInboundEvent = WecomBotInboundBase & {
42
55
  };
43
56
  };
44
57
 
58
+ /**
59
+ * **WecomInboundQuote (引用消息)**
60
+ *
61
+ * 消息中引用的原始内容(如回复某条消息)。
62
+ * 支持引用文本、图片、混合类型、语音、文件等。
63
+ */
45
64
  export type WecomInboundQuote = {
46
65
  msgtype?: "text" | "image" | "mixed" | "voice" | "file";
66
+ /** 引用文本内容 */
47
67
  text?: { content?: string };
68
+ /** 引用图片 URL */
48
69
  image?: { url?: string };
70
+ /** 引用混合消息 (图文) */
49
71
  mixed?: {
50
72
  msg_item?: Array<{
51
73
  msgtype: "text" | "image";
@@ -53,7 +75,9 @@ export type WecomInboundQuote = {
53
75
  image?: { url?: string };
54
76
  }>;
55
77
  };
78
+ /** 引用语音 */
56
79
  voice?: { content?: string };
80
+ /** 引用文件 */
57
81
  file?: { url?: string };
58
82
  };
59
83
 
@@ -67,6 +91,12 @@ export type WecomBotInboundMessage =
67
91
  /**
68
92
  * Agent 模式入站消息结构 (解析自 XML)
69
93
  */
94
+ /**
95
+ * **WecomAgentInboundMessage (Agent 入站消息)**
96
+ *
97
+ * Agent 模式下解析自 XML 的扁平化消息结构。
98
+ * 键名保持 PascalCase (如 `ToUserName`)。
99
+ */
70
100
  export type WecomAgentInboundMessage = {
71
101
  ToUserName?: string;
72
102
  FromUserName?: string;
@@ -103,6 +133,17 @@ export type WecomAgentInboundMessage = {
103
133
  /**
104
134
  * 模板卡片类型
105
135
  */
136
+ /**
137
+ * **WecomTemplateCard (模板卡片)**
138
+ *
139
+ * 复杂的交互式卡片结构。
140
+ * @property card_type 卡片类型: "text_notice" | "news_notice" | "button_interaction" ...
141
+ * @property source 来源信息
142
+ * @property main_title 主标题
143
+ * @property sub_title_text 副标题
144
+ * @property horizontal_content_list 水平排列的键值列表
145
+ * @property button_list 按钮列表
146
+ */
106
147
  export type WecomTemplateCard = {
107
148
  card_type: "text_notice" | "news_notice" | "button_interaction" | "vote_interaction" | "multiple_interaction";
108
149
  source?: { icon_url?: string; desc?: string; desc_color?: number };