@zhin.js/adapter-wechat-mp 3.0.1 → 3.0.3

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/index.ts CHANGED
@@ -1,39 +1,45 @@
1
- /**
2
- * 微信公众号适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin, type Plugin, type Context } from "zhin.js";
5
- import type { Router } from "@zhin.js/host-router";
6
- import { WeChatMPAdapter } from "./adapter.js";
1
+ export {
2
+ buildTextReply,
3
+ computeSignatureHash,
4
+ decryptEchostr,
5
+ decryptMessage,
6
+ encryptMessage,
7
+ extractOutboundText,
8
+ formatCustomerServiceBody,
9
+ formatInboundContent,
10
+ isEncryptedEchostr,
11
+ normalizeEchostrParam,
12
+ parseXMLMessage,
13
+ queryParam,
14
+ readTextBody,
15
+ resolveEventPassiveReply,
16
+ resolveWeChatMpConfig,
17
+ verifySignature,
18
+ type ResolvedWeChatMpConfig,
19
+ type TokenResponse,
20
+ type WeChatAPIResponse,
21
+ type WeChatMessage,
22
+ type WeChatMpAdapterConfig,
23
+ type WeChatWireSegment,
24
+ } from './protocol.js';
7
25
 
8
- declare module "zhin.js" {
9
- namespace Plugin {
10
- interface Contexts {
11
- router: import("@zhin.js/host-router").Router;
12
- }
13
- }
14
- interface Adapters {
15
- "wechat-mp": WeChatMPAdapter;
16
- }
17
- }
26
+ export {
27
+ getPassiveReplyCapture,
28
+ recordPassiveReplyText,
29
+ runWithPassiveReplyCapture,
30
+ type PassiveReplyCapture,
31
+ } from './passive-reply.js';
18
32
 
19
- export * from "./types.js";
20
- export { WeChatMPEndpoint } from "./endpoint.js";
21
- export { WeChatMPAdapter } from "./adapter.js";
33
+ export {
34
+ WeChatMpEndpoint,
35
+ type WeChatMpEndpointOptions,
36
+ type WeChatMpFetch,
37
+ } from './endpoint.js';
22
38
 
23
- const plugin = usePlugin();
24
- const { provide, useContext } = plugin;
25
-
26
- useContext("router", (router: Router) => {
27
- provide({
28
- name: "wechat-mp",
29
- description: "WeChat MP Endpoint Adapter",
30
- mounted: async (p: Plugin) => {
31
- const adapter = new WeChatMPAdapter(p, router);
32
- await adapter.start();
33
- return adapter;
34
- },
35
- dispose: async (adapter: WeChatMPAdapter) => {
36
- await adapter.stop();
37
- },
38
- } as Context<"wechat-mp">);
39
- });
39
+ export {
40
+ registerWeChatMpWebhookRoutes,
41
+ handleWeChatMpVerification,
42
+ handleWeChatMpMessage,
43
+ collectPassiveReply,
44
+ type WeChatMpWebhookHandler,
45
+ } from './webhook.js';
@@ -0,0 +1,477 @@
1
+ /**
2
+ * WeChat Official Account (MP) protocol helpers — no legacy Adapter/Endpoint.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+
6
+ import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
7
+ import type { IncomingMessage } from 'node:http';
8
+ import * as xml2js from 'xml2js';
9
+
10
+ export interface WeChatMpAdapterConfig {
11
+ readonly name?: string;
12
+ readonly appId?: string;
13
+ readonly appSecret?: string;
14
+ readonly token?: string;
15
+ readonly encodingAESKey?: string;
16
+ readonly path?: string;
17
+ readonly encrypt?: boolean;
18
+ /**
19
+ * plain:明文入站/出站
20
+ * compatible:入站可解密,被动回复用明文(微信兼容模式推荐)
21
+ * secure:入站/出站均加密
22
+ */
23
+ readonly encryptMode?: 'plain' | 'compatible' | 'secure';
24
+ /**
25
+ * passive:订阅号默认,在 webhook 响应内被动回复(5 秒内)
26
+ * customer_service:走客服消息 API(需接口权限)
27
+ */
28
+ readonly replyMode?: 'passive' | 'customer_service';
29
+ /** 被动回复等待入站处理的最长时间(毫秒),默认 4500 */
30
+ readonly passiveReplyTimeoutMs?: number;
31
+ /** Transitional: legacy root `endpoints[]` with `context: wechat-mp`. */
32
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedWeChatMpConfig> & {
33
+ readonly context?: string;
34
+ }>;
35
+ }
36
+
37
+ export interface ResolvedWeChatMpConfig {
38
+ readonly context: 'wechat-mp';
39
+ readonly name: string;
40
+ readonly appId: string;
41
+ readonly appSecret: string;
42
+ readonly token: string;
43
+ readonly encodingAESKey?: string;
44
+ readonly path: string;
45
+ readonly encrypt: boolean;
46
+ readonly encryptMode: 'plain' | 'compatible' | 'secure';
47
+ readonly replyMode: 'passive' | 'customer_service';
48
+ readonly passiveReplyTimeoutMs: number;
49
+ }
50
+
51
+ export interface WeChatMessage {
52
+ readonly ToUserName: string;
53
+ readonly FromUserName: string;
54
+ readonly CreateTime: number;
55
+ readonly MsgType: string;
56
+ readonly MsgId?: string;
57
+ readonly Content?: string;
58
+ readonly PicUrl?: string;
59
+ readonly MediaId?: string;
60
+ readonly Format?: string;
61
+ readonly Recognition?: string;
62
+ readonly ThumbMediaId?: string;
63
+ readonly Location_X?: string;
64
+ readonly Location_Y?: string;
65
+ readonly Scale?: string;
66
+ readonly Label?: string;
67
+ readonly Title?: string;
68
+ readonly Description?: string;
69
+ readonly Url?: string;
70
+ readonly Event?: string;
71
+ readonly EventKey?: string;
72
+ readonly Encrypt?: string;
73
+ }
74
+
75
+ export interface TokenResponse {
76
+ readonly access_token: string;
77
+ readonly expires_in: number;
78
+ }
79
+
80
+ export interface WeChatAPIResponse {
81
+ readonly errcode?: number;
82
+ readonly errmsg?: string;
83
+ readonly msgid?: number;
84
+ }
85
+
86
+ export interface WeChatWireSegment {
87
+ readonly type: string;
88
+ readonly data?: Record<string, unknown>;
89
+ }
90
+
91
+ export function resolveWeChatMpConfig(config: WeChatMpAdapterConfig = {}): ResolvedWeChatMpConfig {
92
+ const entry = config.endpoints?.find((item) => item.context === 'wechat-mp');
93
+ const appId = config.appId ?? entry?.appId ?? process.env.WECHAT_APP_ID;
94
+ const appSecret = config.appSecret ?? entry?.appSecret ?? process.env.WECHAT_APP_SECRET;
95
+ const token = config.token ?? entry?.token ?? process.env.WECHAT_TOKEN;
96
+ if (!appId || !appSecret || !token) {
97
+ throw new TypeError(
98
+ 'WeChat MP adapter requires appId + appSecret + token (plugins.<key> or endpoints with context: wechat-mp)',
99
+ );
100
+ }
101
+ const name = (typeof config.name === 'string' && config.name)
102
+ || (typeof entry?.name === 'string' && entry.name)
103
+ || process.env.WECHAT_BOT_NAME
104
+ || 'wechat-mp-bot';
105
+ const path = config.path ?? entry?.path ?? '/wechat/webhook';
106
+ const encodingAESKey = config.encodingAESKey ?? entry?.encodingAESKey;
107
+ const encrypt = config.encrypt ?? entry?.encrypt ?? false;
108
+ const encryptMode = config.encryptMode ?? entry?.encryptMode ?? (encrypt ? 'compatible' : 'plain');
109
+ const replyMode = config.replyMode ?? entry?.replyMode ?? 'passive';
110
+ const passiveReplyTimeoutMs = config.passiveReplyTimeoutMs
111
+ ?? entry?.passiveReplyTimeoutMs
112
+ ?? 4500;
113
+ return {
114
+ context: 'wechat-mp',
115
+ name,
116
+ appId,
117
+ appSecret,
118
+ token,
119
+ encodingAESKey,
120
+ path: path.startsWith('/') ? path : `/${path}`,
121
+ encrypt: !!encrypt,
122
+ encryptMode,
123
+ replyMode,
124
+ passiveReplyTimeoutMs,
125
+ };
126
+ }
127
+
128
+ export function queryParam(value: string | null | undefined): string {
129
+ return value ?? '';
130
+ }
131
+
132
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
133
+ export function normalizeEchostrParam(echostr: string): string {
134
+ return echostr.replace(/ /g, '+');
135
+ }
136
+
137
+ export function computeSignatureHash(
138
+ token: string,
139
+ params: { readonly timestamp: string; readonly nonce: string; readonly echostr?: string },
140
+ ): string {
141
+ const { timestamp, nonce, echostr } = params;
142
+ const arr = echostr
143
+ ? [token, timestamp, nonce, echostr]
144
+ : [token, timestamp, nonce];
145
+ arr.sort();
146
+ return createHash('sha1').update(arr.join('')).digest('hex');
147
+ }
148
+
149
+ export function verifySignature(
150
+ token: string,
151
+ params: {
152
+ readonly signature: string;
153
+ readonly timestamp: string;
154
+ readonly nonce: string;
155
+ readonly echostr?: string;
156
+ },
157
+ ): boolean {
158
+ const { signature, timestamp, nonce, echostr } = params;
159
+ if (!signature || !timestamp || !nonce) return false;
160
+ return computeSignatureHash(token, { timestamp, nonce, echostr }) === signature;
161
+ }
162
+
163
+ export function getAESKey(encodingAESKey: string): Buffer {
164
+ return Buffer.from(`${encodingAESKey}=`, 'base64');
165
+ }
166
+
167
+ /** 微信安全模式加密 echostr 为较长 Base64;明文/兼容模式多为短字符串 */
168
+ export function isEncryptedEchostr(echostr: string): boolean {
169
+ if (echostr.length < 32) return false;
170
+ return /^[A-Za-z0-9+/]+={0,2}$/.test(echostr);
171
+ }
172
+
173
+ export function decryptEchostr(
174
+ encrypted: string,
175
+ encodingAESKey: string,
176
+ appId: string,
177
+ ): string {
178
+ const aesKey = getAESKey(encodingAESKey);
179
+ const iv = aesKey.subarray(0, 16);
180
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
181
+ decipher.setAutoPadding(false);
182
+
183
+ const decrypted = Buffer.concat([
184
+ decipher.update(Buffer.from(encrypted, 'base64')),
185
+ decipher.final(),
186
+ ]);
187
+
188
+ const pad = decrypted[decrypted.length - 1]!;
189
+ const content = decrypted.subarray(0, decrypted.length - pad);
190
+
191
+ const msgLen = content.readUInt32BE(16);
192
+ const plain = content.subarray(20, 20 + msgLen).toString('utf8');
193
+ const gotAppId = content.subarray(20 + msgLen).toString('utf8');
194
+
195
+ if (gotAppId !== appId) {
196
+ throw new Error(`AppID mismatch: expected ${appId}, got ${gotAppId}`);
197
+ }
198
+ return plain;
199
+ }
200
+
201
+ export async function parseXMLMessage(xmlString: string): Promise<WeChatMessage | null> {
202
+ try {
203
+ const parser = new xml2js.Parser({ explicitArray: false, ignoreAttrs: true });
204
+ const result = await parser.parseStringPromise(xmlString);
205
+ return result.xml as WeChatMessage;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ export async function decryptMessage(
212
+ encryptedXml: string,
213
+ msgSignature: string,
214
+ timestamp: string,
215
+ nonce: string,
216
+ token: string,
217
+ encodingAESKey: string,
218
+ appId: string,
219
+ ): Promise<string> {
220
+ const parsed = await parseXMLMessage(encryptedXml);
221
+ const encrypt = parsed?.Encrypt;
222
+ if (!encrypt) throw new Error('Missing Encrypt field in encrypted message');
223
+
224
+ const expected = createHash('sha1')
225
+ .update([token, timestamp, nonce, encrypt].sort().join(''))
226
+ .digest('hex');
227
+ if (expected !== msgSignature) {
228
+ throw new Error('msg_signature verification failed');
229
+ }
230
+
231
+ const aesKey = getAESKey(encodingAESKey);
232
+ const iv = aesKey.subarray(0, 16);
233
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
234
+ decipher.setAutoPadding(false);
235
+
236
+ const decrypted = Buffer.concat([
237
+ decipher.update(Buffer.from(encrypt, 'base64')),
238
+ decipher.final(),
239
+ ]);
240
+
241
+ const pad = decrypted[decrypted.length - 1]!;
242
+ const content = decrypted.subarray(0, decrypted.length - pad);
243
+ const msgLen = content.readUInt32BE(16);
244
+ const xmlContent = content.subarray(20, 20 + msgLen).toString('utf8');
245
+ const gotAppId = content.subarray(20 + msgLen).toString('utf8');
246
+
247
+ if (gotAppId !== appId) {
248
+ throw new Error(`AppID mismatch: expected ${appId}, got ${gotAppId}`);
249
+ }
250
+ return xmlContent;
251
+ }
252
+
253
+ export function encryptMessage(
254
+ replyXml: string,
255
+ token: string,
256
+ encodingAESKey: string,
257
+ appId: string,
258
+ requestTimestamp?: string,
259
+ ): string {
260
+ const aesKey = getAESKey(encodingAESKey);
261
+ const iv = aesKey.subarray(0, 16);
262
+
263
+ const random = randomBytes(16);
264
+ const msgBuf = Buffer.from(replyXml, 'utf8');
265
+ const appIdBuf = Buffer.from(appId, 'utf8');
266
+ const lenBuf = Buffer.alloc(4);
267
+ lenBuf.writeUInt32BE(msgBuf.length, 0);
268
+
269
+ const plaintext = Buffer.concat([random, lenBuf, msgBuf, appIdBuf]);
270
+ const blockSize = 32;
271
+ const padLen = blockSize - (plaintext.length % blockSize);
272
+ const padBuf = Buffer.alloc(padLen, padLen);
273
+ const padded = Buffer.concat([plaintext, padBuf]);
274
+
275
+ const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
276
+ cipher.setAutoPadding(false);
277
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
278
+ const encryptStr = encrypted.toString('base64');
279
+
280
+ const timestamp = requestTimestamp || Math.floor(Date.now() / 1000).toString();
281
+ const nonce = randomBytes(8).toString('hex');
282
+ const signature = createHash('sha1')
283
+ .update([token, timestamp, nonce, encryptStr].sort().join(''))
284
+ .digest('hex');
285
+
286
+ return [
287
+ '<xml>',
288
+ `<Encrypt><![CDATA[${encryptStr}]]></Encrypt>`,
289
+ `<MsgSignature><![CDATA[${signature}]]></MsgSignature>`,
290
+ `<TimeStamp>${timestamp}</TimeStamp>`,
291
+ `<Nonce><![CDATA[${nonce}]]></Nonce>`,
292
+ '</xml>',
293
+ ].join('\n');
294
+ }
295
+
296
+ export function buildTextReply(
297
+ wechatMsg: Pick<WeChatMessage, 'FromUserName' | 'ToUserName'>,
298
+ content: string,
299
+ ): string {
300
+ const cdata = (value: string) => value.replace(/]]>/g, ']]]]><![CDATA[>');
301
+ const createTime = Math.floor(Date.now() / 1000);
302
+ return [
303
+ '<xml>',
304
+ `<ToUserName><![CDATA[${cdata(wechatMsg.FromUserName)}]]></ToUserName>`,
305
+ `<FromUserName><![CDATA[${cdata(wechatMsg.ToUserName)}]]></FromUserName>`,
306
+ `<CreateTime>${createTime}</CreateTime>`,
307
+ `<MsgType><![CDATA[text]]></MsgType>`,
308
+ `<Content><![CDATA[${cdata(content)}]]></Content>`,
309
+ '</xml>',
310
+ ].join('');
311
+ }
312
+
313
+ /** Build inbound text for MessageGateway.receive. */
314
+ export function formatInboundContent(msg: WeChatMessage): string {
315
+ switch (msg.MsgType) {
316
+ case 'text':
317
+ return msg.Content || '(空消息)';
318
+ case 'image':
319
+ return msg.PicUrl ? `[image: ${msg.PicUrl}]` : '[image]';
320
+ case 'voice':
321
+ return msg.Recognition
322
+ ? msg.Recognition
323
+ : `[voice${msg.Format ? `: ${msg.Format}` : ''}]`;
324
+ case 'video':
325
+ case 'shortvideo':
326
+ return '[video]';
327
+ case 'location':
328
+ return `[location: ${msg.Location_X},${msg.Location_Y}${msg.Label ? ` ${msg.Label}` : ''}]`;
329
+ case 'link':
330
+ return `[link: ${msg.Title ?? ''}${msg.Url ? ` ${msg.Url}` : ''}]`;
331
+ case 'event':
332
+ return `[event: ${msg.Event ?? ''}${msg.EventKey ? ` ${msg.EventKey}` : ''}]`;
333
+ default:
334
+ return `[不支持的消息类型: ${msg.MsgType}]`;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Built-in passive XML for subscribe etc. Empty string = fall through to gateway.
340
+ */
341
+ export function resolveEventPassiveReply(msg: WeChatMessage): string {
342
+ if (msg.MsgType !== 'event') return '';
343
+ if (msg.Event === 'subscribe') {
344
+ return buildTextReply(msg, '感谢关注!');
345
+ }
346
+ return '';
347
+ }
348
+
349
+ /**
350
+ * Wire-encode an already-rendered outbound payload into客服消息 JSON body.
351
+ */
352
+ export function formatCustomerServiceBody(
353
+ target: string,
354
+ payload: unknown,
355
+ ): Record<string, unknown> {
356
+ const messageData: Record<string, unknown> = {
357
+ touser: target,
358
+ msgtype: 'text',
359
+ text: { content: '' },
360
+ };
361
+
362
+ if (typeof payload === 'string') {
363
+ (messageData.text as { content: string }).content = payload;
364
+ return messageData;
365
+ }
366
+
367
+ const segments: Array<string | WeChatWireSegment> = Array.isArray(payload)
368
+ ? payload as Array<string | WeChatWireSegment>
369
+ : payload && typeof payload === 'object' && 'type' in (payload as object)
370
+ ? [payload as WeChatWireSegment]
371
+ : [];
372
+
373
+ if (segments.length === 0) {
374
+ (messageData.text as { content: string }).content = payload == null
375
+ ? ''
376
+ : typeof payload === 'object'
377
+ ? JSON.stringify(payload)
378
+ : String(payload);
379
+ return messageData;
380
+ }
381
+
382
+ const textParts: string[] = [];
383
+ let hasMedia = false;
384
+
385
+ for (const item of segments) {
386
+ if (typeof item === 'string') {
387
+ textParts.push(item);
388
+ continue;
389
+ }
390
+ const data = item.data ?? {};
391
+ switch (item.type) {
392
+ case 'text':
393
+ textParts.push(String(data.text ?? data.content ?? ''));
394
+ break;
395
+ case 'image':
396
+ if (!hasMedia && data.mediaId) {
397
+ messageData.msgtype = 'image';
398
+ messageData.image = { media_id: data.mediaId };
399
+ delete messageData.text;
400
+ hasMedia = true;
401
+ }
402
+ break;
403
+ case 'voice':
404
+ if (!hasMedia && data.mediaId) {
405
+ messageData.msgtype = 'voice';
406
+ messageData.voice = { media_id: data.mediaId };
407
+ delete messageData.text;
408
+ hasMedia = true;
409
+ }
410
+ break;
411
+ case 'video':
412
+ if (!hasMedia && data.mediaId) {
413
+ messageData.msgtype = 'video';
414
+ messageData.video = {
415
+ media_id: data.mediaId,
416
+ title: data.title || '',
417
+ description: data.description || '',
418
+ };
419
+ delete messageData.text;
420
+ hasMedia = true;
421
+ }
422
+ break;
423
+ default:
424
+ break;
425
+ }
426
+ }
427
+
428
+ if (!hasMedia && textParts.length > 0 && messageData.text) {
429
+ (messageData.text as { content: string }).content = textParts.join('\n');
430
+ }
431
+ return messageData;
432
+ }
433
+
434
+ /** Extract plain text from a rendered outbound payload (for passive XML). */
435
+ export function extractOutboundText(payload: unknown): string {
436
+ if (typeof payload === 'string') return payload;
437
+ if (!Array.isArray(payload)) {
438
+ if (payload && typeof payload === 'object' && 'type' in (payload as object)) {
439
+ const seg = payload as WeChatWireSegment;
440
+ if (seg.type === 'text') return String(seg.data?.text ?? seg.data?.content ?? '');
441
+ }
442
+ return payload == null ? '' : String(payload);
443
+ }
444
+ const parts: string[] = [];
445
+ for (const item of payload) {
446
+ if (typeof item === 'string') {
447
+ parts.push(item);
448
+ continue;
449
+ }
450
+ if (item && typeof item === 'object' && 'type' in item) {
451
+ const seg = item as WeChatWireSegment;
452
+ if (seg.type === 'text') {
453
+ parts.push(String(seg.data?.text ?? seg.data?.content ?? ''));
454
+ }
455
+ }
456
+ }
457
+ return parts.join('\n');
458
+ }
459
+
460
+ export async function readTextBody(
461
+ request: IncomingMessage,
462
+ options: { readonly limit?: number } = {},
463
+ ): Promise<string> {
464
+ const limit = options.limit ?? 1_048_576;
465
+ const chunks: Buffer[] = [];
466
+ let size = 0;
467
+ for await (const chunk of request) {
468
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
469
+ size += buffer.length;
470
+ if (size > limit) {
471
+ request.destroy();
472
+ throw new Error(`Request body exceeds ${limit} bytes`);
473
+ }
474
+ chunks.push(buffer);
475
+ }
476
+ return Buffer.concat(chunks).toString('utf8');
477
+ }