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