@xuanyue202/wechat-mp 2026.3.21
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/README.md +74 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/src/api.d.ts +101 -0
- package/dist/src/api.d.ts.map +1 -0
- package/dist/src/api.js +142 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/channel.d.ts +296 -0
- package/dist/src/channel.d.ts.map +1 -0
- package/dist/src/channel.js +341 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/config.d.ts +69 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +167 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/crypto.d.ts +117 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +270 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/crypto.test.d.ts +2 -0
- package/dist/src/crypto.test.d.ts.map +1 -0
- package/dist/src/crypto.test.js +76 -0
- package/dist/src/crypto.test.js.map +1 -0
- package/dist/src/dispatch.d.ts +15 -0
- package/dist/src/dispatch.d.ts.map +1 -0
- package/dist/src/dispatch.js +193 -0
- package/dist/src/dispatch.js.map +1 -0
- package/dist/src/dispatch.test.d.ts +2 -0
- package/dist/src/dispatch.test.d.ts.map +1 -0
- package/dist/src/dispatch.test.js +231 -0
- package/dist/src/dispatch.test.js.map +1 -0
- package/dist/src/inbound.d.ts +7 -0
- package/dist/src/inbound.d.ts.map +1 -0
- package/dist/src/inbound.js +82 -0
- package/dist/src/inbound.js.map +1 -0
- package/dist/src/onboarding.d.ts +25 -0
- package/dist/src/onboarding.d.ts.map +1 -0
- package/dist/src/onboarding.js +49 -0
- package/dist/src/onboarding.js.map +1 -0
- package/dist/src/outbound.d.ts +17 -0
- package/dist/src/outbound.d.ts.map +1 -0
- package/dist/src/outbound.js +55 -0
- package/dist/src/outbound.js.map +1 -0
- package/dist/src/outbound.test.d.ts +2 -0
- package/dist/src/outbound.test.d.ts.map +1 -0
- package/dist/src/outbound.test.js +175 -0
- package/dist/src/outbound.test.js.map +1 -0
- package/dist/src/probe.d.ts +15 -0
- package/dist/src/probe.d.ts.map +1 -0
- package/dist/src/probe.js +55 -0
- package/dist/src/probe.js.map +1 -0
- package/dist/src/runtime.d.ts +22 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +33 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/send.d.ts +27 -0
- package/dist/src/send.d.ts.map +1 -0
- package/dist/src/send.js +103 -0
- package/dist/src/send.js.map +1 -0
- package/dist/src/state.d.ts +7 -0
- package/dist/src/state.d.ts.map +1 -0
- package/dist/src/state.js +109 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/text.d.ts +46 -0
- package/dist/src/text.d.ts.map +1 -0
- package/dist/src/text.js +192 -0
- package/dist/src/text.js.map +1 -0
- package/dist/src/text.test.d.ts +2 -0
- package/dist/src/text.test.d.ts.map +1 -0
- package/dist/src/text.test.js +110 -0
- package/dist/src/text.test.js.map +1 -0
- package/dist/src/token.d.ts +40 -0
- package/dist/src/token.d.ts.map +1 -0
- package/dist/src/token.js +154 -0
- package/dist/src/token.js.map +1 -0
- package/dist/src/token.test.d.ts +2 -0
- package/dist/src/token.test.d.ts.map +1 -0
- package/dist/src/token.test.js +74 -0
- package/dist/src/token.test.js.map +1 -0
- package/dist/src/types.d.ts +320 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/webhook.d.ts +6 -0
- package/dist/src/webhook.d.ts.map +1 -0
- package/dist/src/webhook.js +381 -0
- package/dist/src/webhook.js.map +1 -0
- package/dist/src/webhook.test.d.ts +2 -0
- package/dist/src/webhook.test.d.ts.map +1 -0
- package/dist/src/webhook.test.js +737 -0
- package/dist/src/webhook.test.js.map +1 -0
- package/openclaw.plugin.json +83 -0
- package/package.json +103 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { Socket } from "node:net";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
const { sendWechatMpActiveTextMock } = vi.hoisted(() => ({
|
|
8
|
+
sendWechatMpActiveTextMock: vi.fn(async () => ({ ok: true })),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("./send.js", async () => {
|
|
11
|
+
const actual = await vi.importActual("./send.js");
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
sendWechatMpActiveText: sendWechatMpActiveTextMock,
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
import { buildWechatMpXml, computeMsgSignature, computeSignature, encryptWechatMpMessage, parseWechatMpXml } from "./crypto.js";
|
|
18
|
+
import { flushWechatMpStateForTests, setWechatMpStateFilePathForTests } from "./state.js";
|
|
19
|
+
import { handleWechatMpWebhookRequest, registerWechatMpWebhookTarget } from "./webhook.js";
|
|
20
|
+
import { clearWechatMpRuntime, setWechatMpRuntime } from "./runtime.js";
|
|
21
|
+
const token = "callback-token";
|
|
22
|
+
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
|
23
|
+
const appId = "wx-test-appid";
|
|
24
|
+
function createMockRequest(params) {
|
|
25
|
+
const socket = new Socket();
|
|
26
|
+
const req = new IncomingMessage(socket);
|
|
27
|
+
req.method = params.method;
|
|
28
|
+
req.url = params.url;
|
|
29
|
+
if (params.method === "POST") {
|
|
30
|
+
req.push(params.rawBody ?? "");
|
|
31
|
+
}
|
|
32
|
+
req.push(null);
|
|
33
|
+
return req;
|
|
34
|
+
}
|
|
35
|
+
function createMockResponse() {
|
|
36
|
+
const req = new IncomingMessage(new Socket());
|
|
37
|
+
const res = new ServerResponse(req);
|
|
38
|
+
const mutable = res;
|
|
39
|
+
let data = "";
|
|
40
|
+
mutable.write = (chunk) => {
|
|
41
|
+
data += String(chunk ?? "");
|
|
42
|
+
return true;
|
|
43
|
+
};
|
|
44
|
+
mutable.end = (chunk) => {
|
|
45
|
+
if (chunk)
|
|
46
|
+
data += String(chunk);
|
|
47
|
+
return res;
|
|
48
|
+
};
|
|
49
|
+
return Object.assign(res, {
|
|
50
|
+
_getData: () => data,
|
|
51
|
+
_getStatusCode: () => res.statusCode,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function createAccount(overrides) {
|
|
55
|
+
return {
|
|
56
|
+
accountId: "default",
|
|
57
|
+
name: "WeChat MP (default)",
|
|
58
|
+
enabled: true,
|
|
59
|
+
configured: true,
|
|
60
|
+
canSendActive: true,
|
|
61
|
+
config: {
|
|
62
|
+
appId,
|
|
63
|
+
appSecret: "secret",
|
|
64
|
+
token,
|
|
65
|
+
encodingAESKey,
|
|
66
|
+
webhookPath: "/wechat-mp",
|
|
67
|
+
messageMode: "safe",
|
|
68
|
+
replyMode: "passive",
|
|
69
|
+
},
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function createTarget(params) {
|
|
74
|
+
const account = createAccount(params?.account);
|
|
75
|
+
return {
|
|
76
|
+
account,
|
|
77
|
+
config: params?.cfg ?? {},
|
|
78
|
+
runtime: {
|
|
79
|
+
log: () => undefined,
|
|
80
|
+
error: () => undefined,
|
|
81
|
+
},
|
|
82
|
+
path: params?.path ?? account.config.webhookPath ?? "/wechat-mp",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function createEncryptedTextRequest(params) {
|
|
86
|
+
const timestamp = "1710000000";
|
|
87
|
+
const nonce = "nonce-post";
|
|
88
|
+
const plaintext = buildWechatMpXml({
|
|
89
|
+
ToUserName: appId,
|
|
90
|
+
FromUserName: params?.openId ?? "openid-1",
|
|
91
|
+
CreateTime: timestamp,
|
|
92
|
+
MsgType: "text",
|
|
93
|
+
Content: params?.content ?? "hello",
|
|
94
|
+
MsgId: params?.msgId ?? "msg-1",
|
|
95
|
+
});
|
|
96
|
+
const encrypted = encryptWechatMpMessage({
|
|
97
|
+
encodingAESKey,
|
|
98
|
+
appId,
|
|
99
|
+
plaintext,
|
|
100
|
+
}).encrypt;
|
|
101
|
+
const signature = computeMsgSignature({
|
|
102
|
+
token,
|
|
103
|
+
timestamp,
|
|
104
|
+
nonce,
|
|
105
|
+
encrypt: encrypted,
|
|
106
|
+
});
|
|
107
|
+
const rawBody = buildWechatMpXml({
|
|
108
|
+
ToUserName: appId,
|
|
109
|
+
Encrypt: encrypted,
|
|
110
|
+
MsgSignature: signature,
|
|
111
|
+
TimeStamp: timestamp,
|
|
112
|
+
Nonce: nonce,
|
|
113
|
+
});
|
|
114
|
+
return createMockRequest({
|
|
115
|
+
method: "POST",
|
|
116
|
+
url: `${params?.path ?? "/wechat-mp"}?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
117
|
+
rawBody,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
let tempDir = "";
|
|
121
|
+
let stateFilePath = "";
|
|
122
|
+
beforeEach(async () => {
|
|
123
|
+
tempDir = await mkdtemp(path.join(os.tmpdir(), "wechat-mp-webhook-"));
|
|
124
|
+
stateFilePath = path.join(tempDir, "state.json");
|
|
125
|
+
setWechatMpStateFilePathForTests(stateFilePath);
|
|
126
|
+
});
|
|
127
|
+
afterEach(async () => {
|
|
128
|
+
await flushWechatMpStateForTests();
|
|
129
|
+
clearWechatMpRuntime();
|
|
130
|
+
setWechatMpStateFilePathForTests();
|
|
131
|
+
sendWechatMpActiveTextMock.mockReset();
|
|
132
|
+
sendWechatMpActiveTextMock.mockResolvedValue({ ok: true });
|
|
133
|
+
vi.restoreAllMocks();
|
|
134
|
+
if (tempDir) {
|
|
135
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
tempDir = "";
|
|
138
|
+
stateFilePath = "";
|
|
139
|
+
});
|
|
140
|
+
describe("wechat-mp webhook", () => {
|
|
141
|
+
it("handles GET webhook verification in plain mode", async () => {
|
|
142
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({
|
|
143
|
+
account: {
|
|
144
|
+
config: {
|
|
145
|
+
appId,
|
|
146
|
+
appSecret: "secret",
|
|
147
|
+
token,
|
|
148
|
+
webhookPath: "/wechat-mp-plain",
|
|
149
|
+
messageMode: "plain",
|
|
150
|
+
replyMode: "passive",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
path: "/wechat-mp-plain",
|
|
154
|
+
}));
|
|
155
|
+
try {
|
|
156
|
+
const timestamp = "1710000000";
|
|
157
|
+
const nonce = "nonce-verify";
|
|
158
|
+
const echostr = "hello-echostr";
|
|
159
|
+
const signature = computeSignature({ token, timestamp, nonce });
|
|
160
|
+
const req = createMockRequest({
|
|
161
|
+
method: "GET",
|
|
162
|
+
url: `/wechat-mp-plain?signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`,
|
|
163
|
+
});
|
|
164
|
+
const res = createMockResponse();
|
|
165
|
+
const handled = await handleWechatMpWebhookRequest(req, res);
|
|
166
|
+
expect(handled).toBe(true);
|
|
167
|
+
expect(res._getStatusCode()).toBe(200);
|
|
168
|
+
expect(res._getData()).toBe(echostr);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
unregister();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
it("handles GET webhook verification in safe mode without msg_signature by returning plain echostr", async () => {
|
|
175
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({ path: "/wechat-mp-get-safe" }));
|
|
176
|
+
try {
|
|
177
|
+
const timestamp = "1710000000";
|
|
178
|
+
const nonce = "nonce-verify-safe-no-msgsig";
|
|
179
|
+
const echostr = "783367174650329039";
|
|
180
|
+
const signature = computeSignature({ token, timestamp, nonce });
|
|
181
|
+
const req = createMockRequest({
|
|
182
|
+
method: "GET",
|
|
183
|
+
url: `/wechat-mp-get-safe?signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`,
|
|
184
|
+
});
|
|
185
|
+
const res = createMockResponse();
|
|
186
|
+
const handled = await handleWechatMpWebhookRequest(req, res);
|
|
187
|
+
expect(handled).toBe(true);
|
|
188
|
+
expect(res._getStatusCode()).toBe(200);
|
|
189
|
+
expect(res._getData()).toBe(echostr);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
unregister();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
it("handles POST encrypted text message and ACKs success", async () => {
|
|
196
|
+
const unregister = registerWechatMpWebhookTarget(createTarget());
|
|
197
|
+
try {
|
|
198
|
+
const timestamp = "1710000000";
|
|
199
|
+
const nonce = "nonce-post";
|
|
200
|
+
const plaintext = buildWechatMpXml({
|
|
201
|
+
ToUserName: appId,
|
|
202
|
+
FromUserName: "openid-1",
|
|
203
|
+
CreateTime: "1710000000",
|
|
204
|
+
MsgType: "text",
|
|
205
|
+
Content: "hello",
|
|
206
|
+
MsgId: "msg-1",
|
|
207
|
+
});
|
|
208
|
+
const encrypted = encryptWechatMpMessage({
|
|
209
|
+
encodingAESKey,
|
|
210
|
+
appId,
|
|
211
|
+
plaintext,
|
|
212
|
+
}).encrypt;
|
|
213
|
+
const signature = computeMsgSignature({
|
|
214
|
+
token,
|
|
215
|
+
timestamp,
|
|
216
|
+
nonce,
|
|
217
|
+
encrypt: encrypted,
|
|
218
|
+
});
|
|
219
|
+
const rawBody = buildWechatMpXml({
|
|
220
|
+
ToUserName: appId,
|
|
221
|
+
Encrypt: encrypted,
|
|
222
|
+
MsgSignature: signature,
|
|
223
|
+
TimeStamp: timestamp,
|
|
224
|
+
Nonce: nonce,
|
|
225
|
+
});
|
|
226
|
+
const req = createMockRequest({
|
|
227
|
+
method: "POST",
|
|
228
|
+
url: `/wechat-mp?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
229
|
+
rawBody,
|
|
230
|
+
});
|
|
231
|
+
const res = createMockResponse();
|
|
232
|
+
const handled = await handleWechatMpWebhookRequest(req, res);
|
|
233
|
+
expect(handled).toBe(true);
|
|
234
|
+
expect(res._getStatusCode()).toBe(200);
|
|
235
|
+
expect(res._getData()).toBe("success");
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
unregister();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
it("rejects plain POST when signature is invalid", async () => {
|
|
242
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({
|
|
243
|
+
account: {
|
|
244
|
+
config: {
|
|
245
|
+
appId,
|
|
246
|
+
appSecret: "secret",
|
|
247
|
+
token,
|
|
248
|
+
webhookPath: "/wechat-mp-plain-post",
|
|
249
|
+
messageMode: "plain",
|
|
250
|
+
replyMode: "passive",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
path: "/wechat-mp-plain-post",
|
|
254
|
+
}));
|
|
255
|
+
try {
|
|
256
|
+
const timestamp = "1710000000";
|
|
257
|
+
const nonce = "nonce-plain-post";
|
|
258
|
+
const rawBody = buildWechatMpXml({
|
|
259
|
+
ToUserName: appId,
|
|
260
|
+
FromUserName: "openid-plain",
|
|
261
|
+
CreateTime: timestamp,
|
|
262
|
+
MsgType: "text",
|
|
263
|
+
Content: "hello",
|
|
264
|
+
MsgId: "msg-plain",
|
|
265
|
+
});
|
|
266
|
+
const req = createMockRequest({
|
|
267
|
+
method: "POST",
|
|
268
|
+
url: `/wechat-mp-plain-post?signature=bad-signature×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
269
|
+
rawBody,
|
|
270
|
+
});
|
|
271
|
+
const res = createMockResponse();
|
|
272
|
+
const handled = await handleWechatMpWebhookRequest(req, res);
|
|
273
|
+
expect(handled).toBe(true);
|
|
274
|
+
expect(res._getStatusCode()).toBe(401);
|
|
275
|
+
expect(res._getData()).toBe("unauthorized");
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
unregister();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
it("suppresses duplicate msgid via state dedupe", async () => {
|
|
282
|
+
const unregister = registerWechatMpWebhookTarget(createTarget());
|
|
283
|
+
try {
|
|
284
|
+
const timestamp = "1710000000";
|
|
285
|
+
const nonce = "nonce-post";
|
|
286
|
+
const plaintext = buildWechatMpXml({
|
|
287
|
+
ToUserName: appId,
|
|
288
|
+
FromUserName: "openid-1",
|
|
289
|
+
CreateTime: "1710000000",
|
|
290
|
+
MsgType: "text",
|
|
291
|
+
Content: "hello",
|
|
292
|
+
MsgId: "msg-dup",
|
|
293
|
+
});
|
|
294
|
+
const encrypted = encryptWechatMpMessage({ encodingAESKey, appId, plaintext }).encrypt;
|
|
295
|
+
const signature = computeMsgSignature({ token, timestamp, nonce, encrypt: encrypted });
|
|
296
|
+
const rawBody = buildWechatMpXml({
|
|
297
|
+
ToUserName: appId,
|
|
298
|
+
Encrypt: encrypted,
|
|
299
|
+
MsgSignature: signature,
|
|
300
|
+
TimeStamp: timestamp,
|
|
301
|
+
Nonce: nonce,
|
|
302
|
+
});
|
|
303
|
+
const req1 = createMockRequest({
|
|
304
|
+
method: "POST",
|
|
305
|
+
url: `/wechat-mp?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
306
|
+
rawBody,
|
|
307
|
+
});
|
|
308
|
+
const req2 = createMockRequest({
|
|
309
|
+
method: "POST",
|
|
310
|
+
url: `/wechat-mp?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
311
|
+
rawBody,
|
|
312
|
+
});
|
|
313
|
+
const res1 = createMockResponse();
|
|
314
|
+
const res2 = createMockResponse();
|
|
315
|
+
expect(await handleWechatMpWebhookRequest(req1, res1)).toBe(true);
|
|
316
|
+
expect(await handleWechatMpWebhookRequest(req2, res2)).toBe(true);
|
|
317
|
+
expect(res1._getData()).toBe("success");
|
|
318
|
+
expect(res2._getData()).toBe("success");
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
unregister();
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
it("returns passive reply xml when runtime produces final text", async () => {
|
|
325
|
+
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
|
326
|
+
await params.dispatcherOptions.deliver({ text: "final passive reply" });
|
|
327
|
+
});
|
|
328
|
+
setWechatMpRuntime({
|
|
329
|
+
channel: {
|
|
330
|
+
routing: {
|
|
331
|
+
resolveAgentRoute: () => ({
|
|
332
|
+
sessionKey: "session-1",
|
|
333
|
+
accountId: "default",
|
|
334
|
+
agentId: "agent-1",
|
|
335
|
+
}),
|
|
336
|
+
},
|
|
337
|
+
reply: {
|
|
338
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
339
|
+
},
|
|
340
|
+
session: {
|
|
341
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
342
|
+
readSessionUpdatedAt: () => null,
|
|
343
|
+
recordInboundSession: async () => undefined,
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
const unregister = registerWechatMpWebhookTarget(createTarget());
|
|
348
|
+
try {
|
|
349
|
+
const timestamp = "1710000000";
|
|
350
|
+
const nonce = "nonce-post-reply";
|
|
351
|
+
const plaintext = buildWechatMpXml({
|
|
352
|
+
ToUserName: appId,
|
|
353
|
+
FromUserName: "openid-2",
|
|
354
|
+
CreateTime: timestamp,
|
|
355
|
+
MsgType: "text",
|
|
356
|
+
Content: "hello",
|
|
357
|
+
MsgId: "msg-reply",
|
|
358
|
+
});
|
|
359
|
+
const encrypted = encryptWechatMpMessage({ encodingAESKey, appId, plaintext }).encrypt;
|
|
360
|
+
const signature = computeMsgSignature({ token, timestamp, nonce, encrypt: encrypted });
|
|
361
|
+
const rawBody = buildWechatMpXml({
|
|
362
|
+
ToUserName: appId,
|
|
363
|
+
Encrypt: encrypted,
|
|
364
|
+
MsgSignature: signature,
|
|
365
|
+
TimeStamp: timestamp,
|
|
366
|
+
Nonce: nonce,
|
|
367
|
+
});
|
|
368
|
+
const req = createMockRequest({
|
|
369
|
+
method: "POST",
|
|
370
|
+
url: `/wechat-mp?msg_signature=${encodeURIComponent(signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
371
|
+
rawBody,
|
|
372
|
+
});
|
|
373
|
+
const res = createMockResponse();
|
|
374
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
375
|
+
expect(res._getStatusCode()).toBe(200);
|
|
376
|
+
expect(res._getData()).toContain("<xml>");
|
|
377
|
+
const parsed = parseWechatMpXml(res._getData());
|
|
378
|
+
expect(parsed.Encrypt).toBeTruthy();
|
|
379
|
+
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
unregister();
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
it("records slash command as command-authorized context", async () => {
|
|
386
|
+
const recordInboundSession = vi.fn(async () => undefined);
|
|
387
|
+
setWechatMpRuntime({
|
|
388
|
+
channel: {
|
|
389
|
+
routing: {
|
|
390
|
+
resolveAgentRoute: () => ({
|
|
391
|
+
sessionKey: "session-1",
|
|
392
|
+
accountId: "default",
|
|
393
|
+
agentId: "agent-1",
|
|
394
|
+
}),
|
|
395
|
+
},
|
|
396
|
+
reply: {
|
|
397
|
+
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => undefined),
|
|
398
|
+
},
|
|
399
|
+
session: {
|
|
400
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
401
|
+
readSessionUpdatedAt: () => null,
|
|
402
|
+
recordInboundSession,
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
const unregister = registerWechatMpWebhookTarget(createTarget());
|
|
407
|
+
try {
|
|
408
|
+
const req = createEncryptedTextRequest({ content: "/verbose on", msgId: "msg-command" });
|
|
409
|
+
const res = createMockResponse();
|
|
410
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
411
|
+
expect(res._getStatusCode()).toBe(200);
|
|
412
|
+
expect(res._getData()).toBe("success");
|
|
413
|
+
expect(recordInboundSession).toHaveBeenCalledTimes(1);
|
|
414
|
+
const recordCall = recordInboundSession.mock.calls[0]?.[0];
|
|
415
|
+
expect(recordCall?.ctx?.CommandBody).toBe("/verbose on");
|
|
416
|
+
expect(recordCall?.ctx?.CommandAuthorized).toBe(true);
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
unregister();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
it("ignores split delivery config in passive mode", async () => {
|
|
423
|
+
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
|
424
|
+
await params.dispatcherOptions.deliver({ text: "passive final reply" });
|
|
425
|
+
});
|
|
426
|
+
setWechatMpRuntime({
|
|
427
|
+
channel: {
|
|
428
|
+
routing: {
|
|
429
|
+
resolveAgentRoute: () => ({
|
|
430
|
+
sessionKey: "session-1",
|
|
431
|
+
accountId: "default",
|
|
432
|
+
agentId: "agent-1",
|
|
433
|
+
}),
|
|
434
|
+
},
|
|
435
|
+
reply: {
|
|
436
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
437
|
+
},
|
|
438
|
+
session: {
|
|
439
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
440
|
+
readSessionUpdatedAt: () => null,
|
|
441
|
+
recordInboundSession: async () => undefined,
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({
|
|
446
|
+
account: {
|
|
447
|
+
config: {
|
|
448
|
+
appId,
|
|
449
|
+
appSecret: "secret",
|
|
450
|
+
token,
|
|
451
|
+
encodingAESKey,
|
|
452
|
+
webhookPath: "/wechat-mp-passive-split",
|
|
453
|
+
messageMode: "safe",
|
|
454
|
+
replyMode: "passive",
|
|
455
|
+
activeDeliveryMode: "split",
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
path: "/wechat-mp-passive-split",
|
|
459
|
+
}));
|
|
460
|
+
try {
|
|
461
|
+
const req = createEncryptedTextRequest({ path: "/wechat-mp-passive-split", msgId: "msg-passive-split" });
|
|
462
|
+
const res = createMockResponse();
|
|
463
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
464
|
+
expect(res._getStatusCode()).toBe(200);
|
|
465
|
+
expect(res._getData()).toContain("<xml>");
|
|
466
|
+
expect(sendWechatMpActiveTextMock).not.toHaveBeenCalled();
|
|
467
|
+
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
unregister();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
it("sends one merged active message in active merged mode", async () => {
|
|
474
|
+
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
|
475
|
+
await params.dispatcherOptions.deliver({ text: "step 1" });
|
|
476
|
+
await params.dispatcherOptions.deliver({ text: "step 2" });
|
|
477
|
+
});
|
|
478
|
+
setWechatMpRuntime({
|
|
479
|
+
channel: {
|
|
480
|
+
routing: {
|
|
481
|
+
resolveAgentRoute: () => ({
|
|
482
|
+
sessionKey: "session-1",
|
|
483
|
+
accountId: "default",
|
|
484
|
+
agentId: "agent-1",
|
|
485
|
+
}),
|
|
486
|
+
},
|
|
487
|
+
reply: {
|
|
488
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
489
|
+
},
|
|
490
|
+
session: {
|
|
491
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
492
|
+
readSessionUpdatedAt: () => null,
|
|
493
|
+
recordInboundSession: async () => undefined,
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({
|
|
498
|
+
account: {
|
|
499
|
+
config: {
|
|
500
|
+
appId,
|
|
501
|
+
appSecret: "secret",
|
|
502
|
+
token,
|
|
503
|
+
encodingAESKey,
|
|
504
|
+
webhookPath: "/wechat-mp-active-merged",
|
|
505
|
+
messageMode: "safe",
|
|
506
|
+
replyMode: "active",
|
|
507
|
+
activeDeliveryMode: "merged",
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
path: "/wechat-mp-active-merged",
|
|
511
|
+
}));
|
|
512
|
+
try {
|
|
513
|
+
const req = createEncryptedTextRequest({ path: "/wechat-mp-active-merged", msgId: "msg-active-merged" });
|
|
514
|
+
const res = createMockResponse();
|
|
515
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
516
|
+
expect(res._getStatusCode()).toBe(200);
|
|
517
|
+
expect(res._getData()).toBe("success");
|
|
518
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenCalledTimes(1);
|
|
519
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
520
|
+
toUserName: "openid-1",
|
|
521
|
+
text: "step 1\n\nstep 2",
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
unregister();
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
it("sends one active message per chunk in active split mode", async () => {
|
|
529
|
+
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
|
530
|
+
await params.dispatcherOptions.deliver({ text: "log 1" });
|
|
531
|
+
await params.dispatcherOptions.deliver({ text: "log 2" });
|
|
532
|
+
});
|
|
533
|
+
setWechatMpRuntime({
|
|
534
|
+
channel: {
|
|
535
|
+
routing: {
|
|
536
|
+
resolveAgentRoute: () => ({
|
|
537
|
+
sessionKey: "session-1",
|
|
538
|
+
accountId: "default",
|
|
539
|
+
agentId: "agent-1",
|
|
540
|
+
}),
|
|
541
|
+
},
|
|
542
|
+
reply: {
|
|
543
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
544
|
+
},
|
|
545
|
+
session: {
|
|
546
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
547
|
+
readSessionUpdatedAt: () => null,
|
|
548
|
+
recordInboundSession: async () => undefined,
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({
|
|
553
|
+
account: {
|
|
554
|
+
config: {
|
|
555
|
+
appId,
|
|
556
|
+
appSecret: "secret",
|
|
557
|
+
token,
|
|
558
|
+
encodingAESKey,
|
|
559
|
+
webhookPath: "/wechat-mp-active-split",
|
|
560
|
+
messageMode: "safe",
|
|
561
|
+
replyMode: "active",
|
|
562
|
+
activeDeliveryMode: "split",
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
path: "/wechat-mp-active-split",
|
|
566
|
+
}));
|
|
567
|
+
try {
|
|
568
|
+
const req = createEncryptedTextRequest({ path: "/wechat-mp-active-split", msgId: "msg-active-split" });
|
|
569
|
+
const res = createMockResponse();
|
|
570
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
571
|
+
expect(res._getStatusCode()).toBe(200);
|
|
572
|
+
expect(res._getData()).toBe("success");
|
|
573
|
+
await vi.waitFor(() => {
|
|
574
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenCalledTimes(2);
|
|
575
|
+
});
|
|
576
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ toUserName: "openid-1", text: "log 1" }));
|
|
577
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ toUserName: "openid-1", text: "log 2" }));
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
unregister();
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
it("normalizes markdown in passive reply by default", async () => {
|
|
584
|
+
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
|
585
|
+
await params.dispatcherOptions.deliver({ text: "**bold** and `code`" });
|
|
586
|
+
});
|
|
587
|
+
setWechatMpRuntime({
|
|
588
|
+
channel: {
|
|
589
|
+
routing: {
|
|
590
|
+
resolveAgentRoute: () => ({
|
|
591
|
+
sessionKey: "session-1",
|
|
592
|
+
accountId: "default",
|
|
593
|
+
agentId: "agent-1",
|
|
594
|
+
}),
|
|
595
|
+
},
|
|
596
|
+
reply: {
|
|
597
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
598
|
+
},
|
|
599
|
+
session: {
|
|
600
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
601
|
+
readSessionUpdatedAt: () => null,
|
|
602
|
+
recordInboundSession: async () => undefined,
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
const unregister = registerWechatMpWebhookTarget(createTarget());
|
|
607
|
+
try {
|
|
608
|
+
const req = createEncryptedTextRequest({ msgId: "msg-passive-md" });
|
|
609
|
+
const res = createMockResponse();
|
|
610
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
611
|
+
expect(res._getStatusCode()).toBe(200);
|
|
612
|
+
expect(res._getData()).toContain("<xml>");
|
|
613
|
+
const parsed = parseWechatMpXml(res._getData());
|
|
614
|
+
expect(parsed.Encrypt).toBeTruthy();
|
|
615
|
+
// The reply should have markdown stripped
|
|
616
|
+
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
unregister();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
it("normalizes markdown in active merged mode by default", async () => {
|
|
623
|
+
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
|
624
|
+
await params.dispatcherOptions.deliver({ text: "# Title" });
|
|
625
|
+
await params.dispatcherOptions.deliver({ text: "**bold** text" });
|
|
626
|
+
});
|
|
627
|
+
setWechatMpRuntime({
|
|
628
|
+
channel: {
|
|
629
|
+
routing: {
|
|
630
|
+
resolveAgentRoute: () => ({
|
|
631
|
+
sessionKey: "session-1",
|
|
632
|
+
accountId: "default",
|
|
633
|
+
agentId: "agent-1",
|
|
634
|
+
}),
|
|
635
|
+
},
|
|
636
|
+
reply: {
|
|
637
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
638
|
+
},
|
|
639
|
+
session: {
|
|
640
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
641
|
+
readSessionUpdatedAt: () => null,
|
|
642
|
+
recordInboundSession: async () => undefined,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({
|
|
647
|
+
account: {
|
|
648
|
+
config: {
|
|
649
|
+
appId,
|
|
650
|
+
appSecret: "secret",
|
|
651
|
+
token,
|
|
652
|
+
encodingAESKey,
|
|
653
|
+
webhookPath: "/wechat-mp-active-merged-md",
|
|
654
|
+
messageMode: "safe",
|
|
655
|
+
replyMode: "active",
|
|
656
|
+
activeDeliveryMode: "merged",
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
path: "/wechat-mp-active-merged-md",
|
|
660
|
+
}));
|
|
661
|
+
try {
|
|
662
|
+
const req = createEncryptedTextRequest({ path: "/wechat-mp-active-merged-md", msgId: "msg-merged-md" });
|
|
663
|
+
const res = createMockResponse();
|
|
664
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
665
|
+
expect(res._getStatusCode()).toBe(200);
|
|
666
|
+
expect(res._getData()).toBe("success");
|
|
667
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenCalledTimes(1);
|
|
668
|
+
// Markdown is normalized: headings become [bracketed], bold is stripped
|
|
669
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
670
|
+
toUserName: "openid-1",
|
|
671
|
+
text: "[Title]\n\nbold text",
|
|
672
|
+
}));
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
unregister();
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
it("preserves markdown when renderMarkdown is false in active split mode", async () => {
|
|
679
|
+
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (params) => {
|
|
680
|
+
await params.dispatcherOptions.deliver({ text: "**bold** text" });
|
|
681
|
+
});
|
|
682
|
+
setWechatMpRuntime({
|
|
683
|
+
channel: {
|
|
684
|
+
routing: {
|
|
685
|
+
resolveAgentRoute: () => ({
|
|
686
|
+
sessionKey: "session-1",
|
|
687
|
+
accountId: "default",
|
|
688
|
+
agentId: "agent-1",
|
|
689
|
+
}),
|
|
690
|
+
},
|
|
691
|
+
reply: {
|
|
692
|
+
dispatchReplyWithBufferedBlockDispatcher,
|
|
693
|
+
},
|
|
694
|
+
session: {
|
|
695
|
+
resolveStorePath: () => "/tmp/session-store",
|
|
696
|
+
readSessionUpdatedAt: () => null,
|
|
697
|
+
recordInboundSession: async () => undefined,
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
const unregister = registerWechatMpWebhookTarget(createTarget({
|
|
702
|
+
account: {
|
|
703
|
+
config: {
|
|
704
|
+
appId,
|
|
705
|
+
appSecret: "secret",
|
|
706
|
+
token,
|
|
707
|
+
encodingAESKey,
|
|
708
|
+
webhookPath: "/wechat-mp-active-split-norm",
|
|
709
|
+
messageMode: "safe",
|
|
710
|
+
replyMode: "active",
|
|
711
|
+
activeDeliveryMode: "split",
|
|
712
|
+
renderMarkdown: false,
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
path: "/wechat-mp-active-split-norm",
|
|
716
|
+
}));
|
|
717
|
+
try {
|
|
718
|
+
const req = createEncryptedTextRequest({ path: "/wechat-mp-active-split-norm", msgId: "msg-split-norm" });
|
|
719
|
+
const res = createMockResponse();
|
|
720
|
+
expect(await handleWechatMpWebhookRequest(req, res)).toBe(true);
|
|
721
|
+
expect(res._getStatusCode()).toBe(200);
|
|
722
|
+
expect(res._getData()).toBe("success");
|
|
723
|
+
await vi.waitFor(() => {
|
|
724
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenCalledTimes(1);
|
|
725
|
+
});
|
|
726
|
+
// Markdown is preserved when renderMarkdown=false
|
|
727
|
+
expect(sendWechatMpActiveTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
728
|
+
toUserName: "openid-1",
|
|
729
|
+
text: "**bold** text",
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
unregister();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
//# sourceMappingURL=webhook.test.js.map
|