@yanhaidao/wecom 2.0.2 → 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/channel.ts CHANGED
@@ -6,15 +6,15 @@ import type {
6
6
  import {
7
7
  buildChannelConfigSchema,
8
8
  DEFAULT_ACCOUNT_ID,
9
- deleteAccountFromConfigSection,
10
- formatPairingApproveHint,
11
9
  setAccountEnabledInConfigSection,
12
10
  } from "openclaw/plugin-sdk";
13
11
 
14
- import { listWecomAccountIds, resolveDefaultWecomAccountId, resolveWecomAccount } from "./accounts.js";
15
- import { WecomConfigSchema } from "./config-schema.js";
16
- import type { ResolvedWecomAccount } from "./types.js";
17
- import { registerWecomWebhookTarget } from "./monitor.js";
12
+ import { resolveWecomAccounts } from "./config/index.js";
13
+ import { WecomConfigSchema } from "./config/index.js";
14
+ import type { ResolvedAgentAccount, ResolvedBotAccount } from "./types/index.js";
15
+ import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
16
+ import { wecomOnboardingAdapter } from "./onboarding.js";
17
+ import { wecomOutbound } from "./outbound.js";
18
18
 
19
19
  const meta = {
20
20
  id: "wecom",
@@ -34,12 +34,43 @@ function normalizeWecomMessagingTarget(raw: string): string | undefined {
34
34
  return trimmed.replace(/^(wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
35
35
  }
36
36
 
37
+ type ResolvedWecomAccount = {
38
+ accountId: string;
39
+ name?: string;
40
+ enabled: boolean;
41
+ configured: boolean;
42
+ bot?: ResolvedBotAccount;
43
+ agent?: ResolvedAgentAccount;
44
+ };
45
+
46
+ /**
47
+ * **resolveWecomAccount (解析账号配置)**
48
+ *
49
+ * 从全局配置中解析出 WeCom 渠道的配置状态。
50
+ * 兼容 Bot 和 Agent 两种模式的配置检查。
51
+ */
52
+ function resolveWecomAccount(cfg: OpenClawConfig): ResolvedWecomAccount {
53
+ const enabled = (cfg.channels?.wecom as { enabled?: boolean } | undefined)?.enabled !== false;
54
+ const accounts = resolveWecomAccounts(cfg);
55
+ const bot = accounts.bot;
56
+ const agent = accounts.agent;
57
+ const configured = Boolean(bot?.configured || agent?.configured);
58
+ return {
59
+ accountId: DEFAULT_ACCOUNT_ID,
60
+ enabled,
61
+ configured,
62
+ bot,
63
+ agent,
64
+ };
65
+ }
66
+
37
67
  export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
38
68
  id: "wecom",
39
69
  meta,
70
+ onboarding: wecomOnboardingAdapter,
40
71
  capabilities: {
41
72
  chatTypes: ["direct", "group"],
42
- media: false,
73
+ media: true,
43
74
  reactions: false,
44
75
  threads: false,
45
76
  polls: false,
@@ -49,9 +80,9 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
49
80
  reload: { configPrefixes: ["channels.wecom"] },
50
81
  configSchema: buildChannelConfigSchema(WecomConfigSchema),
51
82
  config: {
52
- listAccountIds: (cfg) => listWecomAccountIds(cfg as OpenClawConfig),
53
- resolveAccount: (cfg, accountId) => resolveWecomAccount({ cfg: cfg as OpenClawConfig, accountId }),
54
- defaultAccountId: (cfg) => resolveDefaultWecomAccountId(cfg as OpenClawConfig),
83
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
84
+ resolveAccount: (cfg) => resolveWecomAccount(cfg as OpenClawConfig),
85
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
55
86
  setAccountEnabled: ({ cfg, accountId, enabled }) =>
56
87
  setAccountEnabledInConfigSection({
57
88
  cfg: cfg as OpenClawConfig,
@@ -60,24 +91,28 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
60
91
  enabled,
61
92
  allowTopLevel: true,
62
93
  }),
63
- deleteAccount: ({ cfg, accountId }) =>
64
- deleteAccountFromConfigSection({
65
- cfg: cfg as OpenClawConfig,
66
- sectionKey: "wecom",
67
- clearBaseFields: ["name", "webhookPath", "token", "encodingAESKey", "receiveId", "streamPlaceholderContent", "welcomeText"],
68
- accountId,
69
- }),
94
+ deleteAccount: ({ cfg }) => {
95
+ const next = { ...(cfg as OpenClawConfig) };
96
+ if (next.channels?.wecom) {
97
+ const channels = { ...(next.channels ?? {}) } as Record<string, unknown>;
98
+ delete (channels as Record<string, unknown>).wecom;
99
+ return { ...next, channels } as OpenClawConfig;
100
+ }
101
+ return next;
102
+ },
70
103
  isConfigured: (account) => account.configured,
71
104
  describeAccount: (account): ChannelAccountSnapshot => ({
72
105
  accountId: account.accountId,
73
106
  name: account.name,
74
107
  enabled: account.enabled,
75
108
  configured: account.configured,
76
- webhookPath: account.config.webhookPath ?? "/wecom",
109
+ webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
77
110
  }),
78
111
  resolveAllowFrom: ({ cfg, accountId }) => {
79
- const account = resolveWecomAccount({ cfg: cfg as OpenClawConfig, accountId });
80
- return (account.config.dm?.allowFrom ?? []).map((entry) => String(entry));
112
+ const account = resolveWecomAccount(cfg as OpenClawConfig);
113
+ // 与其他渠道保持一致:直接返回 allowFrom,空则允许所有人
114
+ const allowFrom = account.agent?.config.dm?.allowFrom ?? account.bot?.config.dm?.allowFrom ?? [];
115
+ return allowFrom.map((entry) => String(entry));
81
116
  },
82
117
  formatAllowFrom: ({ allowFrom }) =>
83
118
  allowFrom
@@ -85,21 +120,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
85
120
  .filter(Boolean)
86
121
  .map((entry) => entry.toLowerCase()),
87
122
  },
88
- security: {
89
- resolveDmPolicy: ({ cfg, accountId, account }) => {
90
- const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
91
- const useAccountPath = Boolean((cfg as OpenClawConfig).channels?.wecom?.accounts?.[resolvedAccountId]);
92
- const basePath = useAccountPath ? `channels.wecom.accounts.${resolvedAccountId}.` : "channels.wecom.";
93
- return {
94
- policy: account.config.dm?.policy ?? "pairing",
95
- allowFrom: (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)),
96
- policyPath: `${basePath}dm.policy`,
97
- allowFromPath: `${basePath}dm.allowFrom`,
98
- approveHint: formatPairingApproveHint("wecom"),
99
- normalizeEntry: (raw) => raw.trim().toLowerCase(),
100
- };
101
- },
102
- },
123
+ // security 配置在 WeCom 中不需要,框架会通过 resolveAllowFrom 自动判断
103
124
  groups: {
104
125
  // WeCom bots are usually mention-gated by the platform in groups already.
105
126
  resolveRequireMention: () => true,
@@ -115,17 +136,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
115
136
  },
116
137
  },
117
138
  outbound: {
118
- deliveryMode: "direct",
119
- chunkerMode: "text",
120
- textChunkLimit: 20480,
121
- sendText: async () => {
122
- return {
123
- channel: "wecom",
124
- ok: false,
125
- messageId: "",
126
- error: new Error("WeCom intelligent bot only supports replying within callbacks (no standalone sendText)."),
127
- };
128
- },
139
+ ...wecomOutbound,
129
140
  },
130
141
  status: {
131
142
  defaultRuntime: {
@@ -153,46 +164,82 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
153
164
  name: account.name,
154
165
  enabled: account.enabled,
155
166
  configured: account.configured,
156
- webhookPath: account.config.webhookPath ?? "/wecom",
167
+ webhookPath: account.bot?.config ? "/wecom/bot" : account.agent?.config ? "/wecom/agent" : "/wecom",
157
168
  running: runtime?.running ?? false,
158
169
  lastStartAt: runtime?.lastStartAt ?? null,
159
170
  lastStopAt: runtime?.lastStopAt ?? null,
160
171
  lastError: runtime?.lastError ?? null,
161
172
  lastInboundAt: runtime?.lastInboundAt ?? null,
162
173
  lastOutboundAt: runtime?.lastOutboundAt ?? null,
163
- dmPolicy: account.config.dm?.policy ?? "pairing",
174
+ dmPolicy: account.bot?.config.dm?.policy ?? "pairing",
164
175
  }),
165
176
  },
166
177
  gateway: {
178
+ /**
179
+ * **startAccount (启动账号)**
180
+ *
181
+ * 插件生命周期:启动
182
+ * 职责:
183
+ * 1. 检查配置是否有效。
184
+ * 2. 注册 Bot Webhook (`/wecom`, `/wecom/bot`)。
185
+ * 3. 注册 Agent Webhook (`/wecom/agent`)。
186
+ * 4. 更新运行时状态 (Running)。
187
+ * 5. 返回停止回调 (Cleanup)。
188
+ */
167
189
  startAccount: async (ctx) => {
168
190
  const account = ctx.account;
169
- if (!account.configured) {
191
+ const bot = account.bot;
192
+ const agent = account.agent;
193
+ const botConfigured = Boolean(bot?.configured);
194
+ const agentConfigured = Boolean(agent?.configured);
195
+
196
+ if (!botConfigured && !agentConfigured) {
170
197
  ctx.log?.warn(`[${account.accountId}] wecom not configured; skipping webhook registration`);
171
198
  ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
172
- return { stop: () => {} };
199
+ return { stop: () => { } };
173
200
  }
174
- const path = (account.config.webhookPath ?? "/wecom").trim();
175
- const unregister = registerWecomWebhookTarget({
176
- account,
177
- config: ctx.cfg as OpenClawConfig,
178
- runtime: ctx.runtime,
179
- // The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
180
- // The stored target only needs to be decrypt/verify-capable.
181
- core: ({} as unknown) as any,
182
- path,
183
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
184
- });
185
- ctx.log?.info(`[${account.accountId}] wecom webhook registered at ${path}`);
201
+
202
+ const unregisters: Array<() => void> = [];
203
+ if (bot && botConfigured) {
204
+ for (const path of ["/wecom", "/wecom/bot"]) {
205
+ unregisters.push(
206
+ registerWecomWebhookTarget({
207
+ account: bot,
208
+ config: ctx.cfg as OpenClawConfig,
209
+ runtime: ctx.runtime,
210
+ // The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
211
+ // The stored target only needs to be decrypt/verify-capable.
212
+ core: ({} as unknown) as any,
213
+ path,
214
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
215
+ }),
216
+ );
217
+ }
218
+ ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at /wecom and /wecom/bot`);
219
+ }
220
+ if (agent && agentConfigured) {
221
+ unregisters.push(
222
+ registerAgentWebhookTarget({
223
+ agent,
224
+ config: ctx.cfg as OpenClawConfig,
225
+ runtime: ctx.runtime,
226
+ }),
227
+ );
228
+ ctx.log?.info(`[${account.accountId}] wecom agent webhook registered at /wecom/agent`);
229
+ }
230
+
186
231
  ctx.setStatus({
187
232
  accountId: account.accountId,
188
233
  running: true,
189
234
  configured: true,
190
- webhookPath: path,
235
+ webhookPath: botConfigured ? "/wecom/bot" : "/wecom/agent",
191
236
  lastStartAt: Date.now(),
192
237
  });
193
238
  return {
194
239
  stop: () => {
195
- unregister();
240
+ for (const unregister of unregisters) {
241
+ unregister();
242
+ }
196
243
  ctx.setStatus({
197
244
  accountId: account.accountId,
198
245
  running: false,
@@ -0,0 +1,99 @@
1
+ /**
2
+ * WeCom 账号解析与模式检测
3
+ */
4
+
5
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
6
+ import type {
7
+ WecomConfig,
8
+ WecomBotConfig,
9
+ WecomAgentConfig,
10
+ WecomNetworkConfig,
11
+ ResolvedBotAccount,
12
+ ResolvedAgentAccount,
13
+ ResolvedMode,
14
+ ResolvedWecomAccounts,
15
+ } from "../types/index.js";
16
+
17
+ const DEFAULT_ACCOUNT_ID = "default";
18
+
19
+ /**
20
+ * 检测配置中启用的模式
21
+ */
22
+ export function detectMode(config: WecomConfig | undefined): ResolvedMode {
23
+ if (!config) return { bot: false, agent: false };
24
+
25
+ const botConfigured = Boolean(
26
+ config.bot?.token && config.bot?.encodingAESKey
27
+ );
28
+ const agentConfigured = Boolean(
29
+ config.agent?.corpId && config.agent?.corpSecret && config.agent?.agentId &&
30
+ config.agent?.token && config.agent?.encodingAESKey
31
+ );
32
+
33
+ return { bot: botConfigured, agent: agentConfigured };
34
+ }
35
+
36
+ /**
37
+ * 解析 Bot 模式账号
38
+ */
39
+ function resolveBotAccount(config: WecomBotConfig): ResolvedBotAccount {
40
+ return {
41
+ accountId: DEFAULT_ACCOUNT_ID,
42
+ enabled: true,
43
+ configured: Boolean(config.token && config.encodingAESKey),
44
+ token: config.token,
45
+ encodingAESKey: config.encodingAESKey,
46
+ receiveId: config.receiveId?.trim() ?? "",
47
+ config,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * 解析 Agent 模式账号
53
+ */
54
+ function resolveAgentAccount(config: WecomAgentConfig, network?: WecomNetworkConfig): ResolvedAgentAccount {
55
+ const agentIdRaw = config.agentId;
56
+ const agentId = typeof agentIdRaw === "number" ? agentIdRaw : Number(agentIdRaw);
57
+
58
+ return {
59
+ accountId: DEFAULT_ACCOUNT_ID,
60
+ enabled: true,
61
+ configured: Boolean(
62
+ config.corpId && config.corpSecret && agentId &&
63
+ config.token && config.encodingAESKey
64
+ ),
65
+ corpId: config.corpId,
66
+ corpSecret: config.corpSecret,
67
+ agentId,
68
+ token: config.token,
69
+ encodingAESKey: config.encodingAESKey,
70
+ config,
71
+ network,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * 解析 WeCom 账号 (双模式)
77
+ */
78
+ export function resolveWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccounts {
79
+ const wecom = cfg.channels?.wecom as WecomConfig | undefined;
80
+
81
+ if (!wecom || wecom.enabled === false) {
82
+ return {};
83
+ }
84
+
85
+ const mode = detectMode(wecom);
86
+
87
+ return {
88
+ bot: mode.bot && wecom.bot ? { ...resolveBotAccount(wecom.bot), network: wecom.network } : undefined,
89
+ agent: mode.agent && wecom.agent ? resolveAgentAccount(wecom.agent, wecom.network) : undefined,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * 检查是否有任何模式启用
95
+ */
96
+ export function isWecomEnabled(cfg: OpenClawConfig): boolean {
97
+ const accounts = resolveWecomAccounts(cfg);
98
+ return Boolean(accounts.bot?.configured || accounts.agent?.configured);
99
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * WeCom 配置模块导出
3
+ */
4
+
5
+ export { WecomConfigSchema, type WecomConfigInput } from "./schema.js";
6
+ export {
7
+ detectMode,
8
+ resolveWecomAccounts,
9
+ isWecomEnabled,
10
+ } from "./accounts.js";
11
+ export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
@@ -0,0 +1,16 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+
3
+ import type { WecomConfig, WecomNetworkConfig } from "../types/index.js";
4
+
5
+ export function resolveWecomEgressProxyUrlFromNetwork(network?: WecomNetworkConfig): string | undefined {
6
+ const env = (process.env.OPENCLAW_WECOM_EGRESS_PROXY_URL ?? process.env.WECOM_EGRESS_PROXY_URL ?? "").trim();
7
+ if (env) return env;
8
+
9
+ const fromCfg = network?.egressProxyUrl?.trim() ?? "";
10
+ return fromCfg || undefined;
11
+ }
12
+
13
+ export function resolveWecomEgressProxyUrl(cfg: OpenClawConfig): string | undefined {
14
+ const wecom = cfg.channels?.wecom as WecomConfig | undefined;
15
+ return resolveWecomEgressProxyUrlFromNetwork(wecom?.network);
16
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * WeCom 配置 Schema (Zod)
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * **dmSchema (单聊配置)**
9
+ *
10
+ * 控制单聊行为(如允许名单、策略)。
11
+ * @property enabled - 是否启用单聊 [默认: true]
12
+ * @property policy - 访问策略: "pairing" (需配对, 默认), "allowlist" (仅在名单), "open" (所有人), "disabled" (禁用)
13
+ * @property allowFrom - 允许的用户ID或群ID列表 (仅当 policy="allowlist" 时生效)
14
+ */
15
+ const dmSchema = z.object({
16
+ enabled: z.boolean().optional(),
17
+ policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
18
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
19
+ }).optional();
20
+
21
+ /**
22
+ * **mediaSchema (媒体处理配置)**
23
+ *
24
+ * 控制媒体文件的下载和缓存行为。
25
+ * @property tempDir - 临时文件下载目录
26
+ * @property retentionHours - 临时文件保留时间(小时)
27
+ * @property cleanupOnStart - 启动时是否自动清理旧文件
28
+ * @property maxBytes - 允许下载的最大字节数
29
+ */
30
+ const mediaSchema = z.object({
31
+ tempDir: z.string().optional(),
32
+ retentionHours: z.number().optional(),
33
+ cleanupOnStart: z.boolean().optional(),
34
+ maxBytes: z.number().optional(),
35
+ }).optional();
36
+
37
+ /**
38
+ * **networkSchema (网络配置)**
39
+ *
40
+ * 控制 HTTP 请求行为,特别是出站代理。
41
+ * @property timeoutMs - 请求超时时间 (毫秒)
42
+ * @property retries - 重试次数
43
+ * @property retryDelayMs - 重试间隔 (毫秒)
44
+ * @property egressProxyUrl - 出站 HTTP 代理 (如 "http://127.0.0.1:7890")
45
+ */
46
+ const networkSchema = z.object({
47
+ timeoutMs: z.number().optional(),
48
+ retries: z.number().optional(),
49
+ retryDelayMs: z.number().optional(),
50
+ egressProxyUrl: z.string().optional(),
51
+ }).optional();
52
+
53
+ /**
54
+ * **botSchema (Bot 模式配置)**
55
+ *
56
+ * 用于配置企业微信内部机器人 (Webhook 模式)。
57
+ * @property token - 企业微信后台设置的 Token
58
+ * @property encodingAESKey - 企业微信后台设置的 EncodingAESKey
59
+ * @property receiveId - (可选) 接收者ID,通常不用填
60
+ * @property streamPlaceholderContent - (可选) 流式响应中的占位符,默认为 "Thinking..."或空
61
+ * @property welcomeText - (可选) 用户首次对话时的欢迎语
62
+ * @property dm - 单聊策略覆盖配置
63
+ */
64
+ const botSchema = z.object({
65
+ token: z.string(),
66
+ encodingAESKey: z.string(),
67
+ receiveId: z.string().optional(),
68
+ streamPlaceholderContent: z.string().optional(),
69
+ welcomeText: z.string().optional(),
70
+ dm: dmSchema,
71
+ }).optional();
72
+
73
+ /**
74
+ * **agentSchema (Agent 模式配置)**
75
+ *
76
+ * 用于配置企业微信自建应用 (Agent)。
77
+ * @property corpId - 企业 ID (CorpID)
78
+ * @property corpSecret - 应用 Secret
79
+ * @property agentId - 应用 AgentId (数字)
80
+ * @property token - 回调配置 Token
81
+ * @property encodingAESKey - 回调配置 EncodingAESKey
82
+ * @property welcomeText - (可选) 欢迎语
83
+ * @property dm - 单聊策略覆盖配置
84
+ */
85
+ const agentSchema = z.object({
86
+ corpId: z.string(),
87
+ corpSecret: z.string(),
88
+ agentId: z.union([z.string(), z.number()]),
89
+ token: z.string(),
90
+ encodingAESKey: z.string(),
91
+ welcomeText: z.string().optional(),
92
+ dm: dmSchema,
93
+ }).optional();
94
+
95
+ /** 顶层 WeCom 配置 Schema */
96
+ export const WecomConfigSchema = z.object({
97
+ enabled: z.boolean().optional(),
98
+ bot: botSchema,
99
+ agent: agentSchema,
100
+ media: mediaSchema,
101
+ network: networkSchema,
102
+ });
103
+
104
+ export type WecomConfigInput = z.infer<typeof WecomConfigSchema>;
@@ -20,6 +20,7 @@ export const WecomConfigSchema = z.object({
20
20
  receiveId: z.string().optional(),
21
21
 
22
22
  streamPlaceholderContent: z.string().optional(),
23
+ debounceMs: z.number().optional(),
23
24
 
24
25
  welcomeText: z.string().optional(),
25
26
  dm: dmSchema,
@@ -33,6 +34,7 @@ export const WecomConfigSchema = z.object({
33
34
  encodingAESKey: z.string().optional(),
34
35
  receiveId: z.string().optional(),
35
36
  streamPlaceholderContent: z.string().optional(),
37
+ debounceMs: z.number().optional(),
36
38
  welcomeText: z.string().optional(),
37
39
  dm: dmSchema,
38
40
  })).optional(),
@@ -0,0 +1,108 @@
1
+ /**
2
+ * WeCom AES-256-CBC 加解密核心
3
+ * Bot 和 Agent 模式共用
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+ import { CRYPTO } from "../types/constants.js";
8
+
9
+ export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
10
+ const trimmed = encodingAESKey.trim();
11
+ if (!trimmed) throw new Error("encodingAESKey missing");
12
+ const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
13
+ const key = Buffer.from(withPadding, "base64");
14
+ if (key.length !== CRYPTO.AES_KEY_LENGTH) {
15
+ throw new Error(`invalid encodingAESKey (expected ${CRYPTO.AES_KEY_LENGTH} bytes, got ${key.length})`);
16
+ }
17
+ return key;
18
+ }
19
+
20
+ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
21
+ const mod = buf.length % blockSize;
22
+ const pad = mod === 0 ? blockSize : blockSize - mod;
23
+ const padByte = Buffer.from([pad]);
24
+ return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
25
+ }
26
+
27
+ export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
28
+ if (buf.length === 0) throw new Error("invalid pkcs7 payload");
29
+ const pad = buf[buf.length - 1]!;
30
+ if (pad < 1 || pad > blockSize) {
31
+ throw new Error("invalid pkcs7 padding");
32
+ }
33
+ if (pad > buf.length) {
34
+ throw new Error("invalid pkcs7 payload");
35
+ }
36
+ for (let i = 0; i < pad; i += 1) {
37
+ if (buf[buf.length - 1 - i] !== pad) {
38
+ throw new Error("invalid pkcs7 padding");
39
+ }
40
+ }
41
+ return buf.subarray(0, buf.length - pad);
42
+ }
43
+
44
+ /**
45
+ * 解密 WeCom 加密消息
46
+ */
47
+ export function decryptWecomEncrypted(params: {
48
+ encodingAESKey: string;
49
+ receiveId?: string;
50
+ encrypt: string;
51
+ }): string {
52
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
53
+ const iv = aesKey.subarray(0, 16);
54
+ const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
55
+ decipher.setAutoPadding(false);
56
+ const decryptedPadded = Buffer.concat([
57
+ decipher.update(Buffer.from(params.encrypt, "base64")),
58
+ decipher.final(),
59
+ ]);
60
+ const decrypted = pkcs7Unpad(decryptedPadded, CRYPTO.PKCS7_BLOCK_SIZE);
61
+
62
+ if (decrypted.length < 20) {
63
+ throw new Error(`invalid payload (expected >=20 bytes, got ${decrypted.length})`);
64
+ }
65
+
66
+ // 16 bytes random + 4 bytes length + msg + receiveId
67
+ const msgLen = decrypted.readUInt32BE(16);
68
+ const msgStart = 20;
69
+ const msgEnd = msgStart + msgLen;
70
+ if (msgEnd > decrypted.length) {
71
+ throw new Error(`invalid msg length (msgEnd=${msgEnd}, total=${decrypted.length})`);
72
+ }
73
+ const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");
74
+
75
+ const receiveId = params.receiveId ?? "";
76
+ if (receiveId) {
77
+ const trailing = decrypted.subarray(msgEnd).toString("utf8");
78
+ if (trailing !== receiveId) {
79
+ throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
80
+ }
81
+ }
82
+
83
+ return msg;
84
+ }
85
+
86
+ /**
87
+ * 加密明文为 WeCom 格式
88
+ */
89
+ export function encryptWecomPlaintext(params: {
90
+ encodingAESKey: string;
91
+ receiveId?: string;
92
+ plaintext: string;
93
+ }): string {
94
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
95
+ const iv = aesKey.subarray(0, 16);
96
+ const random16 = crypto.randomBytes(16);
97
+ const msg = Buffer.from(params.plaintext ?? "", "utf8");
98
+ const msgLen = Buffer.alloc(4);
99
+ msgLen.writeUInt32BE(msg.length, 0);
100
+ const receiveId = Buffer.from(params.receiveId ?? "", "utf8");
101
+
102
+ const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
103
+ const padded = pkcs7Pad(raw, CRYPTO.PKCS7_BLOCK_SIZE);
104
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
105
+ cipher.setAutoPadding(false);
106
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
107
+ return encrypted.toString("base64");
108
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * WeCom 加解密模块导出
3
+ */
4
+
5
+ // AES 加解密
6
+ export {
7
+ decodeEncodingAESKey,
8
+ pkcs7Unpad,
9
+ decryptWecomEncrypted,
10
+ encryptWecomPlaintext,
11
+ } from "./aes.js";
12
+
13
+ // 签名验证
14
+ export {
15
+ computeWecomMsgSignature,
16
+ verifyWecomSignature,
17
+ } from "./signature.js";
18
+
19
+ // XML 辅助
20
+ export {
21
+ extractEncryptFromXml,
22
+ extractToUserNameFromXml,
23
+ buildEncryptedXmlResponse,
24
+ } from "./xml.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WeCom 签名计算与验证
3
+ */
4
+
5
+ import crypto from "node:crypto";
6
+
7
+ function sha1Hex(input: string): string {
8
+ return crypto.createHash("sha1").update(input).digest("hex");
9
+ }
10
+
11
+ /**
12
+ * 计算 WeCom 消息签名
13
+ */
14
+ export function computeWecomMsgSignature(params: {
15
+ token: string;
16
+ timestamp: string;
17
+ nonce: string;
18
+ encrypt: string;
19
+ }): string {
20
+ const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
21
+ .map((v) => String(v ?? ""))
22
+ .sort();
23
+ return sha1Hex(parts.join(""));
24
+ }
25
+
26
+ /**
27
+ * 验证 WeCom 消息签名
28
+ */
29
+ export function verifyWecomSignature(params: {
30
+ token: string;
31
+ timestamp: string;
32
+ nonce: string;
33
+ encrypt: string;
34
+ signature: string;
35
+ }): boolean {
36
+ const expected = computeWecomMsgSignature({
37
+ token: params.token,
38
+ timestamp: params.timestamp,
39
+ nonce: params.nonce,
40
+ encrypt: params.encrypt,
41
+ });
42
+ return expected === params.signature;
43
+ }