@zhin.js/adapter-wechat-mp 1.0.1 → 1.1.2
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/CHANGELOG.md +551 -0
- package/README.md +41 -88
- package/adapters/wechat-mp/index.js +32 -0
- package/adapters/wechat-mp/index.ts +38 -0
- package/commands/wechat-mp/endpoint/add/[id]/index.js +3 -0
- package/commands/wechat-mp/endpoint/add/[id]/index.ts +3 -0
- package/commands/wechat-mp/endpoint/definition.js +20 -0
- package/commands/wechat-mp/endpoint/definition.ts +20 -0
- package/commands/wechat-mp/endpoint/list/index.js +3 -0
- package/commands/wechat-mp/endpoint/list/index.ts +3 -0
- package/commands/wechat-mp/endpoint/remove/[id]/index.js +3 -0
- package/commands/wechat-mp/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +32 -0
- package/lib/client.js +70 -0
- package/lib/endpoint.d.ts +31 -72
- package/lib/endpoint.js +226 -747
- package/lib/index.d.ts +6 -15
- package/lib/index.js +6 -25
- package/lib/media-upload.d.ts +22 -0
- package/lib/media-upload.js +64 -0
- package/lib/passive-reply.d.ts +0 -1
- package/lib/passive-reply.js +0 -1
- package/lib/protocol.d.ts +126 -0
- package/lib/protocol.js +350 -0
- package/lib/side-event-dispatch.d.ts +4 -0
- package/lib/side-event-dispatch.js +38 -0
- package/lib/webhook.d.ts +20 -0
- package/lib/webhook.js +152 -0
- package/lib/wechat-mp-runtime-state.d.ts +1 -0
- package/lib/wechat-mp-runtime-state.js +6 -0
- package/package.json +57 -12
- package/plugin.js +14 -0
- package/schema.json +144 -0
- package/src/client.ts +121 -0
- package/src/endpoint.ts +276 -902
- package/src/index.ts +56 -35
- package/src/media-upload.ts +82 -0
- package/src/protocol.ts +508 -0
- package/src/side-event-dispatch.ts +45 -0
- package/src/webhook.ts +237 -0
- package/src/wechat-mp-runtime-state.ts +7 -0
- package/lib/adapter.d.ts +0 -14
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -17
- package/lib/adapter.js.map +0 -1
- package/lib/endpoint.d.ts.map +0 -1
- package/lib/endpoint.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/passive-reply.d.ts.map +0 -1
- package/lib/passive-reply.js.map +0 -1
- package/lib/types.d.ts +0 -58
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/skills/wechat-mp/SKILL.md +0 -33
- package/src/adapter.ts +0 -22
- package/src/types.ts +0 -60
package/lib/endpoint.js
CHANGED
|
@@ -1,785 +1,264 @@
|
|
|
1
|
+
import { Endpoint } from 'zhin.js/adapter';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* WeChatMpEndpoint — lifecycle, outbound, admit, access token refresh.
|
|
3
4
|
*/
|
|
4
|
-
import axios from
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
7
|
+
import { extractOutboundText, formatCustomerServiceBody, formatInboundContent, formatInboundId, wechatMpInboundConversation, } from './protocol.js';
|
|
8
|
+
import { buildMediaUploadForm, readOutboundMedia, resolveMediaBinary, } from './media-upload.js';
|
|
9
|
+
import { getPassiveReplyCapture, recordPassiveReplyText, } from './passive-reply.js';
|
|
10
|
+
import { registerWeChatMpWebhookRoutes } from './webhook.js';
|
|
11
|
+
import { receiveWeChatMpSideEvent } from './side-event-dispatch.js';
|
|
12
|
+
import { WeChatMpClient } from './client.js';
|
|
13
|
+
/**
|
|
14
|
+
* canonical 媒体段类型 → 微信 /cgi-bin/media/upload 的 type。
|
|
15
|
+
* 客服消息无 file 投递面,file 段不可投递。
|
|
16
|
+
*/
|
|
17
|
+
const WECHAT_UPLOAD_TYPE = {
|
|
18
|
+
image: 'image',
|
|
19
|
+
audio: 'voice',
|
|
20
|
+
voice: 'voice',
|
|
21
|
+
video: 'video',
|
|
22
|
+
};
|
|
23
|
+
function defaultFetch(url, init) {
|
|
24
|
+
return axios({
|
|
25
|
+
url,
|
|
26
|
+
method: (init?.method ?? 'GET'),
|
|
27
|
+
data: init?.body,
|
|
28
|
+
headers: init?.headers,
|
|
29
|
+
}).then((response) => ({ data: response.data }));
|
|
22
30
|
}
|
|
23
|
-
export class
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
constructor(
|
|
31
|
+
export class WeChatMpEndpoint extends Endpoint {
|
|
32
|
+
client;
|
|
33
|
+
#logger;
|
|
34
|
+
#options;
|
|
35
|
+
#fetch;
|
|
36
|
+
#routeReleases = [];
|
|
37
|
+
#tokenRefreshTimer;
|
|
38
|
+
/** MsgId → 首次回复 XML(微信 5s 重推去重,有界 LRU)。 */
|
|
39
|
+
#replyCache = new Map();
|
|
40
|
+
static #REPLY_CACHE_LIMIT = 1000;
|
|
41
|
+
#open = false;
|
|
42
|
+
#started = false;
|
|
43
|
+
management = createWeChatMpEndpointManagement(() => this.client);
|
|
44
|
+
constructor(options) {
|
|
37
45
|
super();
|
|
38
|
-
this
|
|
39
|
-
this
|
|
40
|
-
this
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this
|
|
66
|
-
}
|
|
67
|
-
catch (error) {
|
|
68
|
-
this.logger.error('Failed to connect WeChat MP bot:', error);
|
|
69
|
-
throw error;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
async $disconnect() {
|
|
73
|
-
if (this.tokenRefreshTimer) {
|
|
74
|
-
clearInterval(this.tokenRefreshTimer);
|
|
75
|
-
this.tokenRefreshTimer = undefined;
|
|
46
|
+
this.#logger = getAdapterLogger('wechat-mp', options.config.id);
|
|
47
|
+
this.#options = options;
|
|
48
|
+
this.#fetch = options.fetch ?? defaultFetch;
|
|
49
|
+
this.client = new WeChatMpClient(options.config, this.#fetch);
|
|
50
|
+
}
|
|
51
|
+
/** Used by webhook handler. */
|
|
52
|
+
get isOpen() {
|
|
53
|
+
return this.#open;
|
|
54
|
+
}
|
|
55
|
+
get config() {
|
|
56
|
+
return this.#options.config;
|
|
57
|
+
}
|
|
58
|
+
get id() {
|
|
59
|
+
return this.#options.id;
|
|
60
|
+
}
|
|
61
|
+
/** 微信 5s 重推去重:见过该 MsgId 时返回首次回复 XML(含空串=success)。 */
|
|
62
|
+
getCachedReply(msgId) {
|
|
63
|
+
return this.#replyCache.get(msgId);
|
|
64
|
+
}
|
|
65
|
+
cacheReply(msgId, replyXML) {
|
|
66
|
+
if (this.#replyCache.has(msgId))
|
|
67
|
+
this.#replyCache.delete(msgId);
|
|
68
|
+
this.#replyCache.set(msgId, replyXML);
|
|
69
|
+
while (this.#replyCache.size > WeChatMpEndpoint.#REPLY_CACHE_LIMIT) {
|
|
70
|
+
const oldest = this.#replyCache.keys().next().value;
|
|
71
|
+
if (oldest === undefined)
|
|
72
|
+
break;
|
|
73
|
+
this.#replyCache.delete(oldest);
|
|
76
74
|
}
|
|
77
|
-
this.$connected = false;
|
|
78
|
-
this.logger.info(formatCompact({ op: "disconnect", endpoint: this.$config.name }));
|
|
79
75
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const msgSignature = queryParam(ctx.query.msg_signature);
|
|
83
|
-
const timestamp = queryParam(ctx.query.timestamp);
|
|
84
|
-
const nonce = queryParam(ctx.query.nonce);
|
|
85
|
-
const echostr = normalizeEchostrParam(queryParam(ctx.query.echostr));
|
|
86
|
-
const secureMode = !!(this.$config.encrypt && this.$config.encodingAESKey);
|
|
87
|
-
// GET 验证:signature 始终为 3 参数;msg_signature(若存在)为 4 参数含 echostr
|
|
88
|
-
const signMode = msgSignature ? "msg_signature" : "signature";
|
|
89
|
-
const signToCheck = msgSignature || signature;
|
|
90
|
-
const signPayload = msgSignature
|
|
91
|
-
? { signature: msgSignature, timestamp, nonce, echostr }
|
|
92
|
-
: { signature, timestamp, nonce };
|
|
93
|
-
const signFields = msgSignature ? 4 : 3;
|
|
94
|
-
this.logger.info(formatCompact({
|
|
95
|
-
op: "verify",
|
|
96
|
-
stage: "recv",
|
|
97
|
-
path: ctx.path,
|
|
98
|
-
secureMode,
|
|
99
|
-
signMode,
|
|
100
|
-
hasSignature: !!signature,
|
|
101
|
-
hasMsgSignature: !!msgSignature,
|
|
102
|
-
hasEchostr: !!echostr,
|
|
103
|
-
timestamp,
|
|
104
|
-
nonce,
|
|
105
|
-
echostrLen: echostr.length,
|
|
106
|
-
tokenLen: this.$config.token.length,
|
|
107
|
-
}));
|
|
108
|
-
if (!signToCheck || !timestamp || !nonce) {
|
|
109
|
-
this.logger.error(formatCompact({
|
|
110
|
-
op: "verify",
|
|
111
|
-
stage: "sign",
|
|
112
|
-
ok: false,
|
|
113
|
-
error: "missing_query_params",
|
|
114
|
-
}));
|
|
115
|
-
ctx.status = 403;
|
|
116
|
-
ctx.body = "Forbidden";
|
|
76
|
+
async start() {
|
|
77
|
+
if (this.#started)
|
|
117
78
|
return;
|
|
118
|
-
|
|
119
|
-
if (!this.verifySignature(signPayload)) {
|
|
120
|
-
const expected = this.computeSignatureHash(signPayload);
|
|
121
|
-
this.logger.error(formatCompact({
|
|
122
|
-
op: "verify",
|
|
123
|
-
stage: "sign",
|
|
124
|
-
ok: false,
|
|
125
|
-
secureMode,
|
|
126
|
-
signMode,
|
|
127
|
-
signFields,
|
|
128
|
-
expectedPrefix: expected.slice(0, 8),
|
|
129
|
-
gotPrefix: signToCheck.slice(0, 8),
|
|
130
|
-
}));
|
|
131
|
-
ctx.status = 403;
|
|
132
|
-
ctx.body = "Forbidden";
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
this.logger.info(formatCompact({
|
|
136
|
-
op: "verify",
|
|
137
|
-
stage: "sign",
|
|
138
|
-
ok: true,
|
|
139
|
-
signMode,
|
|
140
|
-
signFields,
|
|
141
|
-
}));
|
|
142
|
-
let body = echostr;
|
|
143
|
-
if (secureMode && echostr && this.isEncryptedEchostr(echostr)) {
|
|
144
|
-
try {
|
|
145
|
-
body = this.decryptEchostr(echostr);
|
|
146
|
-
this.logger.info(formatCompact({
|
|
147
|
-
op: "verify",
|
|
148
|
-
stage: "decrypt",
|
|
149
|
-
ok: true,
|
|
150
|
-
mode: "aes",
|
|
151
|
-
plainLen: body.length,
|
|
152
|
-
}));
|
|
153
|
-
}
|
|
154
|
-
catch (error) {
|
|
155
|
-
this.logger.error(formatCompact({
|
|
156
|
-
op: "verify",
|
|
157
|
-
stage: "decrypt",
|
|
158
|
-
ok: false,
|
|
159
|
-
mode: "aes",
|
|
160
|
-
error: error instanceof Error ? error.message : String(error),
|
|
161
|
-
}));
|
|
162
|
-
ctx.status = 403;
|
|
163
|
-
ctx.body = "Forbidden";
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
else if (secureMode && echostr) {
|
|
168
|
-
this.logger.info(formatCompact({
|
|
169
|
-
op: "verify",
|
|
170
|
-
stage: "decrypt",
|
|
171
|
-
ok: true,
|
|
172
|
-
mode: "plain_echostr",
|
|
173
|
-
plainLen: body.length,
|
|
174
|
-
}));
|
|
175
|
-
}
|
|
176
|
-
this.logger.info(formatCompact({
|
|
177
|
-
op: "verify",
|
|
178
|
-
stage: "done",
|
|
179
|
-
ok: true,
|
|
180
|
-
replyLen: body.length,
|
|
181
|
-
}));
|
|
182
|
-
ctx.body = body;
|
|
183
|
-
}
|
|
184
|
-
async handleMessage(ctx) {
|
|
79
|
+
this.#started = true;
|
|
185
80
|
try {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
timestamp,
|
|
195
|
-
nonce,
|
|
196
|
-
})) {
|
|
197
|
-
this.logger.error('Invalid signature');
|
|
198
|
-
ctx.status = 403;
|
|
199
|
-
ctx.body = 'Forbidden';
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
// 获取原始XML数据
|
|
203
|
-
let xmlString = typeof ctx.request.body === 'string' ? ctx.request.body : '';
|
|
204
|
-
// AES 加密模式:先解密
|
|
205
|
-
if (this.$config.encrypt && encrypt_type === 'aes' && this.$config.encodingAESKey) {
|
|
206
|
-
xmlString = await this.decryptMessage(xmlString, msg_signature, timestamp, nonce);
|
|
207
|
-
}
|
|
208
|
-
const wechatMessage = await this.parseXMLMessage(xmlString);
|
|
209
|
-
if (wechatMessage) {
|
|
210
|
-
const message = this.$formatMessage(wechatMessage);
|
|
211
|
-
this.logger.info(formatCompact({
|
|
212
|
-
recv: `private(${message.$channel.id})`,
|
|
213
|
-
endpoint: message.$endpoint,
|
|
214
|
-
preview: truncatePreview(segment.raw(message.$content)),
|
|
215
|
-
replyMode: this.getReplyMode(),
|
|
216
|
-
encryptMode: this.getEncryptMode(),
|
|
217
|
-
encryptType: encrypt_type || "plain",
|
|
218
|
-
}));
|
|
219
|
-
let replyXML = await this.handlePassiveReply(wechatMessage, message);
|
|
220
|
-
if (!replyXML && this.usesPassiveReply()) {
|
|
221
|
-
replyXML = await this.collectPassiveReplyXml(wechatMessage, message);
|
|
222
|
-
}
|
|
223
|
-
else if (!replyXML) {
|
|
224
|
-
this.adapter.emit("message.receive", message);
|
|
225
|
-
}
|
|
226
|
-
// 仅安全模式加密被动回复;兼容模式可明文回包(微信官方允许)
|
|
227
|
-
const encryptReply = !!(replyXML &&
|
|
228
|
-
this.$config.encodingAESKey &&
|
|
229
|
-
encrypt_type === "aes" &&
|
|
230
|
-
this.getEncryptMode() === "secure");
|
|
231
|
-
if (encryptReply) {
|
|
232
|
-
replyXML = this.encryptMessage(replyXML, timestamp);
|
|
233
|
-
}
|
|
234
|
-
ctx.set("Content-Type", "text/xml");
|
|
235
|
-
ctx.body = replyXML || "success";
|
|
236
|
-
if (replyXML) {
|
|
237
|
-
this.logger.info(formatCompact({
|
|
238
|
-
op: "passive_reply",
|
|
239
|
-
stage: "sent",
|
|
240
|
-
encrypted: encryptReply,
|
|
241
|
-
encryptMode: this.getEncryptMode(),
|
|
242
|
-
bodyLen: replyXML.length,
|
|
243
|
-
}));
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
ctx.body = 'success';
|
|
248
|
-
}
|
|
81
|
+
await this.client.refreshAccessToken();
|
|
82
|
+
this.#routeReleases.push(...registerWeChatMpWebhookRoutes(this.#options.http, this));
|
|
83
|
+
this.#startTokenRefreshTimer();
|
|
84
|
+
this.#logger.debug(formatCompact({
|
|
85
|
+
endpoint: this.#options.config.id,
|
|
86
|
+
op: 'webhook',
|
|
87
|
+
path: this.#options.config.path,
|
|
88
|
+
}));
|
|
249
89
|
}
|
|
250
90
|
catch (error) {
|
|
251
|
-
this.
|
|
252
|
-
|
|
91
|
+
await this.stop();
|
|
92
|
+
this.#logger.error('Failed to connect WeChat MP bot:', error);
|
|
93
|
+
throw error;
|
|
253
94
|
}
|
|
254
95
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const token = this.$config.token;
|
|
258
|
-
const arr = echostr
|
|
259
|
-
? [token, timestamp, nonce, echostr]
|
|
260
|
-
: [token, timestamp, nonce];
|
|
261
|
-
arr.sort();
|
|
262
|
-
return createHash("sha1").update(arr.join("")).digest("hex");
|
|
263
|
-
}
|
|
264
|
-
verifySignature(params) {
|
|
265
|
-
const { signature, timestamp, nonce, echostr } = params;
|
|
266
|
-
if (!signature || !timestamp || !nonce)
|
|
267
|
-
return false;
|
|
268
|
-
return this.computeSignatureHash({ timestamp, nonce, echostr }) === signature;
|
|
96
|
+
open() {
|
|
97
|
+
this.#open = true;
|
|
269
98
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const parser = new xml2js.Parser({ explicitArray: false, ignoreAttrs: true });
|
|
273
|
-
const result = await parser.parseStringPromise(xmlString);
|
|
274
|
-
return result.xml;
|
|
275
|
-
}
|
|
276
|
-
catch (error) {
|
|
277
|
-
this.logger.error('Error parsing XML:', error);
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
99
|
+
close() {
|
|
100
|
+
this.#open = false;
|
|
280
101
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const base = {
|
|
287
|
-
$id: wechatMsg.MsgId || `${wechatMsg.CreateTime}`,
|
|
288
|
-
$adapter: 'wechat-mp',
|
|
289
|
-
$endpoint: this.$config.name,
|
|
290
|
-
$sender: {
|
|
291
|
-
id: wechatMsg.FromUserName,
|
|
292
|
-
name: wechatMsg.FromUserName
|
|
293
|
-
},
|
|
294
|
-
$channel: {
|
|
295
|
-
id: channelId,
|
|
296
|
-
type: channelType
|
|
297
|
-
},
|
|
298
|
-
$raw: JSON.stringify(wechatMsg),
|
|
299
|
-
$timestamp: wechatMsg.CreateTime * 1000,
|
|
300
|
-
$content: content,
|
|
301
|
-
};
|
|
302
|
-
if (hasOutbound(this)) {
|
|
303
|
-
base.$recall = async () => {
|
|
304
|
-
await this.$recallMessage(wechatMsg.MsgId || `${wechatMsg.CreateTime}`);
|
|
305
|
-
};
|
|
306
|
-
base.$reply = async (replyContent) => {
|
|
307
|
-
return await this.adapter.sendMessage({
|
|
308
|
-
context: this.$config.context,
|
|
309
|
-
endpoint: this.$config.name,
|
|
310
|
-
id: wechatMsg.FromUserName,
|
|
311
|
-
type: 'private',
|
|
312
|
-
content: replyContent
|
|
313
|
-
});
|
|
314
|
-
};
|
|
102
|
+
async stop() {
|
|
103
|
+
this.#open = false;
|
|
104
|
+
if (this.#tokenRefreshTimer) {
|
|
105
|
+
clearInterval(this.#tokenRefreshTimer);
|
|
106
|
+
this.#tokenRefreshTimer = undefined;
|
|
315
107
|
}
|
|
316
|
-
|
|
108
|
+
for (const release of this.#routeReleases.splice(0))
|
|
109
|
+
release();
|
|
110
|
+
this.#started = false;
|
|
111
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
317
112
|
}
|
|
318
|
-
|
|
319
|
-
const segments = [];
|
|
320
|
-
switch (wechatMsg.MsgType) {
|
|
321
|
-
case 'text':
|
|
322
|
-
if (wechatMsg.Content) {
|
|
323
|
-
segments.push(segment.text(wechatMsg.Content));
|
|
324
|
-
}
|
|
325
|
-
break;
|
|
326
|
-
case 'image':
|
|
327
|
-
segments.push(segment('image', {
|
|
328
|
-
url: wechatMsg.PicUrl,
|
|
329
|
-
mediaId: wechatMsg.MediaId
|
|
330
|
-
}));
|
|
331
|
-
break;
|
|
332
|
-
case 'voice':
|
|
333
|
-
segments.push(segment('voice', {
|
|
334
|
-
mediaId: wechatMsg.MediaId,
|
|
335
|
-
format: wechatMsg.Format,
|
|
336
|
-
recognition: wechatMsg.Recognition
|
|
337
|
-
}));
|
|
338
|
-
break;
|
|
339
|
-
case 'video':
|
|
340
|
-
case 'shortvideo':
|
|
341
|
-
segments.push(segment('video', {
|
|
342
|
-
mediaId: wechatMsg.MediaId,
|
|
343
|
-
thumbMediaId: wechatMsg.ThumbMediaId
|
|
344
|
-
}));
|
|
345
|
-
break;
|
|
346
|
-
case 'location':
|
|
347
|
-
segments.push(segment('location', {
|
|
348
|
-
latitude: wechatMsg.Location_X,
|
|
349
|
-
longitude: wechatMsg.Location_Y,
|
|
350
|
-
scale: wechatMsg.Scale,
|
|
351
|
-
label: wechatMsg.Label
|
|
352
|
-
}));
|
|
353
|
-
break;
|
|
354
|
-
case 'link':
|
|
355
|
-
segments.push(segment('link', {
|
|
356
|
-
title: wechatMsg.Title,
|
|
357
|
-
description: wechatMsg.Description,
|
|
358
|
-
url: wechatMsg.Url
|
|
359
|
-
}));
|
|
360
|
-
break;
|
|
361
|
-
case 'event':
|
|
362
|
-
segments.push(segment('event', {
|
|
363
|
-
event: wechatMsg.Event,
|
|
364
|
-
eventKey: wechatMsg.EventKey
|
|
365
|
-
}));
|
|
366
|
-
break;
|
|
367
|
-
default:
|
|
368
|
-
segments.push(segment.text(`[不支持的消息类型: ${wechatMsg.MsgType}]`));
|
|
369
|
-
}
|
|
370
|
-
return segments.length > 0 ? segments : [segment.text('(空消息)')];
|
|
371
|
-
}
|
|
372
|
-
async $sendMessage(options) {
|
|
113
|
+
async send({ conversation, payload }) {
|
|
373
114
|
if (getPassiveReplyCapture()) {
|
|
374
|
-
const text =
|
|
115
|
+
const text = extractOutboundText(payload);
|
|
375
116
|
recordPassiveReplyText(text);
|
|
376
117
|
return `passive_${Date.now()}`;
|
|
377
118
|
}
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
return await this.sendCustomerServiceMessage(options);
|
|
381
|
-
}
|
|
382
|
-
catch (error) {
|
|
383
|
-
this.logger.error("Failed to send WeChat message:", error);
|
|
384
|
-
throw error;
|
|
385
|
-
}
|
|
119
|
+
if (this.#options.config.replyMode === 'customer_service') {
|
|
120
|
+
return this.#sendCustomerService(conversation.id, payload);
|
|
386
121
|
}
|
|
387
|
-
this
|
|
388
|
-
op:
|
|
389
|
-
skip:
|
|
390
|
-
endpoint: this
|
|
122
|
+
this.#logger.warn(formatCompact({
|
|
123
|
+
op: 'send',
|
|
124
|
+
skip: 'passive_outside_webhook',
|
|
125
|
+
endpoint: this.#options.config.id,
|
|
126
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
391
127
|
}));
|
|
392
128
|
return `passive_skipped_${Date.now()}`;
|
|
393
129
|
}
|
|
394
|
-
|
|
395
|
-
|
|
130
|
+
/** Test / internal: admit a parsed message when open (non-webhook path). */
|
|
131
|
+
admit(msg) {
|
|
132
|
+
if (!this.#open)
|
|
133
|
+
return;
|
|
134
|
+
void this.emitPlatform(msg.Event ? `${msg.MsgType}.${msg.Event}` : msg.MsgType, msg).catch((error) => {
|
|
135
|
+
this.#logger.warn(formatCompact({
|
|
136
|
+
op: 'wechat_mp_platform_event_failed',
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
}));
|
|
139
|
+
});
|
|
140
|
+
if (receiveWeChatMpSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, msg, this.#logger)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const conversation = wechatMpInboundConversation(String(this.#options.id), msg);
|
|
144
|
+
return this.emit('message.receive', {
|
|
145
|
+
conversation,
|
|
146
|
+
message: { conversation, id: formatInboundId(msg) },
|
|
147
|
+
content: formatInboundContent(msg),
|
|
148
|
+
sender: { id: msg.FromUserName },
|
|
149
|
+
endpointId: this.#options.config.id,
|
|
150
|
+
metadata: Object.freeze({
|
|
151
|
+
msgType: msg.MsgType,
|
|
152
|
+
event: msg.Event,
|
|
153
|
+
toUserName: msg.ToUserName,
|
|
154
|
+
}),
|
|
155
|
+
}).catch((err) => {
|
|
156
|
+
this.#logger.warn(formatCompact({
|
|
157
|
+
op: 'wechat_mp_gateway_receive_failed',
|
|
158
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
159
|
+
error: err instanceof Error ? err.message : String(err),
|
|
160
|
+
}));
|
|
161
|
+
});
|
|
396
162
|
}
|
|
397
|
-
async
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
const messageData = this.formatSendContent(options);
|
|
403
|
-
const response = await axios.post(url, messageData);
|
|
404
|
-
const result = response.data;
|
|
163
|
+
async #sendCustomerService(target, payload) {
|
|
164
|
+
// 发送前检查过期(不只判 null):过期 token 直接刷新,不白跑一次 40001。
|
|
165
|
+
const materialized = await this.#materializeOutboundMedia(payload);
|
|
166
|
+
const messageData = formatCustomerServiceBody(target, materialized);
|
|
167
|
+
const result = await this.client.sendCustomerService(messageData);
|
|
405
168
|
if (result.errcode && result.errcode !== 0) {
|
|
406
169
|
throw new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`);
|
|
407
170
|
}
|
|
171
|
+
this.#logger.debug(formatCompact({ op: 'wechat_mp_send', target, messageId: result.msgid }));
|
|
408
172
|
return result.msgid?.toString() || `cs_${Date.now()}`;
|
|
409
173
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
messageData.msgtype = 'voice';
|
|
446
|
-
messageData.voice = { media_id: segment.data.mediaId };
|
|
447
|
-
delete messageData.text;
|
|
448
|
-
hasMedia = true;
|
|
449
|
-
}
|
|
450
|
-
break;
|
|
451
|
-
case 'video':
|
|
452
|
-
if (!hasMedia && segment.data.mediaId) {
|
|
453
|
-
messageData.msgtype = 'video';
|
|
454
|
-
messageData.video = {
|
|
455
|
-
media_id: segment.data.mediaId,
|
|
456
|
-
title: segment.data.title || '',
|
|
457
|
-
description: segment.data.description || ''
|
|
458
|
-
};
|
|
459
|
-
delete messageData.text;
|
|
460
|
-
hasMedia = true;
|
|
461
|
-
}
|
|
462
|
-
break;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
if (!hasMedia && textParts.length > 0) {
|
|
467
|
-
messageData.text.content = textParts.join('\n');
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
return messageData;
|
|
471
|
-
}
|
|
472
|
-
getReplyMode() {
|
|
473
|
-
return this.$config.replyMode ?? "passive";
|
|
474
|
-
}
|
|
475
|
-
getEncryptMode() {
|
|
476
|
-
if (!this.$config.encrypt || !this.$config.encodingAESKey) {
|
|
477
|
-
return "plain";
|
|
478
|
-
}
|
|
479
|
-
return this.$config.encryptMode ?? "compatible";
|
|
480
|
-
}
|
|
481
|
-
usesPassiveReply() {
|
|
482
|
-
return this.getReplyMode() === "passive";
|
|
483
|
-
}
|
|
484
|
-
extractSendText(options) {
|
|
485
|
-
if (typeof options.content === "string") {
|
|
486
|
-
return options.content;
|
|
487
|
-
}
|
|
488
|
-
return segment.raw(options.content);
|
|
489
|
-
}
|
|
490
|
-
async collectPassiveReplyXml(wechatMsg, message) {
|
|
491
|
-
const timeoutMs = this.$config.passiveReplyTimeoutMs ?? 4500;
|
|
492
|
-
const text = await runWithPassiveReplyCapture(async () => {
|
|
493
|
-
await Promise.race([
|
|
494
|
-
runInboundMessage({
|
|
495
|
-
plugin: this.adapter.plugin,
|
|
496
|
-
message,
|
|
497
|
-
emitAdapterObservers: () => {
|
|
498
|
-
EventEmitter.prototype.emit.call(this.adapter, "message.receive", message);
|
|
499
|
-
},
|
|
500
|
-
}),
|
|
501
|
-
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
502
|
-
]);
|
|
503
|
-
return getPassiveReplyCapture()?.text ?? null;
|
|
504
|
-
});
|
|
505
|
-
if (!text) {
|
|
506
|
-
this.logger.warn(formatCompact({
|
|
507
|
-
op: "passive_reply",
|
|
508
|
-
ok: false,
|
|
509
|
-
reason: "timeout_or_empty",
|
|
510
|
-
timeoutMs,
|
|
511
|
-
}));
|
|
512
|
-
return "";
|
|
513
|
-
}
|
|
514
|
-
this.logger.info(formatCompact({
|
|
515
|
-
op: "passive_reply",
|
|
516
|
-
ok: true,
|
|
517
|
-
plainLen: text.length,
|
|
518
|
-
}));
|
|
519
|
-
return this.buildTextReply(wechatMsg, text);
|
|
520
|
-
}
|
|
521
|
-
async handlePassiveReply(wechatMsg, message) {
|
|
522
|
-
// 事件类型消息的自动回复
|
|
523
|
-
if (wechatMsg.MsgType === 'event') {
|
|
524
|
-
switch (wechatMsg.Event) {
|
|
525
|
-
case 'subscribe':
|
|
526
|
-
this.logger.info(formatCompact({ op: "subscribe", user: wechatMsg.FromUserName, scene: wechatMsg.EventKey }));
|
|
527
|
-
return this.buildTextReply(wechatMsg, '感谢关注!');
|
|
528
|
-
case 'unsubscribe':
|
|
529
|
-
this.logger.info(formatCompact({ op: "unsubscribe", user: wechatMsg.FromUserName }));
|
|
530
|
-
return '';
|
|
531
|
-
case 'SCAN':
|
|
532
|
-
this.logger.info(formatCompact({ op: "scan", user: wechatMsg.FromUserName, scene: wechatMsg.EventKey }));
|
|
533
|
-
return '';
|
|
534
|
-
case 'LOCATION':
|
|
535
|
-
this.logger.debug(`User location: ${wechatMsg.FromUserName}, lat=${wechatMsg.Location_X}, lng=${wechatMsg.Location_Y}`);
|
|
536
|
-
return '';
|
|
537
|
-
case 'CLICK':
|
|
538
|
-
this.logger.debug(`Menu click: ${wechatMsg.EventKey}`);
|
|
539
|
-
return '';
|
|
540
|
-
case 'VIEW':
|
|
541
|
-
this.logger.debug(`Menu view: ${wechatMsg.EventKey}`);
|
|
542
|
-
return '';
|
|
174
|
+
/**
|
|
175
|
+
* 客服消息媒体段只接受 media_id:canonical MediaRef 是唯一来源。
|
|
176
|
+
* - kind=file(平台不透明引用,即既有 media_id)→ 直接透传;
|
|
177
|
+
* - kind=base64 / path / url → 经 /cgi-bin/media/upload 物化;
|
|
178
|
+
* - 无 MediaRef / 类型不可投递(file 段)→ warn + 丢弃;
|
|
179
|
+
* - 上传失败降级为文本(alt 优先),不阻断发送。
|
|
180
|
+
*/
|
|
181
|
+
async #materializeOutboundMedia(payload) {
|
|
182
|
+
if (!Array.isArray(payload))
|
|
183
|
+
return payload;
|
|
184
|
+
const materialized = await Promise.all(payload.map(async (item) => {
|
|
185
|
+
if (typeof item === 'string' || !item || typeof item !== 'object')
|
|
186
|
+
return item;
|
|
187
|
+
const seg = item;
|
|
188
|
+
if (typeof seg.type !== 'string')
|
|
189
|
+
return item;
|
|
190
|
+
const data = seg.data ?? {};
|
|
191
|
+
const uploadType = WECHAT_UPLOAD_TYPE[seg.type];
|
|
192
|
+
const isMediaSegment = uploadType != null || seg.type === 'file';
|
|
193
|
+
if (!isMediaSegment)
|
|
194
|
+
return item;
|
|
195
|
+
const media = readOutboundMedia(data);
|
|
196
|
+
if (!media) {
|
|
197
|
+
// 已物化(mediaId/media_id)的段透传;其余无 canonical 媒体引用,丢弃留痕
|
|
198
|
+
if (typeof data.mediaId === 'string' && data.mediaId)
|
|
199
|
+
return item;
|
|
200
|
+
if (typeof data.media_id === 'string' && data.media_id)
|
|
201
|
+
return item;
|
|
202
|
+
this.#logger.warn(formatCompact({
|
|
203
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
204
|
+
endpoint: this.#options.config.id,
|
|
205
|
+
type: seg.type,
|
|
206
|
+
reason: 'missing_media_ref',
|
|
207
|
+
}));
|
|
208
|
+
return null;
|
|
543
209
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
buildTextReply(wechatMsg, content) {
|
|
548
|
-
const cdata = (value) => value.replace(/]]>/g, "]]]]><![CDATA[>");
|
|
549
|
-
const createTime = Math.floor(Date.now() / 1000);
|
|
550
|
-
return [
|
|
551
|
-
"<xml>",
|
|
552
|
-
`<ToUserName><![CDATA[${cdata(wechatMsg.FromUserName)}]]></ToUserName>`,
|
|
553
|
-
`<FromUserName><![CDATA[${cdata(wechatMsg.ToUserName)}]]></FromUserName>`,
|
|
554
|
-
`<CreateTime>${createTime}</CreateTime>`,
|
|
555
|
-
`<MsgType><![CDATA[text]]></MsgType>`,
|
|
556
|
-
`<Content><![CDATA[${cdata(content)}]]></Content>`,
|
|
557
|
-
"</xml>",
|
|
558
|
-
].join("");
|
|
559
|
-
}
|
|
560
|
-
async refreshAccessToken() {
|
|
561
|
-
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.$config.appId}&secret=${this.$config.appSecret}`;
|
|
562
|
-
try {
|
|
563
|
-
const response = await axios.get(url);
|
|
564
|
-
const data = response.data;
|
|
565
|
-
if (data.access_token) {
|
|
566
|
-
this.accessToken = data.access_token;
|
|
567
|
-
this.tokenExpireTime = Date.now() + (data.expires_in - 300) * 1000; // 提前5分钟刷新
|
|
568
|
-
this.logger.debug(formatCompact({ op: "token_refresh" }));
|
|
210
|
+
if (media.kind === 'file') {
|
|
211
|
+
// 平台不透明引用:value 即 media_id,直接透传不上传
|
|
212
|
+
return { type: seg.type, data: { mediaId: media.value } };
|
|
569
213
|
}
|
|
570
|
-
|
|
571
|
-
|
|
214
|
+
if (!uploadType) {
|
|
215
|
+
this.#logger.warn(formatCompact({
|
|
216
|
+
op: 'wechat_mp_outbound_media_dropped',
|
|
217
|
+
endpoint: this.#options.config.id,
|
|
218
|
+
type: seg.type,
|
|
219
|
+
reason: 'unsupported_segment_type',
|
|
220
|
+
}));
|
|
221
|
+
return null;
|
|
572
222
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
throw error;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
tokenRefreshTimer;
|
|
580
|
-
startTokenRefreshTimer() {
|
|
581
|
-
// 每小时检查一次token是否需要刷新
|
|
582
|
-
this.tokenRefreshTimer = setInterval(async () => {
|
|
583
|
-
if (Date.now() >= this.tokenExpireTime) {
|
|
584
|
-
try {
|
|
585
|
-
await this.refreshAccessToken();
|
|
586
|
-
}
|
|
587
|
-
catch (error) {
|
|
588
|
-
this.logger.error('Failed to refresh access token in timer:', error);
|
|
589
|
-
}
|
|
223
|
+
try {
|
|
224
|
+
const mediaId = await this.#uploadMedia(uploadType, media);
|
|
225
|
+
return { type: seg.type, data: { mediaId } };
|
|
590
226
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const response = await axios.get(url);
|
|
600
|
-
return response.data;
|
|
601
|
-
}
|
|
602
|
-
/**
|
|
603
|
-
* 上传多媒体文件到微信服务器
|
|
604
|
-
* @param type 媒体类型:image(图片)、voice(语音)、video(视频)、thumb(缩略图)
|
|
605
|
-
* @param buffer 文件 Buffer
|
|
606
|
-
* @param filename 文件名(可选,用于确定文件类型)
|
|
607
|
-
* @returns 微信服务器返回的 media_id
|
|
608
|
-
*/
|
|
609
|
-
async uploadMedia(type, buffer, filename) {
|
|
610
|
-
try {
|
|
611
|
-
// 确保有有效的 access_token
|
|
612
|
-
if (!this.accessToken) {
|
|
613
|
-
await this.refreshAccessToken();
|
|
227
|
+
catch (error) {
|
|
228
|
+
this.#logger.warn(formatCompact({
|
|
229
|
+
op: 'wechat_mp_media_upload_failed',
|
|
230
|
+
endpoint: this.#options.config.id,
|
|
231
|
+
error: error instanceof Error ? error.message : String(error),
|
|
232
|
+
}));
|
|
233
|
+
const alt = typeof data.alt === 'string' && data.alt ? data.alt : `[${seg.type}]`;
|
|
234
|
+
return { type: 'text', data: { text: alt } };
|
|
614
235
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
...form.getHeaders(),
|
|
631
|
-
},
|
|
632
|
-
maxBodyLength: Infinity,
|
|
633
|
-
maxContentLength: Infinity,
|
|
634
|
-
});
|
|
635
|
-
if (response.data.errcode) {
|
|
636
|
-
throw new Error(`微信媒体上传失败: ${response.data.errmsg} (错误码: ${response.data.errcode})`);
|
|
236
|
+
}));
|
|
237
|
+
return materialized.filter((item) => item != null);
|
|
238
|
+
}
|
|
239
|
+
/** POST /cgi-bin/media/upload(临时素材,3 天有效),返回 media_id。 */
|
|
240
|
+
async #uploadMedia(type, media) {
|
|
241
|
+
const binary = await resolveMediaBinary(media);
|
|
242
|
+
const form = buildMediaUploadForm(binary);
|
|
243
|
+
return this.client.uploadMedia(type, form);
|
|
244
|
+
}
|
|
245
|
+
#startTokenRefreshTimer() {
|
|
246
|
+
this.#tokenRefreshTimer = setInterval(() => {
|
|
247
|
+
if (this.client.tokenExpired) {
|
|
248
|
+
void this.client.refreshAccessToken().catch((error) => {
|
|
249
|
+
this.#logger.error('Failed to refresh access token in timer:', error);
|
|
250
|
+
});
|
|
637
251
|
}
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
catch (error) {
|
|
641
|
-
this.logger.error('上传媒体文件失败:', error);
|
|
642
|
-
throw error;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
/**
|
|
646
|
-
* 获取文件扩展名
|
|
647
|
-
*/
|
|
648
|
-
getFileExtension(type, filename) {
|
|
649
|
-
if (filename) {
|
|
650
|
-
const match = filename.match(/\.([^.]+)$/);
|
|
651
|
-
if (match)
|
|
652
|
-
return match[1];
|
|
653
|
-
}
|
|
654
|
-
// 默认扩展名
|
|
655
|
-
const defaultExt = {
|
|
656
|
-
image: 'jpg',
|
|
657
|
-
voice: 'mp3',
|
|
658
|
-
video: 'mp4',
|
|
659
|
-
thumb: 'jpg',
|
|
660
|
-
};
|
|
661
|
-
return defaultExt[type] || 'bin';
|
|
662
|
-
}
|
|
663
|
-
/**
|
|
664
|
-
* 获取 Content-Type
|
|
665
|
-
*/
|
|
666
|
-
getContentType(type) {
|
|
667
|
-
const contentTypes = {
|
|
668
|
-
image: 'image/jpeg',
|
|
669
|
-
voice: 'audio/mpeg',
|
|
670
|
-
video: 'video/mp4',
|
|
671
|
-
thumb: 'image/jpeg',
|
|
672
|
-
};
|
|
673
|
-
return contentTypes[type] || 'application/octet-stream';
|
|
674
|
-
}
|
|
675
|
-
// ── AES 加解密(安全模式) ──────────────────────────────
|
|
676
|
-
getAESKey() {
|
|
677
|
-
const key = this.$config.encodingAESKey;
|
|
678
|
-
return Buffer.from(key + '=', 'base64');
|
|
679
|
-
}
|
|
680
|
-
/** 微信安全模式加密 echostr 为较长 Base64;明文/兼容模式多为短字符串 */
|
|
681
|
-
isEncryptedEchostr(echostr) {
|
|
682
|
-
if (echostr.length < 32)
|
|
683
|
-
return false;
|
|
684
|
-
return /^[A-Za-z0-9+/]+={0,2}$/.test(echostr);
|
|
685
|
-
}
|
|
686
|
-
/**
|
|
687
|
-
* 解密安全模式 URL 验证中的 echostr
|
|
688
|
-
*/
|
|
689
|
-
decryptEchostr(encrypted) {
|
|
690
|
-
const aesKey = this.getAESKey();
|
|
691
|
-
const iv = aesKey.subarray(0, 16);
|
|
692
|
-
const decipher = createDecipheriv("aes-256-cbc", aesKey, iv);
|
|
693
|
-
decipher.setAutoPadding(false);
|
|
694
|
-
const decrypted = Buffer.concat([
|
|
695
|
-
decipher.update(Buffer.from(encrypted, "base64")),
|
|
696
|
-
decipher.final(),
|
|
697
|
-
]);
|
|
698
|
-
const pad = decrypted[decrypted.length - 1];
|
|
699
|
-
const content = decrypted.subarray(0, decrypted.length - pad);
|
|
700
|
-
const msgLen = content.readUInt32BE(16);
|
|
701
|
-
const plain = content.subarray(20, 20 + msgLen).toString("utf8");
|
|
702
|
-
const appId = content.subarray(20 + msgLen).toString("utf8");
|
|
703
|
-
if (appId !== this.$config.appId) {
|
|
704
|
-
throw new Error(`AppID mismatch: expected ${this.$config.appId}, got ${appId}`);
|
|
705
|
-
}
|
|
706
|
-
return plain;
|
|
707
|
-
}
|
|
708
|
-
/**
|
|
709
|
-
* 解密微信推送的加密消息
|
|
710
|
-
*/
|
|
711
|
-
async decryptMessage(encryptedXml, msgSignature, timestamp, nonce) {
|
|
712
|
-
// 从外层 XML 提取 Encrypt 字段
|
|
713
|
-
const parsed = await this.parseXMLMessage(encryptedXml);
|
|
714
|
-
const encrypt = parsed?.Encrypt;
|
|
715
|
-
if (!encrypt)
|
|
716
|
-
throw new Error('Missing Encrypt field in encrypted message');
|
|
717
|
-
// 校验 msg_signature
|
|
718
|
-
const expected = createHash('sha1')
|
|
719
|
-
.update([this.$config.token, timestamp, nonce, encrypt].sort().join(''))
|
|
720
|
-
.digest('hex');
|
|
721
|
-
if (expected !== msgSignature) {
|
|
722
|
-
throw new Error('msg_signature verification failed');
|
|
723
|
-
}
|
|
724
|
-
// AES-256-CBC 解密
|
|
725
|
-
const aesKey = this.getAESKey();
|
|
726
|
-
const iv = aesKey.subarray(0, 16);
|
|
727
|
-
const decipher = createDecipheriv('aes-256-cbc', aesKey, iv);
|
|
728
|
-
decipher.setAutoPadding(false);
|
|
729
|
-
const decrypted = Buffer.concat([
|
|
730
|
-
decipher.update(Buffer.from(encrypt, 'base64')),
|
|
731
|
-
decipher.final()
|
|
732
|
-
]);
|
|
733
|
-
// 去除 PKCS#7 填充
|
|
734
|
-
const pad = decrypted[decrypted.length - 1];
|
|
735
|
-
const content = decrypted.subarray(0, decrypted.length - pad);
|
|
736
|
-
// 格式: 16 bytes random + 4 bytes msgLen (network order) + msg + appId
|
|
737
|
-
const msgLen = content.readUInt32BE(16);
|
|
738
|
-
const xmlContent = content.subarray(20, 20 + msgLen).toString('utf8');
|
|
739
|
-
const appId = content.subarray(20 + msgLen).toString('utf8');
|
|
740
|
-
if (appId !== this.$config.appId) {
|
|
741
|
-
throw new Error(`AppID mismatch: expected ${this.$config.appId}, got ${appId}`);
|
|
742
|
-
}
|
|
743
|
-
return xmlContent;
|
|
744
|
-
}
|
|
745
|
-
/**
|
|
746
|
-
* 加密被动回复消息
|
|
747
|
-
*/
|
|
748
|
-
encryptMessage(replyXml, requestTimestamp) {
|
|
749
|
-
const aesKey = this.getAESKey();
|
|
750
|
-
const iv = aesKey.subarray(0, 16);
|
|
751
|
-
// 组装明文: 16 bytes random + 4 bytes msgLen + msg + appId
|
|
752
|
-
const random = randomBytes(16);
|
|
753
|
-
const msgBuf = Buffer.from(replyXml, 'utf8');
|
|
754
|
-
const appIdBuf = Buffer.from(this.$config.appId, 'utf8');
|
|
755
|
-
const lenBuf = Buffer.alloc(4);
|
|
756
|
-
lenBuf.writeUInt32BE(msgBuf.length, 0);
|
|
757
|
-
const plaintext = Buffer.concat([random, lenBuf, msgBuf, appIdBuf]);
|
|
758
|
-
// PKCS#7 填充
|
|
759
|
-
const blockSize = 32;
|
|
760
|
-
const padLen = blockSize - (plaintext.length % blockSize);
|
|
761
|
-
const padBuf = Buffer.alloc(padLen, padLen);
|
|
762
|
-
const padded = Buffer.concat([plaintext, padBuf]);
|
|
763
|
-
// AES-256-CBC 加密
|
|
764
|
-
const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
|
|
765
|
-
cipher.setAutoPadding(false);
|
|
766
|
-
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
767
|
-
const encryptStr = encrypted.toString('base64');
|
|
768
|
-
// 签名(TimeStamp 优先复用入站请求值,与微信官方示例一致)
|
|
769
|
-
const timestamp = requestTimestamp || Math.floor(Date.now() / 1000).toString();
|
|
770
|
-
const nonce = randomBytes(8).toString('hex');
|
|
771
|
-
const signature = createHash('sha1')
|
|
772
|
-
.update([this.$config.token, timestamp, nonce, encryptStr].sort().join(''))
|
|
773
|
-
.digest('hex');
|
|
774
|
-
return [
|
|
775
|
-
'<xml>',
|
|
776
|
-
`<Encrypt><![CDATA[${encryptStr}]]></Encrypt>`,
|
|
777
|
-
`<MsgSignature><![CDATA[${signature}]]></MsgSignature>`,
|
|
778
|
-
`<TimeStamp>${timestamp}</TimeStamp>`,
|
|
779
|
-
`<Nonce><![CDATA[${nonce}]]></Nonce>`,
|
|
780
|
-
'</xml>'
|
|
781
|
-
].join('\n');
|
|
252
|
+
}, 3_600_000);
|
|
782
253
|
}
|
|
783
254
|
}
|
|
784
|
-
|
|
785
|
-
|
|
255
|
+
function createWeChatMpEndpointManagement(requireClient) {
|
|
256
|
+
return Object.freeze({
|
|
257
|
+
// 公众号无群/频道概念;关注者即"好友"(nickname 为 openid 占位,见 getFollowers)。
|
|
258
|
+
listFriends: async () => (await requireClient().getFollowerIds()).map((openid) => ({
|
|
259
|
+
user_id: openid,
|
|
260
|
+
nickname: openid,
|
|
261
|
+
remark: '',
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
}
|