@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/lib/index.d.ts CHANGED
@@ -1,15 +1,4 @@
1
- import { WeChatMPAdapter } from "./adapter.js";
2
- declare module "zhin.js" {
3
- namespace Plugin {
4
- interface Contexts {
5
- router: import("@zhin.js/host-router").Router;
6
- }
7
- }
8
- interface Adapters {
9
- "wechat-mp": WeChatMPAdapter;
10
- }
11
- }
12
- export * from "./types.js";
13
- export { WeChatMPEndpoint } from "./endpoint.js";
14
- export { WeChatMPAdapter } from "./adapter.js";
15
- //# sourceMappingURL=index.d.ts.map
1
+ export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, type ResolvedWeChatMpConfig, type TokenResponse, type WeChatAPIResponse, type WeChatMessage, type WeChatMpAdapterConfig, type WeChatWireSegment, } from './protocol.js';
2
+ export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, type PassiveReplyCapture, } from './passive-reply.js';
3
+ export { WeChatMpEndpoint, type WeChatMpEndpointOptions, type WeChatMpFetch, } from './endpoint.js';
4
+ export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, type WeChatMpWebhookHandler, } from './webhook.js';
package/lib/index.js CHANGED
@@ -1,25 +1,4 @@
1
- /**
2
- * 微信公众号适配器入口:类型扩展、导出、注册
3
- */
4
- import { usePlugin } from "zhin.js";
5
- import { WeChatMPAdapter } from "./adapter.js";
6
- export * from "./types.js";
7
- export { WeChatMPEndpoint } from "./endpoint.js";
8
- export { WeChatMPAdapter } from "./adapter.js";
9
- const plugin = usePlugin();
10
- const { provide, useContext } = plugin;
11
- useContext("router", (router) => {
12
- provide({
13
- name: "wechat-mp",
14
- description: "WeChat MP Endpoint Adapter",
15
- mounted: async (p) => {
16
- const adapter = new WeChatMPAdapter(p, router);
17
- await adapter.start();
18
- return adapter;
19
- },
20
- dispose: async (adapter) => {
21
- await adapter.stop();
22
- },
23
- });
24
- });
25
- //# sourceMappingURL=index.js.map
1
+ export { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, extractOutboundText, formatCustomerServiceBody, formatInboundContent, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, resolveWeChatMpConfig, verifySignature, } from './protocol.js';
2
+ export { getPassiveReplyCapture, recordPassiveReplyText, runWithPassiveReplyCapture, } from './passive-reply.js';
3
+ export { WeChatMpEndpoint, } from './endpoint.js';
4
+ export { registerWeChatMpWebhookRoutes, handleWeChatMpVerification, handleWeChatMpMessage, collectPassiveReply, } from './webhook.js';
@@ -5,4 +5,3 @@ export type PassiveReplyCapture = {
5
5
  export declare function getPassiveReplyCapture(): PassiveReplyCapture | undefined;
6
6
  export declare function runWithPassiveReplyCapture<T>(fn: () => Promise<T>): Promise<T>;
7
7
  export declare function recordPassiveReplyText(text: string): void;
8
- //# sourceMappingURL=passive-reply.d.ts.map
@@ -13,4 +13,3 @@ export function recordPassiveReplyText(text) {
13
13
  return;
14
14
  capture.text = text;
15
15
  }
16
- //# sourceMappingURL=passive-reply.js.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * WeChat Official Account (MP) protocol helpers — no legacy Adapter/Endpoint.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import type { IncomingMessage } from 'node:http';
6
+ export interface WeChatMpAdapterConfig {
7
+ readonly name?: string;
8
+ readonly appId?: string;
9
+ readonly appSecret?: string;
10
+ readonly token?: string;
11
+ readonly encodingAESKey?: string;
12
+ readonly path?: string;
13
+ readonly encrypt?: boolean;
14
+ /**
15
+ * plain:明文入站/出站
16
+ * compatible:入站可解密,被动回复用明文(微信兼容模式推荐)
17
+ * secure:入站/出站均加密
18
+ */
19
+ readonly encryptMode?: 'plain' | 'compatible' | 'secure';
20
+ /**
21
+ * passive:订阅号默认,在 webhook 响应内被动回复(5 秒内)
22
+ * customer_service:走客服消息 API(需接口权限)
23
+ */
24
+ readonly replyMode?: 'passive' | 'customer_service';
25
+ /** 被动回复等待入站处理的最长时间(毫秒),默认 4500 */
26
+ readonly passiveReplyTimeoutMs?: number;
27
+ /** Transitional: legacy root `endpoints[]` with `context: wechat-mp`. */
28
+ readonly endpoints?: ReadonlyArray<Partial<ResolvedWeChatMpConfig> & {
29
+ readonly context?: string;
30
+ }>;
31
+ }
32
+ export interface ResolvedWeChatMpConfig {
33
+ readonly context: 'wechat-mp';
34
+ readonly name: string;
35
+ readonly appId: string;
36
+ readonly appSecret: string;
37
+ readonly token: string;
38
+ readonly encodingAESKey?: string;
39
+ readonly path: string;
40
+ readonly encrypt: boolean;
41
+ readonly encryptMode: 'plain' | 'compatible' | 'secure';
42
+ readonly replyMode: 'passive' | 'customer_service';
43
+ readonly passiveReplyTimeoutMs: number;
44
+ }
45
+ export interface WeChatMessage {
46
+ readonly ToUserName: string;
47
+ readonly FromUserName: string;
48
+ readonly CreateTime: number;
49
+ readonly MsgType: string;
50
+ readonly MsgId?: string;
51
+ readonly Content?: string;
52
+ readonly PicUrl?: string;
53
+ readonly MediaId?: string;
54
+ readonly Format?: string;
55
+ readonly Recognition?: string;
56
+ readonly ThumbMediaId?: string;
57
+ readonly Location_X?: string;
58
+ readonly Location_Y?: string;
59
+ readonly Scale?: string;
60
+ readonly Label?: string;
61
+ readonly Title?: string;
62
+ readonly Description?: string;
63
+ readonly Url?: string;
64
+ readonly Event?: string;
65
+ readonly EventKey?: string;
66
+ readonly Encrypt?: string;
67
+ }
68
+ export interface TokenResponse {
69
+ readonly access_token: string;
70
+ readonly expires_in: number;
71
+ }
72
+ export interface WeChatAPIResponse {
73
+ readonly errcode?: number;
74
+ readonly errmsg?: string;
75
+ readonly msgid?: number;
76
+ }
77
+ export interface WeChatWireSegment {
78
+ readonly type: string;
79
+ readonly data?: Record<string, unknown>;
80
+ }
81
+ export declare function resolveWeChatMpConfig(config?: WeChatMpAdapterConfig): ResolvedWeChatMpConfig;
82
+ export declare function queryParam(value: string | null | undefined): string;
83
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
84
+ export declare function normalizeEchostrParam(echostr: string): string;
85
+ export declare function computeSignatureHash(token: string, params: {
86
+ readonly timestamp: string;
87
+ readonly nonce: string;
88
+ readonly echostr?: string;
89
+ }): string;
90
+ export declare function verifySignature(token: string, params: {
91
+ readonly signature: string;
92
+ readonly timestamp: string;
93
+ readonly nonce: string;
94
+ readonly echostr?: string;
95
+ }): boolean;
96
+ export declare function getAESKey(encodingAESKey: string): Buffer;
97
+ /** 微信安全模式加密 echostr 为较长 Base64;明文/兼容模式多为短字符串 */
98
+ export declare function isEncryptedEchostr(echostr: string): boolean;
99
+ export declare function decryptEchostr(encrypted: string, encodingAESKey: string, appId: string): string;
100
+ export declare function parseXMLMessage(xmlString: string): Promise<WeChatMessage | null>;
101
+ export declare function decryptMessage(encryptedXml: string, msgSignature: string, timestamp: string, nonce: string, token: string, encodingAESKey: string, appId: string): Promise<string>;
102
+ export declare function encryptMessage(replyXml: string, token: string, encodingAESKey: string, appId: string, requestTimestamp?: string): string;
103
+ export declare function buildTextReply(wechatMsg: Pick<WeChatMessage, 'FromUserName' | 'ToUserName'>, content: string): string;
104
+ /** Build inbound text for MessageGateway.receive. */
105
+ export declare function formatInboundContent(msg: WeChatMessage): string;
106
+ /**
107
+ * Built-in passive XML for subscribe etc. Empty string = fall through to gateway.
108
+ */
109
+ export declare function resolveEventPassiveReply(msg: WeChatMessage): string;
110
+ /**
111
+ * Wire-encode an already-rendered outbound payload into客服消息 JSON body.
112
+ */
113
+ export declare function formatCustomerServiceBody(target: string, payload: unknown): Record<string, unknown>;
114
+ /** Extract plain text from a rendered outbound payload (for passive XML). */
115
+ export declare function extractOutboundText(payload: unknown): string;
116
+ export declare function readTextBody(request: IncomingMessage, options?: {
117
+ readonly limit?: number;
118
+ }): Promise<string>;
@@ -0,0 +1,324 @@
1
+ /**
2
+ * WeChat Official Account (MP) protocol helpers — no legacy Adapter/Endpoint.
3
+ * Canonicalization is owned by gateway/core before endpoint.send.
4
+ */
5
+ import { createHash, createDecipheriv, createCipheriv, randomBytes } from 'node:crypto';
6
+ import * as xml2js from 'xml2js';
7
+ export function resolveWeChatMpConfig(config = {}) {
8
+ const entry = config.endpoints?.find((item) => item.context === 'wechat-mp');
9
+ const appId = config.appId ?? entry?.appId ?? process.env.WECHAT_APP_ID;
10
+ const appSecret = config.appSecret ?? entry?.appSecret ?? process.env.WECHAT_APP_SECRET;
11
+ const token = config.token ?? entry?.token ?? process.env.WECHAT_TOKEN;
12
+ if (!appId || !appSecret || !token) {
13
+ throw new TypeError('WeChat MP adapter requires appId + appSecret + token (plugins.<key> or endpoints with context: wechat-mp)');
14
+ }
15
+ const name = (typeof config.name === 'string' && config.name)
16
+ || (typeof entry?.name === 'string' && entry.name)
17
+ || process.env.WECHAT_BOT_NAME
18
+ || 'wechat-mp-bot';
19
+ const path = config.path ?? entry?.path ?? '/wechat/webhook';
20
+ const encodingAESKey = config.encodingAESKey ?? entry?.encodingAESKey;
21
+ const encrypt = config.encrypt ?? entry?.encrypt ?? false;
22
+ const encryptMode = config.encryptMode ?? entry?.encryptMode ?? (encrypt ? 'compatible' : 'plain');
23
+ const replyMode = config.replyMode ?? entry?.replyMode ?? 'passive';
24
+ const passiveReplyTimeoutMs = config.passiveReplyTimeoutMs
25
+ ?? entry?.passiveReplyTimeoutMs
26
+ ?? 4500;
27
+ return {
28
+ context: 'wechat-mp',
29
+ name,
30
+ appId,
31
+ appSecret,
32
+ token,
33
+ encodingAESKey,
34
+ path: path.startsWith('/') ? path : `/${path}`,
35
+ encrypt: !!encrypt,
36
+ encryptMode,
37
+ replyMode,
38
+ passiveReplyTimeoutMs,
39
+ };
40
+ }
41
+ export function queryParam(value) {
42
+ return value ?? '';
43
+ }
44
+ /** URL 查询里的 Base64 可能把 `+` 解码成空格 */
45
+ export function normalizeEchostrParam(echostr) {
46
+ return echostr.replace(/ /g, '+');
47
+ }
48
+ export function computeSignatureHash(token, params) {
49
+ const { timestamp, nonce, echostr } = params;
50
+ const arr = echostr
51
+ ? [token, timestamp, nonce, echostr]
52
+ : [token, timestamp, nonce];
53
+ arr.sort();
54
+ return createHash('sha1').update(arr.join('')).digest('hex');
55
+ }
56
+ export function verifySignature(token, params) {
57
+ const { signature, timestamp, nonce, echostr } = params;
58
+ if (!signature || !timestamp || !nonce)
59
+ return false;
60
+ return computeSignatureHash(token, { timestamp, nonce, echostr }) === signature;
61
+ }
62
+ export function getAESKey(encodingAESKey) {
63
+ return Buffer.from(`${encodingAESKey}=`, 'base64');
64
+ }
65
+ /** 微信安全模式加密 echostr 为较长 Base64;明文/兼容模式多为短字符串 */
66
+ export function isEncryptedEchostr(echostr) {
67
+ if (echostr.length < 32)
68
+ return false;
69
+ return /^[A-Za-z0-9+/]+={0,2}$/.test(echostr);
70
+ }
71
+ export function decryptEchostr(encrypted, encodingAESKey, appId) {
72
+ const aesKey = getAESKey(encodingAESKey);
73
+ const iv = aesKey.subarray(0, 16);
74
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
75
+ decipher.setAutoPadding(false);
76
+ const decrypted = Buffer.concat([
77
+ decipher.update(Buffer.from(encrypted, 'base64')),
78
+ decipher.final(),
79
+ ]);
80
+ const pad = decrypted[decrypted.length - 1];
81
+ const content = decrypted.subarray(0, decrypted.length - pad);
82
+ const msgLen = content.readUInt32BE(16);
83
+ const plain = content.subarray(20, 20 + msgLen).toString('utf8');
84
+ const gotAppId = content.subarray(20 + msgLen).toString('utf8');
85
+ if (gotAppId !== appId) {
86
+ throw new Error(`AppID mismatch: expected ${appId}, got ${gotAppId}`);
87
+ }
88
+ return plain;
89
+ }
90
+ export async function parseXMLMessage(xmlString) {
91
+ try {
92
+ const parser = new xml2js.Parser({ explicitArray: false, ignoreAttrs: true });
93
+ const result = await parser.parseStringPromise(xmlString);
94
+ return result.xml;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ export async function decryptMessage(encryptedXml, msgSignature, timestamp, nonce, token, encodingAESKey, appId) {
101
+ const parsed = await parseXMLMessage(encryptedXml);
102
+ const encrypt = parsed?.Encrypt;
103
+ if (!encrypt)
104
+ throw new Error('Missing Encrypt field in encrypted message');
105
+ const expected = createHash('sha1')
106
+ .update([token, timestamp, nonce, encrypt].sort().join(''))
107
+ .digest('hex');
108
+ if (expected !== msgSignature) {
109
+ throw new Error('msg_signature verification failed');
110
+ }
111
+ const aesKey = getAESKey(encodingAESKey);
112
+ const iv = aesKey.subarray(0, 16);
113
+ const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
114
+ decipher.setAutoPadding(false);
115
+ const decrypted = Buffer.concat([
116
+ decipher.update(Buffer.from(encrypt, 'base64')),
117
+ decipher.final(),
118
+ ]);
119
+ const pad = decrypted[decrypted.length - 1];
120
+ const content = decrypted.subarray(0, decrypted.length - pad);
121
+ const msgLen = content.readUInt32BE(16);
122
+ const xmlContent = content.subarray(20, 20 + msgLen).toString('utf8');
123
+ const gotAppId = content.subarray(20 + msgLen).toString('utf8');
124
+ if (gotAppId !== appId) {
125
+ throw new Error(`AppID mismatch: expected ${appId}, got ${gotAppId}`);
126
+ }
127
+ return xmlContent;
128
+ }
129
+ export function encryptMessage(replyXml, token, encodingAESKey, appId, requestTimestamp) {
130
+ const aesKey = getAESKey(encodingAESKey);
131
+ const iv = aesKey.subarray(0, 16);
132
+ const random = randomBytes(16);
133
+ const msgBuf = Buffer.from(replyXml, 'utf8');
134
+ const appIdBuf = Buffer.from(appId, 'utf8');
135
+ const lenBuf = Buffer.alloc(4);
136
+ lenBuf.writeUInt32BE(msgBuf.length, 0);
137
+ const plaintext = Buffer.concat([random, lenBuf, msgBuf, appIdBuf]);
138
+ const blockSize = 32;
139
+ const padLen = blockSize - (plaintext.length % blockSize);
140
+ const padBuf = Buffer.alloc(padLen, padLen);
141
+ const padded = Buffer.concat([plaintext, padBuf]);
142
+ const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
143
+ cipher.setAutoPadding(false);
144
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
145
+ const encryptStr = encrypted.toString('base64');
146
+ const timestamp = requestTimestamp || Math.floor(Date.now() / 1000).toString();
147
+ const nonce = randomBytes(8).toString('hex');
148
+ const signature = createHash('sha1')
149
+ .update([token, timestamp, nonce, encryptStr].sort().join(''))
150
+ .digest('hex');
151
+ return [
152
+ '<xml>',
153
+ `<Encrypt><![CDATA[${encryptStr}]]></Encrypt>`,
154
+ `<MsgSignature><![CDATA[${signature}]]></MsgSignature>`,
155
+ `<TimeStamp>${timestamp}</TimeStamp>`,
156
+ `<Nonce><![CDATA[${nonce}]]></Nonce>`,
157
+ '</xml>',
158
+ ].join('\n');
159
+ }
160
+ export function buildTextReply(wechatMsg, content) {
161
+ const cdata = (value) => value.replace(/]]>/g, ']]]]><![CDATA[>');
162
+ const createTime = Math.floor(Date.now() / 1000);
163
+ return [
164
+ '<xml>',
165
+ `<ToUserName><![CDATA[${cdata(wechatMsg.FromUserName)}]]></ToUserName>`,
166
+ `<FromUserName><![CDATA[${cdata(wechatMsg.ToUserName)}]]></FromUserName>`,
167
+ `<CreateTime>${createTime}</CreateTime>`,
168
+ `<MsgType><![CDATA[text]]></MsgType>`,
169
+ `<Content><![CDATA[${cdata(content)}]]></Content>`,
170
+ '</xml>',
171
+ ].join('');
172
+ }
173
+ /** Build inbound text for MessageGateway.receive. */
174
+ export function formatInboundContent(msg) {
175
+ switch (msg.MsgType) {
176
+ case 'text':
177
+ return msg.Content || '(空消息)';
178
+ case 'image':
179
+ return msg.PicUrl ? `[image: ${msg.PicUrl}]` : '[image]';
180
+ case 'voice':
181
+ return msg.Recognition
182
+ ? msg.Recognition
183
+ : `[voice${msg.Format ? `: ${msg.Format}` : ''}]`;
184
+ case 'video':
185
+ case 'shortvideo':
186
+ return '[video]';
187
+ case 'location':
188
+ return `[location: ${msg.Location_X},${msg.Location_Y}${msg.Label ? ` ${msg.Label}` : ''}]`;
189
+ case 'link':
190
+ return `[link: ${msg.Title ?? ''}${msg.Url ? ` ${msg.Url}` : ''}]`;
191
+ case 'event':
192
+ return `[event: ${msg.Event ?? ''}${msg.EventKey ? ` ${msg.EventKey}` : ''}]`;
193
+ default:
194
+ return `[不支持的消息类型: ${msg.MsgType}]`;
195
+ }
196
+ }
197
+ /**
198
+ * Built-in passive XML for subscribe etc. Empty string = fall through to gateway.
199
+ */
200
+ export function resolveEventPassiveReply(msg) {
201
+ if (msg.MsgType !== 'event')
202
+ return '';
203
+ if (msg.Event === 'subscribe') {
204
+ return buildTextReply(msg, '感谢关注!');
205
+ }
206
+ return '';
207
+ }
208
+ /**
209
+ * Wire-encode an already-rendered outbound payload into客服消息 JSON body.
210
+ */
211
+ export function formatCustomerServiceBody(target, payload) {
212
+ const messageData = {
213
+ touser: target,
214
+ msgtype: 'text',
215
+ text: { content: '' },
216
+ };
217
+ if (typeof payload === 'string') {
218
+ messageData.text.content = payload;
219
+ return messageData;
220
+ }
221
+ const segments = Array.isArray(payload)
222
+ ? payload
223
+ : payload && typeof payload === 'object' && 'type' in payload
224
+ ? [payload]
225
+ : [];
226
+ if (segments.length === 0) {
227
+ messageData.text.content = payload == null
228
+ ? ''
229
+ : typeof payload === 'object'
230
+ ? JSON.stringify(payload)
231
+ : String(payload);
232
+ return messageData;
233
+ }
234
+ const textParts = [];
235
+ let hasMedia = false;
236
+ for (const item of segments) {
237
+ if (typeof item === 'string') {
238
+ textParts.push(item);
239
+ continue;
240
+ }
241
+ const data = item.data ?? {};
242
+ switch (item.type) {
243
+ case 'text':
244
+ textParts.push(String(data.text ?? data.content ?? ''));
245
+ break;
246
+ case 'image':
247
+ if (!hasMedia && data.mediaId) {
248
+ messageData.msgtype = 'image';
249
+ messageData.image = { media_id: data.mediaId };
250
+ delete messageData.text;
251
+ hasMedia = true;
252
+ }
253
+ break;
254
+ case 'voice':
255
+ if (!hasMedia && data.mediaId) {
256
+ messageData.msgtype = 'voice';
257
+ messageData.voice = { media_id: data.mediaId };
258
+ delete messageData.text;
259
+ hasMedia = true;
260
+ }
261
+ break;
262
+ case 'video':
263
+ if (!hasMedia && data.mediaId) {
264
+ messageData.msgtype = 'video';
265
+ messageData.video = {
266
+ media_id: data.mediaId,
267
+ title: data.title || '',
268
+ description: data.description || '',
269
+ };
270
+ delete messageData.text;
271
+ hasMedia = true;
272
+ }
273
+ break;
274
+ default:
275
+ break;
276
+ }
277
+ }
278
+ if (!hasMedia && textParts.length > 0 && messageData.text) {
279
+ messageData.text.content = textParts.join('\n');
280
+ }
281
+ return messageData;
282
+ }
283
+ /** Extract plain text from a rendered outbound payload (for passive XML). */
284
+ export function extractOutboundText(payload) {
285
+ if (typeof payload === 'string')
286
+ return payload;
287
+ if (!Array.isArray(payload)) {
288
+ if (payload && typeof payload === 'object' && 'type' in payload) {
289
+ const seg = payload;
290
+ if (seg.type === 'text')
291
+ return String(seg.data?.text ?? seg.data?.content ?? '');
292
+ }
293
+ return payload == null ? '' : String(payload);
294
+ }
295
+ const parts = [];
296
+ for (const item of payload) {
297
+ if (typeof item === 'string') {
298
+ parts.push(item);
299
+ continue;
300
+ }
301
+ if (item && typeof item === 'object' && 'type' in item) {
302
+ const seg = item;
303
+ if (seg.type === 'text') {
304
+ parts.push(String(seg.data?.text ?? seg.data?.content ?? ''));
305
+ }
306
+ }
307
+ }
308
+ return parts.join('\n');
309
+ }
310
+ export async function readTextBody(request, options = {}) {
311
+ const limit = options.limit ?? 1_048_576;
312
+ const chunks = [];
313
+ let size = 0;
314
+ for await (const chunk of request) {
315
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
316
+ size += buffer.length;
317
+ if (size > limit) {
318
+ request.destroy();
319
+ throw new Error(`Request body exceeds ${limit} bytes`);
320
+ }
321
+ chunks.push(buffer);
322
+ }
323
+ return Buffer.concat(chunks).toString('utf8');
324
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * WeChat MP webhook HTTP: URL verification + inbound message handling.
3
+ */
4
+ import type { IncomingMessage, ServerResponse } from 'node:http';
5
+ import type { MessageGateway } from '@zhin.js/core/runtime';
6
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
7
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
8
+ import { type ResolvedWeChatMpConfig, type WeChatMessage } from './protocol.js';
9
+ export interface WeChatMpWebhookHandler {
10
+ readonly config: ResolvedWeChatMpConfig;
11
+ readonly isOpen: boolean;
12
+ readonly id: CapabilityId;
13
+ readonly gateway: MessageGateway;
14
+ admit(msg: WeChatMessage): void;
15
+ }
16
+ export declare function registerWeChatMpWebhookRoutes(http: HttpHost, handler: WeChatMpWebhookHandler): HttpRouteRegistration[];
17
+ export declare function handleWeChatMpVerification(_request: IncomingMessage, response: ServerResponse, url: URL, config: ResolvedWeChatMpConfig): void;
18
+ export declare function handleWeChatMpMessage(request: IncomingMessage, response: ServerResponse, url: URL, handler: WeChatMpWebhookHandler): Promise<void>;
19
+ export declare function collectPassiveReply(handler: WeChatMpWebhookHandler, wechatMsg: WeChatMessage): Promise<string>;
package/lib/webhook.js ADDED
@@ -0,0 +1,149 @@
1
+ import { formatCompact, getLogger } from '@zhin.js/logger';
2
+ import { getPassiveReplyCapture, runWithPassiveReplyCapture, } from './passive-reply.js';
3
+ import { buildTextReply, computeSignatureHash, decryptEchostr, decryptMessage, encryptMessage, formatInboundContent, isEncryptedEchostr, normalizeEchostrParam, parseXMLMessage, queryParam, readTextBody, resolveEventPassiveReply, verifySignature, } from './protocol.js';
4
+ const logger = getLogger('wechat-mp');
5
+ export function registerWeChatMpWebhookRoutes(http, handler) {
6
+ const path = handler.config.path;
7
+ return [
8
+ http.route('GET', path, (request, response, url) => {
9
+ handleWeChatMpVerification(request, response, url, handler.config);
10
+ }, { summary: 'WeChat MP URL verification', tags: ['wechat-mp'] }),
11
+ http.route('POST', path, async (request, response, url) => {
12
+ await handleWeChatMpMessage(request, response, url, handler);
13
+ }, { summary: 'WeChat MP inbound webhook', tags: ['wechat-mp'] }),
14
+ ];
15
+ }
16
+ export function handleWeChatMpVerification(_request, response, url, config) {
17
+ const signature = queryParam(url.searchParams.get('signature'));
18
+ const msgSignature = queryParam(url.searchParams.get('msg_signature'));
19
+ const timestamp = queryParam(url.searchParams.get('timestamp'));
20
+ const nonce = queryParam(url.searchParams.get('nonce'));
21
+ const echostr = normalizeEchostrParam(queryParam(url.searchParams.get('echostr')));
22
+ const secureMode = !!(config.encrypt && config.encodingAESKey);
23
+ const signToCheck = msgSignature || signature;
24
+ const signPayload = msgSignature
25
+ ? { signature: msgSignature, timestamp, nonce, echostr }
26
+ : { signature, timestamp, nonce };
27
+ if (!signToCheck || !timestamp || !nonce) {
28
+ response.writeHead(403, { 'Content-Type': 'text/plain' });
29
+ response.end('Forbidden');
30
+ return;
31
+ }
32
+ if (!verifySignature(config.token, signPayload)) {
33
+ const expected = computeSignatureHash(config.token, {
34
+ timestamp,
35
+ nonce,
36
+ ...(msgSignature ? { echostr } : {}),
37
+ });
38
+ logger.error(formatCompact({
39
+ op: 'verify',
40
+ stage: 'sign',
41
+ ok: false,
42
+ expectedPrefix: expected.slice(0, 8),
43
+ gotPrefix: signToCheck.slice(0, 8),
44
+ }));
45
+ response.writeHead(403, { 'Content-Type': 'text/plain' });
46
+ response.end('Forbidden');
47
+ return;
48
+ }
49
+ let body = echostr;
50
+ if (secureMode && echostr && isEncryptedEchostr(echostr) && config.encodingAESKey) {
51
+ try {
52
+ body = decryptEchostr(echostr, config.encodingAESKey, config.appId);
53
+ }
54
+ catch (error) {
55
+ logger.error(formatCompact({
56
+ op: 'verify',
57
+ stage: 'decrypt',
58
+ ok: false,
59
+ error: error instanceof Error ? error.message : String(error),
60
+ }));
61
+ response.writeHead(403, { 'Content-Type': 'text/plain' });
62
+ response.end('Forbidden');
63
+ return;
64
+ }
65
+ }
66
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
67
+ response.end(body);
68
+ }
69
+ export async function handleWeChatMpMessage(request, response, url, handler) {
70
+ try {
71
+ const config = handler.config;
72
+ const signature = queryParam(url.searchParams.get('signature'));
73
+ const timestamp = queryParam(url.searchParams.get('timestamp'));
74
+ const nonce = queryParam(url.searchParams.get('nonce'));
75
+ const msgSignature = queryParam(url.searchParams.get('msg_signature'));
76
+ const encryptType = queryParam(url.searchParams.get('encrypt_type'));
77
+ if (!verifySignature(config.token, { signature, timestamp, nonce })) {
78
+ logger.error('Invalid signature');
79
+ response.writeHead(403, { 'Content-Type': 'text/plain' });
80
+ response.end('Forbidden');
81
+ return;
82
+ }
83
+ let xmlString = await readTextBody(request);
84
+ if (config.encrypt && encryptType === 'aes' && config.encodingAESKey) {
85
+ xmlString = await decryptMessage(xmlString, msgSignature, timestamp, nonce, config.token, config.encodingAESKey, config.appId);
86
+ }
87
+ const wechatMessage = await parseXMLMessage(xmlString);
88
+ if (!wechatMessage) {
89
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
90
+ response.end('success');
91
+ return;
92
+ }
93
+ let replyXML = resolveEventPassiveReply(wechatMessage);
94
+ if (!replyXML && handler.isOpen) {
95
+ if (config.replyMode === 'passive') {
96
+ replyXML = await collectPassiveReply(handler, wechatMessage);
97
+ }
98
+ else {
99
+ handler.admit(wechatMessage);
100
+ }
101
+ }
102
+ const encryptReply = !!(replyXML
103
+ && config.encodingAESKey
104
+ && encryptType === 'aes'
105
+ && config.encryptMode === 'secure');
106
+ if (encryptReply && config.encodingAESKey) {
107
+ replyXML = encryptMessage(replyXML, config.token, config.encodingAESKey, config.appId, timestamp);
108
+ }
109
+ response.writeHead(200, { 'Content-Type': 'text/xml' });
110
+ response.end(replyXML || 'success');
111
+ }
112
+ catch (error) {
113
+ logger.error('Error handling WeChat message:', error);
114
+ response.writeHead(200, { 'Content-Type': 'text/plain' });
115
+ response.end('success');
116
+ }
117
+ }
118
+ export async function collectPassiveReply(handler, wechatMsg) {
119
+ const timeoutMs = handler.config.passiveReplyTimeoutMs;
120
+ const text = await runWithPassiveReplyCapture(async () => {
121
+ await Promise.race([
122
+ handler.gateway.receive({
123
+ adapter: handler.id,
124
+ target: wechatMsg.FromUserName,
125
+ content: formatInboundContent(wechatMsg),
126
+ sender: wechatMsg.FromUserName,
127
+ id: wechatMsg.MsgId || `${wechatMsg.CreateTime}`,
128
+ metadata: Object.freeze({
129
+ msgType: wechatMsg.MsgType,
130
+ event: wechatMsg.Event,
131
+ endpoint: handler.config.name,
132
+ toUserName: wechatMsg.ToUserName,
133
+ }),
134
+ }),
135
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
136
+ ]);
137
+ return getPassiveReplyCapture()?.text ?? null;
138
+ });
139
+ if (!text) {
140
+ logger.warn(formatCompact({
141
+ op: 'passive_reply',
142
+ ok: false,
143
+ reason: 'timeout_or_empty',
144
+ timeoutMs,
145
+ }));
146
+ return '';
147
+ }
148
+ return buildTextReply(wechatMsg, text);
149
+ }