@marshulll/wecom-dual 0.1.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.
@@ -0,0 +1,136 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
3
+
4
+ import type { ResolvedWecomAccount, WecomAccountConfig, WecomConfig, WecomMode } from "./types.js";
5
+
6
+ function resolveEnvValue(cfg: ClawdbotConfig, name: string): string | undefined {
7
+ const envVars = (cfg as any)?.env?.vars ?? {};
8
+ const fromCfg = envVars[name];
9
+ if (fromCfg != null && String(fromCfg).trim() !== "") return String(fromCfg).trim();
10
+ const fromProcess = process.env[name];
11
+ if (fromProcess != null && fromProcess.trim() !== "") return fromProcess.trim();
12
+ return undefined;
13
+ }
14
+
15
+ function resolveAccountEnv(cfg: ClawdbotConfig, accountId: string, key: string): string | undefined {
16
+ const prefix = accountId === DEFAULT_ACCOUNT_ID ? "WECOM" : `WECOM_${accountId.toUpperCase()}`;
17
+ return resolveEnvValue(cfg, `${prefix}_${key}`);
18
+ }
19
+
20
+ function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
21
+ const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
22
+ if (!accounts || typeof accounts !== "object") return [];
23
+ return Object.keys(accounts).filter(Boolean);
24
+ }
25
+
26
+ export function listWecomAccountIds(cfg: ClawdbotConfig): string[] {
27
+ const ids = listConfiguredAccountIds(cfg);
28
+ if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
29
+ return ids.sort((a, b) => a.localeCompare(b));
30
+ }
31
+
32
+ export function resolveDefaultWecomAccountId(cfg: ClawdbotConfig): string {
33
+ const wecomConfig = cfg.channels?.wecom as WecomConfig | undefined;
34
+ if (wecomConfig?.defaultAccount?.trim()) return wecomConfig.defaultAccount.trim();
35
+ const ids = listWecomAccountIds(cfg);
36
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
37
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
38
+ }
39
+
40
+ function resolveAccountConfig(cfg: ClawdbotConfig, accountId: string): WecomAccountConfig | undefined {
41
+ const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
42
+ if (!accounts || typeof accounts !== "object") return undefined;
43
+ return accounts[accountId] as WecomAccountConfig | undefined;
44
+ }
45
+
46
+ function mergeWecomAccountConfig(cfg: ClawdbotConfig, accountId: string): WecomAccountConfig {
47
+ const raw = (cfg.channels?.wecom ?? {}) as WecomConfig;
48
+ const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
49
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
50
+ return { ...base, ...account };
51
+ }
52
+
53
+ function resolveMode(raw?: string): WecomMode {
54
+ if (raw === "bot" || raw === "app" || raw === "both") return raw;
55
+ return "both";
56
+ }
57
+
58
+ export function resolveWecomAccount(params: {
59
+ cfg: ClawdbotConfig;
60
+ accountId?: string | null;
61
+ }): ResolvedWecomAccount {
62
+ const accountId = normalizeAccountId(params.accountId);
63
+ const baseEnabled = (params.cfg.channels?.wecom as WecomConfig | undefined)?.enabled !== false;
64
+ const merged = mergeWecomAccountConfig(params.cfg, accountId);
65
+ const enabled = baseEnabled && merged.enabled !== false;
66
+
67
+ const token = merged.token?.trim()
68
+ || resolveAccountEnv(params.cfg, accountId, "TOKEN")
69
+ || undefined;
70
+ const encodingAESKey = merged.encodingAESKey?.trim()
71
+ || resolveAccountEnv(params.cfg, accountId, "ENCODING_AES_KEY")
72
+ || undefined;
73
+ const receiveId = merged.receiveId?.trim()
74
+ || resolveAccountEnv(params.cfg, accountId, "RECEIVE_ID")
75
+ || "";
76
+
77
+ const corpId = merged.corpId?.trim()
78
+ || resolveAccountEnv(params.cfg, accountId, "CORP_ID")
79
+ || undefined;
80
+ const corpSecret = merged.corpSecret?.trim()
81
+ || resolveAccountEnv(params.cfg, accountId, "CORP_SECRET")
82
+ || undefined;
83
+ const agentIdRaw = merged.agentId != null ? String(merged.agentId) : resolveAccountEnv(params.cfg, accountId, "AGENT_ID");
84
+ const agentId = agentIdRaw != null ? Number(agentIdRaw) : undefined;
85
+ const callbackToken = merged.callbackToken?.trim()
86
+ || resolveAccountEnv(params.cfg, accountId, "CALLBACK_TOKEN")
87
+ || undefined;
88
+ const callbackAesKey = merged.callbackAesKey?.trim()
89
+ || resolveAccountEnv(params.cfg, accountId, "CALLBACK_AES_KEY")
90
+ || undefined;
91
+ const webhookPath = merged.webhookPath?.trim()
92
+ || resolveAccountEnv(params.cfg, accountId, "WEBHOOK_PATH")
93
+ || undefined;
94
+
95
+ const configuredBot = Boolean(token && encodingAESKey);
96
+ const configuredApp = Boolean(corpId && corpSecret && agentId);
97
+ const configured = configuredBot || configuredApp;
98
+
99
+ const mode = resolveMode(merged.mode);
100
+
101
+ const mergedConfig: WecomAccountConfig = {
102
+ ...merged,
103
+ webhookPath,
104
+ token,
105
+ encodingAESKey,
106
+ receiveId,
107
+ corpId,
108
+ corpSecret,
109
+ agentId,
110
+ callbackToken,
111
+ callbackAesKey,
112
+ };
113
+
114
+ return {
115
+ accountId,
116
+ name: merged.name?.trim() || undefined,
117
+ enabled,
118
+ configured,
119
+ mode,
120
+ token,
121
+ encodingAESKey,
122
+ receiveId,
123
+ corpId,
124
+ corpSecret,
125
+ agentId,
126
+ callbackToken,
127
+ callbackAesKey,
128
+ config: mergedConfig,
129
+ };
130
+ }
131
+
132
+ export function listEnabledWecomAccounts(cfg: ClawdbotConfig): ResolvedWecomAccount[] {
133
+ return listWecomAccountIds(cfg)
134
+ .map((accountId) => resolveWecomAccount({ cfg, accountId }))
135
+ .filter((account) => account.enabled);
136
+ }
@@ -0,0 +1,221 @@
1
+ import type {
2
+ ChannelAccountSnapshot,
3
+ ChannelPlugin,
4
+ ClawdbotConfig,
5
+ } from "openclaw/plugin-sdk";
6
+ import {
7
+ buildChannelConfigSchema,
8
+ DEFAULT_ACCOUNT_ID,
9
+ deleteAccountFromConfigSection,
10
+ formatPairingApproveHint,
11
+ setAccountEnabledInConfigSection,
12
+ } from "openclaw/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: bot API + internal app (dual mode).",
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: true,
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: [
68
+ "name",
69
+ "webhookPath",
70
+ "token",
71
+ "encodingAESKey",
72
+ "receiveId",
73
+ "corpId",
74
+ "corpSecret",
75
+ "agentId",
76
+ "callbackToken",
77
+ "callbackAesKey",
78
+ "welcomeText",
79
+ ],
80
+ accountId,
81
+ }),
82
+ isConfigured: (account) => account.configured,
83
+ describeAccount: (account): ChannelAccountSnapshot => ({
84
+ accountId: account.accountId,
85
+ name: account.name,
86
+ enabled: account.enabled,
87
+ configured: account.configured,
88
+ webhookPath: account.config.webhookPath ?? "/wecom",
89
+ }),
90
+ resolveAllowFrom: ({ cfg, accountId }) => {
91
+ const account = resolveWecomAccount({ cfg: cfg as ClawdbotConfig, accountId });
92
+ return (account.config.dm?.allowFrom ?? []).map((entry) => String(entry));
93
+ },
94
+ formatAllowFrom: ({ allowFrom }) =>
95
+ allowFrom
96
+ .map((entry) => String(entry).trim())
97
+ .filter(Boolean)
98
+ .map((entry) => entry.toLowerCase()),
99
+ },
100
+ security: {
101
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
102
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
103
+ const useAccountPath = Boolean((cfg as ClawdbotConfig).channels?.wecom?.accounts?.[resolvedAccountId]);
104
+ const basePath = useAccountPath ? `channels.wecom.accounts.${resolvedAccountId}.` : "channels.wecom.";
105
+ return {
106
+ policy: account.config.dm?.policy ?? "pairing",
107
+ allowFrom: (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)),
108
+ policyPath: `${basePath}dm.policy`,
109
+ allowFromPath: `${basePath}dm.allowFrom`,
110
+ approveHint: formatPairingApproveHint("wecom"),
111
+ normalizeEntry: (raw) => raw.trim().toLowerCase(),
112
+ };
113
+ },
114
+ },
115
+ groups: {
116
+ resolveRequireMention: () => true,
117
+ },
118
+ threading: {
119
+ resolveReplyToMode: () => "off",
120
+ },
121
+ messaging: {
122
+ normalizeTarget: normalizeWecomMessagingTarget,
123
+ targetResolver: {
124
+ looksLikeId: (raw) => Boolean(raw.trim()),
125
+ hint: "<userid|chatid>",
126
+ },
127
+ },
128
+ outbound: {
129
+ deliveryMode: "direct",
130
+ chunkerMode: "text",
131
+ textChunkLimit: 20480,
132
+ sendText: async () => {
133
+ return {
134
+ channel: "wecom",
135
+ ok: false,
136
+ messageId: "",
137
+ error: new Error("WeCom outbound sendText not wired yet (skeleton)."),
138
+ };
139
+ },
140
+ },
141
+ status: {
142
+ defaultRuntime: {
143
+ accountId: DEFAULT_ACCOUNT_ID,
144
+ running: false,
145
+ lastStartAt: null,
146
+ lastStopAt: null,
147
+ lastError: null,
148
+ },
149
+ buildChannelSummary: ({ snapshot }) => ({
150
+ configured: snapshot.configured ?? false,
151
+ running: snapshot.running ?? false,
152
+ webhookPath: snapshot.webhookPath ?? null,
153
+ lastStartAt: snapshot.lastStartAt ?? null,
154
+ lastStopAt: snapshot.lastStopAt ?? null,
155
+ lastError: snapshot.lastError ?? null,
156
+ lastInboundAt: snapshot.lastInboundAt ?? null,
157
+ lastOutboundAt: snapshot.lastOutboundAt ?? null,
158
+ probe: snapshot.probe,
159
+ lastProbeAt: snapshot.lastProbeAt ?? null,
160
+ }),
161
+ probeAccount: async () => ({ ok: true }),
162
+ buildAccountSnapshot: ({ account, runtime }) => ({
163
+ accountId: account.accountId,
164
+ name: account.name,
165
+ enabled: account.enabled,
166
+ configured: account.configured,
167
+ webhookPath: account.config.webhookPath ?? "/wecom",
168
+ running: runtime?.running ?? false,
169
+ lastStartAt: runtime?.lastStartAt ?? null,
170
+ lastStopAt: runtime?.lastStopAt ?? null,
171
+ lastError: runtime?.lastError ?? null,
172
+ lastInboundAt: runtime?.lastInboundAt ?? null,
173
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
174
+ dmPolicy: account.config.dm?.policy ?? "pairing",
175
+ }),
176
+ },
177
+ gateway: {
178
+ startAccount: async (ctx) => {
179
+ const account = ctx.account;
180
+ if (!account.configured) {
181
+ ctx.log?.warn(`[${account.accountId}] wecom not configured; skipping webhook registration`);
182
+ ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
183
+ return { stop: () => {} };
184
+ }
185
+ const path = (account.config.webhookPath ?? "/wecom").trim();
186
+ const unregister = registerWecomWebhookTarget({
187
+ account,
188
+ config: ctx.cfg as ClawdbotConfig,
189
+ runtime: ctx.runtime,
190
+ core: ({} as unknown) as any,
191
+ path,
192
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
193
+ });
194
+ ctx.log?.info(`[${account.accountId}] wecom webhook registered at ${path}`);
195
+ ctx.setStatus({
196
+ accountId: account.accountId,
197
+ running: true,
198
+ configured: true,
199
+ webhookPath: path,
200
+ lastStartAt: Date.now(),
201
+ });
202
+ return {
203
+ stop: () => {
204
+ unregister();
205
+ ctx.setStatus({
206
+ accountId: account.accountId,
207
+ running: false,
208
+ lastStopAt: Date.now(),
209
+ });
210
+ },
211
+ };
212
+ },
213
+ stopAccount: async (ctx) => {
214
+ ctx.setStatus({
215
+ accountId: ctx.account.accountId,
216
+ running: false,
217
+ lastStopAt: Date.now(),
218
+ });
219
+ },
220
+ },
221
+ };
@@ -0,0 +1,96 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
+
3
+ import { getWecomRuntime } from "./runtime.js";
4
+ import { listWecomAccountIds } from "./accounts.js";
5
+ import { sendWecomText } from "./wecom-api.js";
6
+ import type { ResolvedWecomAccount } from "./types.js";
7
+
8
+ export type CommandContext = {
9
+ account: ResolvedWecomAccount;
10
+ fromUser: string;
11
+ chatId?: string;
12
+ isGroup: boolean;
13
+ cfg: ClawdbotConfig;
14
+ log?: (message: string) => void;
15
+ statusSink?: (patch: { lastOutboundAt?: number }) => void;
16
+ };
17
+
18
+ async function sendAndRecord(ctx: CommandContext, text: string): Promise<void> {
19
+ await sendWecomText({ account: ctx.account, toUser: ctx.fromUser, chatId: ctx.isGroup ? ctx.chatId : undefined, text });
20
+ ctx.statusSink?.({ lastOutboundAt: Date.now() });
21
+ ctx.log?.(`[wecom] command reply sent to ${ctx.fromUser}`);
22
+ }
23
+
24
+ async function handleHelp(ctx: CommandContext): Promise<void> {
25
+ const helpText = `🤖 WeCom 助手使用帮助
26
+
27
+ 可用命令:
28
+ /help - 显示此帮助信息
29
+ /clear - 清除会话历史,开始新对话
30
+ /status - 查看系统状态
31
+
32
+ 直接发送消息即可与 AI 对话。`;
33
+ await sendAndRecord(ctx, helpText);
34
+ }
35
+
36
+ async function handleStatus(ctx: CommandContext): Promise<void> {
37
+ const accounts = listWecomAccountIds(ctx.cfg);
38
+ const statusText = `📊 系统状态
39
+
40
+ 渠道:WeCom
41
+ 会话ID:${ctx.isGroup ? `wecom:group:${ctx.chatId}` : `wecom:${ctx.fromUser}`}
42
+ 账户ID:${ctx.account.accountId}
43
+ 已配置账户:${accounts.join(", ") || "default"}
44
+
45
+ 功能状态:
46
+ ✅ Bot 模式
47
+ ✅ App 模式
48
+ ✅ 文本消息
49
+ ✅ 图片接收
50
+ ✅ 语音识别
51
+ ✅ 消息分段
52
+ ✅ API 限流`;
53
+ await sendAndRecord(ctx, statusText);
54
+ }
55
+
56
+ async function handleClear(ctx: CommandContext): Promise<void> {
57
+ const runtime = getWecomRuntime();
58
+ const peerId = ctx.isGroup ? (ctx.chatId || "unknown") : ctx.fromUser;
59
+ const route = runtime.channel.routing.resolveAgentRoute({
60
+ cfg: ctx.cfg,
61
+ channel: "wecom",
62
+ accountId: ctx.account.accountId,
63
+ peer: { kind: ctx.isGroup ? "group" : "dm", id: peerId },
64
+ });
65
+ const storePath = runtime.channel.session.resolveStorePath(ctx.cfg.session?.store, {
66
+ agentId: route.agentId,
67
+ });
68
+
69
+ const clearFn = (runtime.channel.session as any).clearSession ?? (runtime.channel.session as any).deleteSession;
70
+ if (typeof clearFn === "function") {
71
+ await clearFn.call(runtime.channel.session, {
72
+ storePath,
73
+ sessionKey: route.sessionKey,
74
+ });
75
+ await sendAndRecord(ctx, "✅ 会话已清除,我们可以开始新的对话了!");
76
+ return;
77
+ }
78
+
79
+ await sendAndRecord(ctx, "✅ 会话已重置,请开始新的对话。");
80
+ }
81
+
82
+ const COMMANDS: Record<string, (ctx: CommandContext) => Promise<void>> = {
83
+ "/help": handleHelp,
84
+ "/status": handleStatus,
85
+ "/clear": handleClear,
86
+ };
87
+
88
+ export async function handleCommand(cmd: string, ctx: CommandContext): Promise<boolean> {
89
+ const key = cmd.trim().split(/\s+/)[0]?.toLowerCase();
90
+ if (!key) return false;
91
+ const handler = COMMANDS[key];
92
+ if (!handler) return false;
93
+ ctx.log?.(`[wecom] handling command ${key}`);
94
+ await handler(ctx);
95
+ return true;
96
+ }
@@ -0,0 +1,84 @@
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
+ const accountSchema = z.object({
14
+ name: z.string().optional(),
15
+ enabled: z.boolean().optional(),
16
+ mode: z.enum(["bot", "app", "both"]).optional(),
17
+ webhookPath: z.string().optional(),
18
+ welcomeText: z.string().optional(),
19
+ dm: dmSchema,
20
+
21
+ // Bot API
22
+ token: z.string().optional(),
23
+ encodingAESKey: z.string().optional(),
24
+ receiveId: z.string().optional(),
25
+
26
+ // Internal app
27
+ corpId: z.string().optional(),
28
+ corpSecret: z.string().optional(),
29
+ agentId: z.union([z.string(), z.number()]).optional(),
30
+ callbackToken: z.string().optional(),
31
+ callbackAesKey: z.string().optional(),
32
+
33
+ media: z.object({
34
+ tempDir: z.string().optional(),
35
+ retentionHours: z.number().optional(),
36
+ cleanupOnStart: z.boolean().optional(),
37
+ maxBytes: z.number().optional(),
38
+ }).optional(),
39
+
40
+ network: z.object({
41
+ timeoutMs: z.number().optional(),
42
+ retries: z.number().optional(),
43
+ retryDelayMs: z.number().optional(),
44
+ }).optional(),
45
+
46
+ botMediaBridge: z.boolean().optional(),
47
+ });
48
+
49
+ export const WecomConfigSchema = z.object({
50
+ name: z.string().optional(),
51
+ enabled: z.boolean().optional(),
52
+ mode: z.enum(["bot", "app", "both"]).optional(),
53
+ webhookPath: z.string().optional(),
54
+ welcomeText: z.string().optional(),
55
+ dm: dmSchema,
56
+
57
+ token: z.string().optional(),
58
+ encodingAESKey: z.string().optional(),
59
+ receiveId: z.string().optional(),
60
+
61
+ corpId: z.string().optional(),
62
+ corpSecret: z.string().optional(),
63
+ agentId: z.union([z.string(), z.number()]).optional(),
64
+ callbackToken: z.string().optional(),
65
+ callbackAesKey: z.string().optional(),
66
+
67
+ media: z.object({
68
+ tempDir: z.string().optional(),
69
+ retentionHours: z.number().optional(),
70
+ cleanupOnStart: z.boolean().optional(),
71
+ maxBytes: z.number().optional(),
72
+ }).optional(),
73
+
74
+ network: z.object({
75
+ timeoutMs: z.number().optional(),
76
+ retries: z.number().optional(),
77
+ retryDelayMs: z.number().optional(),
78
+ }).optional(),
79
+
80
+ botMediaBridge: z.boolean().optional(),
81
+
82
+ defaultAccount: z.string().optional(),
83
+ accounts: z.object({}).catchall(accountSchema).optional(),
84
+ });
@@ -0,0 +1,129 @@
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
+ const WECOM_PKCS7_BLOCK_SIZE = 32;
15
+
16
+ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
17
+ const mod = buf.length % blockSize;
18
+ const pad = mod === 0 ? blockSize : blockSize - mod;
19
+ const padByte = Buffer.from([pad]);
20
+ return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
21
+ }
22
+
23
+ function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
24
+ if (buf.length === 0) throw new Error("invalid pkcs7 payload");
25
+ const pad = buf[buf.length - 1]!;
26
+ if (pad < 1 || pad > blockSize) {
27
+ throw new Error("invalid pkcs7 padding");
28
+ }
29
+ if (pad > buf.length) {
30
+ throw new Error("invalid pkcs7 payload");
31
+ }
32
+ for (let i = 0; i < pad; i += 1) {
33
+ if (buf[buf.length - 1 - i] !== pad) {
34
+ throw new Error("invalid pkcs7 padding");
35
+ }
36
+ }
37
+ return buf.subarray(0, buf.length - pad);
38
+ }
39
+
40
+ function sha1Hex(input: string): string {
41
+ return crypto.createHash("sha1").update(input).digest("hex");
42
+ }
43
+
44
+ export function computeWecomMsgSignature(params: {
45
+ token: string;
46
+ timestamp: string;
47
+ nonce: string;
48
+ encrypt: string;
49
+ }): string {
50
+ const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
51
+ .map((v) => String(v ?? ""))
52
+ .sort();
53
+ return sha1Hex(parts.join(""));
54
+ }
55
+
56
+ export function verifyWecomSignature(params: {
57
+ token: string;
58
+ timestamp: string;
59
+ nonce: string;
60
+ encrypt: string;
61
+ signature: string;
62
+ }): boolean {
63
+ const expected = computeWecomMsgSignature({
64
+ token: params.token,
65
+ timestamp: params.timestamp,
66
+ nonce: params.nonce,
67
+ encrypt: params.encrypt,
68
+ });
69
+ return expected === params.signature;
70
+ }
71
+
72
+ export function decryptWecomEncrypted(params: {
73
+ encodingAESKey: string;
74
+ receiveId?: string;
75
+ encrypt: string;
76
+ }): string {
77
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
78
+ const iv = aesKey.subarray(0, 16);
79
+ const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
80
+ decipher.setAutoPadding(false);
81
+ const decryptedPadded = Buffer.concat([
82
+ decipher.update(Buffer.from(params.encrypt, "base64")),
83
+ decipher.final(),
84
+ ]);
85
+ const decrypted = pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
86
+
87
+ if (decrypted.length < 20) {
88
+ throw new Error(`invalid decrypted payload (expected at least 20 bytes, got ${decrypted.length})`);
89
+ }
90
+
91
+ const msgLen = decrypted.readUInt32BE(16);
92
+ const msgStart = 20;
93
+ const msgEnd = msgStart + msgLen;
94
+ if (msgEnd > decrypted.length) {
95
+ throw new Error(`invalid decrypted msg length (msgEnd=${msgEnd}, payloadLength=${decrypted.length})`);
96
+ }
97
+ const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");
98
+
99
+ const receiveId = params.receiveId ?? "";
100
+ if (receiveId) {
101
+ const trailing = decrypted.subarray(msgEnd).toString("utf8");
102
+ if (trailing !== receiveId) {
103
+ throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
104
+ }
105
+ }
106
+
107
+ return msg;
108
+ }
109
+
110
+ export function encryptWecomPlaintext(params: {
111
+ encodingAESKey: string;
112
+ receiveId?: string;
113
+ plaintext: string;
114
+ }): string {
115
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
116
+ const iv = aesKey.subarray(0, 16);
117
+ const random16 = crypto.randomBytes(16);
118
+ const msg = Buffer.from(params.plaintext ?? "", "utf8");
119
+ const msgLen = Buffer.alloc(4);
120
+ msgLen.writeUInt32BE(msg.length, 0);
121
+ const receiveId = Buffer.from(params.receiveId ?? "", "utf8");
122
+
123
+ const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
124
+ const padded = pkcs7Pad(raw, WECOM_PKCS7_BLOCK_SIZE);
125
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
126
+ cipher.setAutoPadding(false);
127
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
128
+ return encrypted.toString("base64");
129
+ }