@yanhaidao/wecom 1.0.0

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 ADDED
@@ -0,0 +1,93 @@
1
+ # Clawdbot 企业微信(WeCom)Channel 插件
2
+
3
+ 维护者:YanHaidao(VX:YanHaidao)
4
+
5
+ 状态:支持企业微信智能机器人(API 模式)加密回调 + 被动回复(stream)。
6
+
7
+ ## 联系我
8
+
9
+ 微信交流群(扫码入群):
10
+
11
+ ![企业微信交流群](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/link-me.jpg)
12
+
13
+ ## 安装
14
+
15
+ ### 从 npm 安装
16
+ ```bash
17
+ clawdbot plugins install @yanhaidao/wecom
18
+ clawdbot plugins enable wecom
19
+ clawdbot gateway restart
20
+ ```
21
+
22
+
23
+ ## 配置结构参考
24
+
25
+ ```json5
26
+ {
27
+ channels: {
28
+ wecom: {
29
+ enabled: true,
30
+ webhookPath: "/wecom",
31
+ token: "YOUR_TOKEN",
32
+ encodingAESKey: "YOUR_ENCODING_AES_KEY",
33
+ receiveId: "YOUR_RECEIVE_ID",
34
+ dm: { policy: "pairing" }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## 接入企业微信
41
+
42
+ ### 搭建企微 Bot(API 模式)
43
+
44
+ 1. 登录企业微信管理后台
45
+ 进入「安全与管理」→「管理工具」→「智能机器人」:`https://work.weixin.qq.com/wework_admin/frame#/manageTools`
46
+
47
+ 2. 创建机器人(务必选择 API 模式)
48
+ 创建机器人时需要填写回调 URL(公网可访问的 HTTPS 地址),例如:`https://example.com/wecom`
49
+
50
+ 3. 记录机器人配置
51
+ 在机器人详情里找到并保存以下信息,后续会写入 Clawdbot 配置:
52
+ - Token
53
+ - EncodingAESKey
54
+ - ReceiveId(如果你的机器人/回调配置需要校验的话)
55
+
56
+ ## 快速开始
57
+
58
+ 1. 启用插件
59
+ ```bash
60
+ clawdbot plugins enable wecom
61
+ ```
62
+
63
+ 2. 配置企业微信机器人(必需)
64
+ ```bash
65
+ clawdbot config set channels.wecom.enabled true
66
+ clawdbot config set channels.wecom.webhookPath "/wecom"
67
+ clawdbot config set channels.wecom.token "YOUR_TOKEN"
68
+ clawdbot config set channels.wecom.encodingAESKey "YOUR_ENCODING_AES_KEY"
69
+ clawdbot config set channels.wecom.receiveId ""
70
+ ```
71
+
72
+ 3. 配置 Gateway(示例)
73
+ ```bash
74
+ clawdbot config set gateway.mode "local"
75
+ clawdbot config set gateway.bind "0.0.0.0"
76
+ clawdbot config set gateway.port 18789
77
+ ```
78
+
79
+ 4. 重启 Gateway
80
+ ```bash
81
+ clawdbot gateway restart
82
+ ```
83
+
84
+ 5. 验证
85
+ ```bash
86
+ clawdbot channels status
87
+ ```
88
+
89
+ ## 说明
90
+
91
+ - webhook 必须是公网 HTTPS。出于安全考虑,建议只对外暴露 `/wecom` 路径。
92
+ - stream 模式:第一次回包可能是占位符;随后 WeCom 会以 `msgtype=stream` 回调刷新拉取完整内容。
93
+ - 限制:仅支持被动回复,不支持脱离回调的主动发送。
Binary file
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "wecom",
3
+ "channels": ["wecom"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Author: YanHaidao
3
+ */
4
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
5
+ import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
6
+
7
+ import { handleWecomWebhookRequest } from "./src/monitor.js";
8
+ import { setWecomRuntime } from "./src/runtime.js";
9
+ import { wecomPlugin } from "./src/channel.js";
10
+
11
+ const plugin = {
12
+ id: "wecom",
13
+ name: "WeCom",
14
+ description: "Clawdbot WeCom (WeChat Work) intelligent bot channel plugin",
15
+ configSchema: emptyPluginConfigSchema(),
16
+ register(api: ClawdbotPluginApi) {
17
+ setWecomRuntime(api.runtime);
18
+ api.registerChannel({ plugin: wecomPlugin });
19
+ api.registerHttpHandler(handleWecomWebhookRequest);
20
+ },
21
+ };
22
+
23
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@yanhaidao/wecom",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Clawdbot WeCom (WeChat Work) intelligent bot channel plugin",
6
+ "author": "YanHaidao (VX: YanHaidao)",
7
+ "clawdbot": {
8
+ "extensions": [
9
+ "./index.ts"
10
+ ],
11
+ "channel": {
12
+ "id": "wecom",
13
+ "label": "WeCom",
14
+ "selectionLabel": "WeCom (plugin)",
15
+ "detailLabel": "WeCom Bot",
16
+ "docsPath": "/channels/wecom",
17
+ "docsLabel": "wecom",
18
+ "blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
19
+ "aliases": [
20
+ "wechatwork",
21
+ "wework",
22
+ "qywx",
23
+ "企微",
24
+ "企业微信"
25
+ ],
26
+ "order": 85
27
+ },
28
+ "install": {
29
+ "npmSpec": "@yanhaidao/wecom",
30
+ "localPath": "extensions/wecom",
31
+ "defaultChoice": "npm"
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "zod": "^4.3.6"
36
+ },
37
+ "devDependencies": {},
38
+ "peerDependencies": {
39
+ "clawdbot": ">=2026.1.25"
40
+ }
41
+ }
@@ -0,0 +1,73 @@
1
+ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
3
+
4
+ import type { ResolvedWecomAccount, WecomAccountConfig, WecomConfig } from "./types.js";
5
+
6
+ function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
7
+ const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
8
+ if (!accounts || typeof accounts !== "object") return [];
9
+ return Object.keys(accounts).filter(Boolean);
10
+ }
11
+
12
+ export function listWecomAccountIds(cfg: ClawdbotConfig): string[] {
13
+ const ids = listConfiguredAccountIds(cfg);
14
+ if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
15
+ return ids.sort((a, b) => a.localeCompare(b));
16
+ }
17
+
18
+ export function resolveDefaultWecomAccountId(cfg: ClawdbotConfig): string {
19
+ const wecomConfig = cfg.channels?.wecom as WecomConfig | undefined;
20
+ if (wecomConfig?.defaultAccount?.trim()) return wecomConfig.defaultAccount.trim();
21
+ const ids = listWecomAccountIds(cfg);
22
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
23
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
24
+ }
25
+
26
+ function resolveAccountConfig(
27
+ cfg: ClawdbotConfig,
28
+ accountId: string,
29
+ ): WecomAccountConfig | undefined {
30
+ const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
31
+ if (!accounts || typeof accounts !== "object") return undefined;
32
+ return accounts[accountId] as WecomAccountConfig | undefined;
33
+ }
34
+
35
+ function mergeWecomAccountConfig(cfg: ClawdbotConfig, accountId: string): WecomAccountConfig {
36
+ const raw = (cfg.channels?.wecom ?? {}) as WecomConfig;
37
+ const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
38
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
39
+ return { ...base, ...account };
40
+ }
41
+
42
+ export function resolveWecomAccount(params: {
43
+ cfg: ClawdbotConfig;
44
+ accountId?: string | null;
45
+ }): ResolvedWecomAccount {
46
+ const accountId = normalizeAccountId(params.accountId);
47
+ const baseEnabled = (params.cfg.channels?.wecom as WecomConfig | undefined)?.enabled !== false;
48
+ const merged = mergeWecomAccountConfig(params.cfg, accountId);
49
+ const enabled = baseEnabled && merged.enabled !== false;
50
+
51
+ const token = merged.token?.trim() || undefined;
52
+ const encodingAESKey = merged.encodingAESKey?.trim() || undefined;
53
+ const receiveId = merged.receiveId?.trim() ?? "";
54
+ const configured = Boolean(token && encodingAESKey);
55
+
56
+ return {
57
+ accountId,
58
+ name: merged.name?.trim() || undefined,
59
+ enabled,
60
+ configured,
61
+ token,
62
+ encodingAESKey,
63
+ receiveId,
64
+ config: merged,
65
+ };
66
+ }
67
+
68
+ export function listEnabledWecomAccounts(cfg: ClawdbotConfig): ResolvedWecomAccount[] {
69
+ return listWecomAccountIds(cfg)
70
+ .map((accountId) => resolveWecomAccount({ cfg, accountId }))
71
+ .filter((account) => account.enabled);
72
+ }
73
+
package/src/channel.ts ADDED
@@ -0,0 +1,212 @@
1
+ import type {
2
+ ChannelAccountSnapshot,
3
+ ChannelPlugin,
4
+ ClawdbotConfig,
5
+ } from "clawdbot/plugin-sdk";
6
+ import {
7
+ buildChannelConfigSchema,
8
+ DEFAULT_ACCOUNT_ID,
9
+ deleteAccountFromConfigSection,
10
+ formatPairingApproveHint,
11
+ setAccountEnabledInConfigSection,
12
+ } from "clawdbot/plugin-sdk";
13
+
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";
18
+
19
+ const meta = {
20
+ id: "wecom",
21
+ label: "WeCom",
22
+ selectionLabel: "WeCom (plugin)",
23
+ docsPath: "/channels/wecom",
24
+ docsLabel: "wecom",
25
+ blurb: "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.",
26
+ aliases: ["wechatwork", "wework", "qywx", "企微", "企业微信"],
27
+ order: 85,
28
+ quickstartAllowFrom: true,
29
+ };
30
+
31
+ function normalizeWecomMessagingTarget(raw: string): string | undefined {
32
+ const trimmed = raw.trim();
33
+ if (!trimmed) return undefined;
34
+ return trimmed.replace(/^(wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
35
+ }
36
+
37
+ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
38
+ id: "wecom",
39
+ meta,
40
+ capabilities: {
41
+ chatTypes: ["direct", "group"],
42
+ media: false,
43
+ reactions: false,
44
+ threads: false,
45
+ polls: false,
46
+ nativeCommands: false,
47
+ blockStreaming: true,
48
+ },
49
+ reload: { configPrefixes: ["channels.wecom"] },
50
+ configSchema: buildChannelConfigSchema(WecomConfigSchema),
51
+ config: {
52
+ listAccountIds: (cfg) => listWecomAccountIds(cfg as ClawdbotConfig),
53
+ resolveAccount: (cfg, accountId) => resolveWecomAccount({ cfg: cfg as ClawdbotConfig, accountId }),
54
+ defaultAccountId: (cfg) => resolveDefaultWecomAccountId(cfg as ClawdbotConfig),
55
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
56
+ setAccountEnabledInConfigSection({
57
+ cfg: cfg as ClawdbotConfig,
58
+ sectionKey: "wecom",
59
+ accountId,
60
+ enabled,
61
+ allowTopLevel: true,
62
+ }),
63
+ deleteAccount: ({ cfg, accountId }) =>
64
+ deleteAccountFromConfigSection({
65
+ cfg: cfg as ClawdbotConfig,
66
+ sectionKey: "wecom",
67
+ clearBaseFields: ["name", "webhookPath", "token", "encodingAESKey", "receiveId", "welcomeText"],
68
+ accountId,
69
+ }),
70
+ isConfigured: (account) => account.configured,
71
+ describeAccount: (account): ChannelAccountSnapshot => ({
72
+ accountId: account.accountId,
73
+ name: account.name,
74
+ enabled: account.enabled,
75
+ configured: account.configured,
76
+ webhookPath: account.config.webhookPath ?? "/wecom",
77
+ }),
78
+ resolveAllowFrom: ({ cfg, accountId }) => {
79
+ const account = resolveWecomAccount({ cfg: cfg as ClawdbotConfig, accountId });
80
+ return (account.config.dm?.allowFrom ?? []).map((entry) => String(entry));
81
+ },
82
+ formatAllowFrom: ({ allowFrom }) =>
83
+ allowFrom
84
+ .map((entry) => String(entry).trim())
85
+ .filter(Boolean)
86
+ .map((entry) => entry.toLowerCase()),
87
+ },
88
+ security: {
89
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
90
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
91
+ const useAccountPath = Boolean((cfg as ClawdbotConfig).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
+ },
103
+ groups: {
104
+ // WeCom bots are usually mention-gated by the platform in groups already.
105
+ resolveRequireMention: () => true,
106
+ },
107
+ threading: {
108
+ resolveReplyToMode: () => "off",
109
+ },
110
+ messaging: {
111
+ normalizeTarget: normalizeWecomMessagingTarget,
112
+ targetResolver: {
113
+ looksLikeId: (raw) => Boolean(raw.trim()),
114
+ hint: "<userid|chatid>",
115
+ },
116
+ },
117
+ 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
+ },
129
+ },
130
+ status: {
131
+ defaultRuntime: {
132
+ accountId: DEFAULT_ACCOUNT_ID,
133
+ running: false,
134
+ lastStartAt: null,
135
+ lastStopAt: null,
136
+ lastError: null,
137
+ },
138
+ buildChannelSummary: ({ snapshot }) => ({
139
+ configured: snapshot.configured ?? false,
140
+ running: snapshot.running ?? false,
141
+ webhookPath: snapshot.webhookPath ?? null,
142
+ lastStartAt: snapshot.lastStartAt ?? null,
143
+ lastStopAt: snapshot.lastStopAt ?? null,
144
+ lastError: snapshot.lastError ?? null,
145
+ lastInboundAt: snapshot.lastInboundAt ?? null,
146
+ lastOutboundAt: snapshot.lastOutboundAt ?? null,
147
+ probe: snapshot.probe,
148
+ lastProbeAt: snapshot.lastProbeAt ?? null,
149
+ }),
150
+ probeAccount: async () => ({ ok: true }),
151
+ buildAccountSnapshot: ({ account, runtime }) => ({
152
+ accountId: account.accountId,
153
+ name: account.name,
154
+ enabled: account.enabled,
155
+ configured: account.configured,
156
+ webhookPath: account.config.webhookPath ?? "/wecom",
157
+ running: runtime?.running ?? false,
158
+ lastStartAt: runtime?.lastStartAt ?? null,
159
+ lastStopAt: runtime?.lastStopAt ?? null,
160
+ lastError: runtime?.lastError ?? null,
161
+ lastInboundAt: runtime?.lastInboundAt ?? null,
162
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
163
+ dmPolicy: account.config.dm?.policy ?? "pairing",
164
+ }),
165
+ },
166
+ gateway: {
167
+ startAccount: async (ctx) => {
168
+ const account = ctx.account;
169
+ if (!account.configured) {
170
+ ctx.log?.warn(`[${account.accountId}] wecom not configured; skipping webhook registration`);
171
+ ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
172
+ return { stop: () => {} };
173
+ }
174
+ const path = (account.config.webhookPath ?? "/wecom").trim();
175
+ const unregister = registerWecomWebhookTarget({
176
+ account,
177
+ config: ctx.cfg as ClawdbotConfig,
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}`);
186
+ ctx.setStatus({
187
+ accountId: account.accountId,
188
+ running: true,
189
+ configured: true,
190
+ webhookPath: path,
191
+ lastStartAt: Date.now(),
192
+ });
193
+ return {
194
+ stop: () => {
195
+ unregister();
196
+ ctx.setStatus({
197
+ accountId: account.accountId,
198
+ running: false,
199
+ lastStopAt: Date.now(),
200
+ });
201
+ },
202
+ };
203
+ },
204
+ stopAccount: async (ctx) => {
205
+ ctx.setStatus({
206
+ accountId: ctx.account.accountId,
207
+ running: false,
208
+ lastStopAt: Date.now(),
209
+ });
210
+ },
211
+ },
212
+ };
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+
3
+ const allowFromEntry = z.union([z.string(), z.number()]);
4
+
5
+ const dmSchema = z
6
+ .object({
7
+ enabled: z.boolean().optional(),
8
+ policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
9
+ allowFrom: z.array(allowFromEntry).optional(),
10
+ })
11
+ .optional();
12
+
13
+ export const WecomConfigSchema = z.object({
14
+ name: z.string().optional(),
15
+ enabled: z.boolean().optional(),
16
+
17
+ webhookPath: z.string().optional(),
18
+ token: z.string().optional(),
19
+ encodingAESKey: z.string().optional(),
20
+ receiveId: z.string().optional(),
21
+
22
+ welcomeText: z.string().optional(),
23
+ dm: dmSchema,
24
+
25
+ defaultAccount: z.string().optional(),
26
+ accounts: z.object({}).catchall(z.object({
27
+ name: z.string().optional(),
28
+ enabled: z.boolean().optional(),
29
+ webhookPath: z.string().optional(),
30
+ token: z.string().optional(),
31
+ encodingAESKey: z.string().optional(),
32
+ receiveId: z.string().optional(),
33
+ welcomeText: z.string().optional(),
34
+ dm: dmSchema,
35
+ })).optional(),
36
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js";
4
+
5
+ describe("wecom crypto", () => {
6
+ it("round-trips plaintext", () => {
7
+ const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // 43 chars base64 (plus '=' padding)
8
+ const plaintext = JSON.stringify({ hello: "world" });
9
+ const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext });
10
+ const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt });
11
+ expect(decrypted).toBe(plaintext);
12
+ });
13
+
14
+ it("pads correctly when raw length is a multiple of 32", () => {
15
+ const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
16
+ // raw length = 20 + plaintext.length + receiveId.length; choose plaintext length % 32 === 12
17
+ const plaintext = "x".repeat(12);
18
+ const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext });
19
+ const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt });
20
+ expect(decrypted).toBe(plaintext);
21
+ });
22
+
23
+ it("computes sha1 msg signature", () => {
24
+ const sig = computeWecomMsgSignature({
25
+ token: "token",
26
+ timestamp: "123",
27
+ nonce: "456",
28
+ encrypt: "ENCRYPT",
29
+ });
30
+ expect(sig).toMatch(/^[a-f0-9]{40}$/);
31
+ });
32
+ });
package/src/crypto.ts ADDED
@@ -0,0 +1,133 @@
1
+ import crypto from "node:crypto";
2
+
3
+ function decodeEncodingAESKey(encodingAESKey: string): Buffer {
4
+ const trimmed = encodingAESKey.trim();
5
+ if (!trimmed) throw new Error("encodingAESKey missing");
6
+ const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
7
+ const key = Buffer.from(withPadding, "base64");
8
+ if (key.length !== 32) {
9
+ throw new Error(`invalid encodingAESKey (expected 32 bytes after base64 decode, got ${key.length})`);
10
+ }
11
+ return key;
12
+ }
13
+
14
+ // WeCom uses PKCS#7 padding with a block size of 32 bytes (not AES's 16-byte block).
15
+ // This is compatible with AES-CBC as 32 is a multiple of 16, but it requires manual padding/unpadding.
16
+ const WECOM_PKCS7_BLOCK_SIZE = 32;
17
+
18
+ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
19
+ const mod = buf.length % blockSize;
20
+ const pad = mod === 0 ? blockSize : blockSize - mod;
21
+ const padByte = Buffer.from([pad]);
22
+ return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
23
+ }
24
+
25
+ function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
26
+ if (buf.length === 0) throw new Error("invalid pkcs7 payload");
27
+ const pad = buf[buf.length - 1]!;
28
+ if (pad < 1 || pad > blockSize) {
29
+ throw new Error("invalid pkcs7 padding");
30
+ }
31
+ if (pad > buf.length) {
32
+ throw new Error("invalid pkcs7 payload");
33
+ }
34
+ // Best-effort validation (all padding bytes equal).
35
+ for (let i = 0; i < pad; i += 1) {
36
+ if (buf[buf.length - 1 - i] !== pad) {
37
+ throw new Error("invalid pkcs7 padding");
38
+ }
39
+ }
40
+ return buf.subarray(0, buf.length - pad);
41
+ }
42
+
43
+ function sha1Hex(input: string): string {
44
+ return crypto.createHash("sha1").update(input).digest("hex");
45
+ }
46
+
47
+ export function computeWecomMsgSignature(params: {
48
+ token: string;
49
+ timestamp: string;
50
+ nonce: string;
51
+ encrypt: string;
52
+ }): string {
53
+ const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
54
+ .map((v) => String(v ?? ""))
55
+ .sort();
56
+ return sha1Hex(parts.join(""));
57
+ }
58
+
59
+ export function verifyWecomSignature(params: {
60
+ token: string;
61
+ timestamp: string;
62
+ nonce: string;
63
+ encrypt: string;
64
+ signature: string;
65
+ }): boolean {
66
+ const expected = computeWecomMsgSignature({
67
+ token: params.token,
68
+ timestamp: params.timestamp,
69
+ nonce: params.nonce,
70
+ encrypt: params.encrypt,
71
+ });
72
+ return expected === params.signature;
73
+ }
74
+
75
+ export function decryptWecomEncrypted(params: {
76
+ encodingAESKey: string;
77
+ receiveId?: string;
78
+ encrypt: string;
79
+ }): string {
80
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
81
+ const iv = aesKey.subarray(0, 16);
82
+ const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
83
+ decipher.setAutoPadding(false);
84
+ const decryptedPadded = Buffer.concat([
85
+ decipher.update(Buffer.from(params.encrypt, "base64")),
86
+ decipher.final(),
87
+ ]);
88
+ const decrypted = pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
89
+
90
+ if (decrypted.length < 20) {
91
+ throw new Error(`invalid decrypted payload (expected at least 20 bytes, got ${decrypted.length})`);
92
+ }
93
+
94
+ // 16 bytes random + 4 bytes network-order length + msg + receiveId (optional)
95
+ const msgLen = decrypted.readUInt32BE(16);
96
+ const msgStart = 20;
97
+ const msgEnd = msgStart + msgLen;
98
+ if (msgEnd > decrypted.length) {
99
+ throw new Error(`invalid decrypted msg length (msgEnd=${msgEnd}, payloadLength=${decrypted.length})`);
100
+ }
101
+ const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");
102
+
103
+ const receiveId = params.receiveId ?? "";
104
+ if (receiveId) {
105
+ const trailing = decrypted.subarray(msgEnd).toString("utf8");
106
+ if (trailing !== receiveId) {
107
+ throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
108
+ }
109
+ }
110
+
111
+ return msg;
112
+ }
113
+
114
+ export function encryptWecomPlaintext(params: {
115
+ encodingAESKey: string;
116
+ receiveId?: string;
117
+ plaintext: string;
118
+ }): string {
119
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
120
+ const iv = aesKey.subarray(0, 16);
121
+ const random16 = crypto.randomBytes(16);
122
+ const msg = Buffer.from(params.plaintext ?? "", "utf8");
123
+ const msgLen = Buffer.alloc(4);
124
+ msgLen.writeUInt32BE(msg.length, 0);
125
+ const receiveId = Buffer.from(params.receiveId ?? "", "utf8");
126
+
127
+ const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
128
+ const padded = pkcs7Pad(raw, WECOM_PKCS7_BLOCK_SIZE);
129
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
130
+ cipher.setAutoPadding(false);
131
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
132
+ return encrypted.toString("base64");
133
+ }