@elizaos/plugin-wechat 2.0.0-alpha.537
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 +26 -0
- package/dist/bot.d.ts +25 -0
- package/dist/bot.js +49 -0
- package/dist/callback-server.js +207 -0
- package/dist/channel.d.ts +28 -0
- package/dist/channel.js +194 -0
- package/dist/index.d.ts +173 -0
- package/dist/index.js +833 -0
- package/dist/proxy-client.d.ts +35 -0
- package/dist/proxy-client.js +117 -0
- package/dist/reply-dispatcher.d.ts +17 -0
- package/dist/reply-dispatcher.js +47 -0
- package/dist/runtime-bridge.d.ts +12 -0
- package/dist/runtime-bridge.js +159 -0
- package/dist/types.d.ts +61 -0
- package/dist/utils/qrcode.js +20 -0
- package/package.json +65 -0
- package/src/bot.ts +95 -0
- package/src/callback-server.test.ts +190 -0
- package/src/callback-server.ts +283 -0
- package/src/channel.test.ts +121 -0
- package/src/channel.ts +314 -0
- package/src/index.ts +100 -0
- package/src/proxy-client-429.test.ts +24 -0
- package/src/proxy-client.test.ts +46 -0
- package/src/proxy-client.ts +189 -0
- package/src/reply-dispatcher.ts +75 -0
- package/src/runtime-bridge.test.ts +135 -0
- package/src/runtime-bridge.ts +259 -0
- package/src/types.ts +76 -0
- package/src/utils/qrcode.ts +16 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { startCallbackServer } from "./callback-server";
|
|
3
|
+
|
|
4
|
+
const servers: Array<{ close: () => Promise<void> }> = [];
|
|
5
|
+
|
|
6
|
+
afterEach(async () => {
|
|
7
|
+
await Promise.all(servers.splice(0).map((server) => server.close()));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("startCallbackServer", () => {
|
|
11
|
+
it("routes a webhook to the matching account and validates that account's API key", async () => {
|
|
12
|
+
const onMessage = vi.fn();
|
|
13
|
+
const server = await startCallbackServer({
|
|
14
|
+
port: 0,
|
|
15
|
+
accounts: [
|
|
16
|
+
{ accountId: "main", apiKey: "main-key" },
|
|
17
|
+
{ accountId: "secondary", apiKey: "secondary-key" },
|
|
18
|
+
],
|
|
19
|
+
onMessage,
|
|
20
|
+
});
|
|
21
|
+
servers.push(server);
|
|
22
|
+
|
|
23
|
+
const response = await fetch(
|
|
24
|
+
`http://127.0.0.1:${server.port}/webhook/wechat/secondary`,
|
|
25
|
+
{
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: {
|
|
28
|
+
"content-type": "application/json",
|
|
29
|
+
"x-api-key": "secondary-key",
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
type: 60001,
|
|
33
|
+
sender: "wxid-alice",
|
|
34
|
+
recipient: "wxid-agent",
|
|
35
|
+
content: "hello",
|
|
36
|
+
timestamp: 123,
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(response.status).toBe(200);
|
|
42
|
+
expect(onMessage).toHaveBeenCalledWith(
|
|
43
|
+
"secondary",
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
sender: "wxid-alice",
|
|
46
|
+
content: "hello",
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects requests signed with another account's API key", async () => {
|
|
52
|
+
const onMessage = vi.fn();
|
|
53
|
+
const server = await startCallbackServer({
|
|
54
|
+
port: 0,
|
|
55
|
+
accounts: [
|
|
56
|
+
{ accountId: "main", apiKey: "main-key" },
|
|
57
|
+
{ accountId: "secondary", apiKey: "secondary-key" },
|
|
58
|
+
],
|
|
59
|
+
onMessage,
|
|
60
|
+
});
|
|
61
|
+
servers.push(server);
|
|
62
|
+
|
|
63
|
+
const response = await fetch(
|
|
64
|
+
`http://127.0.0.1:${server.port}/webhook/wechat/secondary`,
|
|
65
|
+
{
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
"content-type": "application/json",
|
|
69
|
+
"x-api-key": "main-key",
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
type: 60001,
|
|
73
|
+
sender: "wxid-alice",
|
|
74
|
+
recipient: "wxid-agent",
|
|
75
|
+
content: "hello",
|
|
76
|
+
timestamp: 123,
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(response.status).toBe(401);
|
|
82
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects oversized webhook payloads", async () => {
|
|
86
|
+
const onMessage = vi.fn();
|
|
87
|
+
const server = await startCallbackServer({
|
|
88
|
+
port: 0,
|
|
89
|
+
accounts: [{ accountId: "main", apiKey: "main-key" }],
|
|
90
|
+
maxBodyBytes: 64,
|
|
91
|
+
onMessage,
|
|
92
|
+
});
|
|
93
|
+
servers.push(server);
|
|
94
|
+
|
|
95
|
+
const response = await fetch(
|
|
96
|
+
`http://127.0.0.1:${server.port}/webhook/wechat/main`,
|
|
97
|
+
{
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: {
|
|
100
|
+
"content-type": "application/json",
|
|
101
|
+
"x-api-key": "main-key",
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
type: 60001,
|
|
105
|
+
sender: "wxid-alice",
|
|
106
|
+
recipient: "wxid-agent",
|
|
107
|
+
content: "x".repeat(512),
|
|
108
|
+
timestamp: 123,
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(response.status).toBe(413);
|
|
114
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("maps voice message type correctly", async () => {
|
|
118
|
+
const onMessage = vi.fn();
|
|
119
|
+
const server = await startCallbackServer({
|
|
120
|
+
port: 0,
|
|
121
|
+
accounts: [{ accountId: "main", apiKey: "key" }],
|
|
122
|
+
onMessage,
|
|
123
|
+
});
|
|
124
|
+
servers.push(server);
|
|
125
|
+
|
|
126
|
+
const response = await fetch(
|
|
127
|
+
`http://127.0.0.1:${server.port}/webhook/wechat/main`,
|
|
128
|
+
{
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "content-type": "application/json", "x-api-key": "key" },
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
data: {
|
|
133
|
+
type: 60003,
|
|
134
|
+
sender: "wxid-alice",
|
|
135
|
+
recipient: "wxid-agent",
|
|
136
|
+
content: "",
|
|
137
|
+
timestamp: 456,
|
|
138
|
+
mediaUrl: "https://example.com/voice.amr",
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(response.status).toBe(200);
|
|
145
|
+
expect(onMessage).toHaveBeenCalledWith(
|
|
146
|
+
"main",
|
|
147
|
+
expect.objectContaining({
|
|
148
|
+
type: "voice",
|
|
149
|
+
imageUrl: "https://example.com/voice.amr",
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("maps group video message type correctly", async () => {
|
|
155
|
+
const onMessage = vi.fn();
|
|
156
|
+
const server = await startCallbackServer({
|
|
157
|
+
port: 0,
|
|
158
|
+
accounts: [{ accountId: "main", apiKey: "key" }],
|
|
159
|
+
onMessage,
|
|
160
|
+
});
|
|
161
|
+
servers.push(server);
|
|
162
|
+
|
|
163
|
+
const response = await fetch(
|
|
164
|
+
`http://127.0.0.1:${server.port}/webhook/wechat/main`,
|
|
165
|
+
{
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "content-type": "application/json", "x-api-key": "key" },
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
data: {
|
|
170
|
+
type: 80004,
|
|
171
|
+
sender: "room@chatroom",
|
|
172
|
+
recipient: "wxid-agent",
|
|
173
|
+
content: "",
|
|
174
|
+
timestamp: 789,
|
|
175
|
+
mediaUrl: "https://example.com/video.mp4",
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(response.status).toBe(200);
|
|
182
|
+
expect(onMessage).toHaveBeenCalledWith(
|
|
183
|
+
"main",
|
|
184
|
+
expect.objectContaining({
|
|
185
|
+
type: "video",
|
|
186
|
+
imageUrl: "https://example.com/video.mp4",
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
createServer,
|
|
4
|
+
type IncomingMessage,
|
|
5
|
+
type ServerResponse,
|
|
6
|
+
} from "node:http";
|
|
7
|
+
import type { AddressInfo } from "node:net";
|
|
8
|
+
import type { WechatMessageContext, WechatMessageType } from "./types";
|
|
9
|
+
|
|
10
|
+
const WECHAT_TYPE_MAP: Record<
|
|
11
|
+
number,
|
|
12
|
+
{ type: WechatMessageType; scope: "private" | "group" }
|
|
13
|
+
> = {
|
|
14
|
+
// Private message types
|
|
15
|
+
60001: { type: "text", scope: "private" },
|
|
16
|
+
60002: { type: "image", scope: "private" },
|
|
17
|
+
60003: { type: "voice", scope: "private" },
|
|
18
|
+
60004: { type: "video", scope: "private" },
|
|
19
|
+
60005: { type: "file", scope: "private" },
|
|
20
|
+
// Group message types
|
|
21
|
+
80001: { type: "text", scope: "group" },
|
|
22
|
+
80002: { type: "image", scope: "group" },
|
|
23
|
+
80003: { type: "voice", scope: "group" },
|
|
24
|
+
80004: { type: "video", scope: "group" },
|
|
25
|
+
80005: { type: "file", scope: "group" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
export interface CallbackServerOptions {
|
|
31
|
+
port: number;
|
|
32
|
+
accounts: Array<{ accountId: string; apiKey: string }>;
|
|
33
|
+
onMessage: (accountId: string, msg: WechatMessageContext) => void;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
maxBodyBytes?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function startCallbackServer(
|
|
39
|
+
options: CallbackServerOptions,
|
|
40
|
+
): Promise<{
|
|
41
|
+
close: () => Promise<void>;
|
|
42
|
+
port: number;
|
|
43
|
+
}> {
|
|
44
|
+
const {
|
|
45
|
+
port,
|
|
46
|
+
accounts,
|
|
47
|
+
onMessage,
|
|
48
|
+
signal,
|
|
49
|
+
maxBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
53
|
+
const account = resolveWebhookAccount(req.url, accounts);
|
|
54
|
+
if (req.method !== "POST" || !account) {
|
|
55
|
+
res.writeHead(404);
|
|
56
|
+
res.end("Not Found");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const incomingKey = readHeaderValue(req.headers["x-api-key"]);
|
|
61
|
+
if (!incomingKey || !safeCompare(incomingKey, account.apiKey)) {
|
|
62
|
+
res.writeHead(401);
|
|
63
|
+
res.end("Unauthorized");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let body = "";
|
|
68
|
+
let bodyBytes = 0;
|
|
69
|
+
req.on("data", (chunk: Buffer) => {
|
|
70
|
+
bodyBytes += chunk.length;
|
|
71
|
+
if (bodyBytes > maxBodyBytes) {
|
|
72
|
+
res.writeHead(413);
|
|
73
|
+
res.end("Payload Too Large");
|
|
74
|
+
req.destroy();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
body += chunk.toString();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
req.on("end", () => {
|
|
81
|
+
if (res.writableEnded) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const payload = JSON.parse(body) as Record<string, unknown>;
|
|
87
|
+
const message = normalizePayload(payload);
|
|
88
|
+
if (message) {
|
|
89
|
+
onMessage(account.accountId, message);
|
|
90
|
+
}
|
|
91
|
+
res.writeHead(200);
|
|
92
|
+
res.end("OK");
|
|
93
|
+
} catch {
|
|
94
|
+
res.writeHead(400);
|
|
95
|
+
res.end("Bad Request");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
req.on("error", () => {
|
|
100
|
+
if (res.writableEnded) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
res.writeHead(400);
|
|
105
|
+
res.end("Bad Request");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await new Promise<void>((resolve, reject) => {
|
|
110
|
+
const handleListening = () => {
|
|
111
|
+
server.off("error", handleError);
|
|
112
|
+
resolve();
|
|
113
|
+
};
|
|
114
|
+
const handleError = (error: Error) => {
|
|
115
|
+
server.off("listening", handleListening);
|
|
116
|
+
reject(error);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
server.once("listening", handleListening);
|
|
120
|
+
server.once("error", handleError);
|
|
121
|
+
server.listen(port);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const address = server.address() as AddressInfo | null;
|
|
125
|
+
const listeningPort = address?.port ?? port;
|
|
126
|
+
console.log(`[wechat] Webhook server listening on port ${listeningPort}`);
|
|
127
|
+
|
|
128
|
+
server.on("error", (err: Error) => {
|
|
129
|
+
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
|
|
130
|
+
console.error(
|
|
131
|
+
`[wechat] Port ${listeningPort} already in use — webhook server failed to start`,
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
console.error(`[wechat] Webhook server error:`, err);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (signal) {
|
|
139
|
+
signal.addEventListener(
|
|
140
|
+
"abort",
|
|
141
|
+
() => {
|
|
142
|
+
void closeServer(server);
|
|
143
|
+
},
|
|
144
|
+
{ once: true },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
close: () => closeServer(server),
|
|
150
|
+
port: listeningPort,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resolveWebhookAccount(
|
|
155
|
+
rawUrl: string | undefined,
|
|
156
|
+
accounts: Array<{ accountId: string; apiKey: string }>,
|
|
157
|
+
) {
|
|
158
|
+
if (!rawUrl) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pathname = new URL(rawUrl, "http://localhost").pathname;
|
|
163
|
+
if (pathname === "/webhook/wechat" && accounts.length === 1) {
|
|
164
|
+
return accounts[0];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const match = /^\/webhook\/wechat\/([^/]+)$/.exec(pathname);
|
|
168
|
+
if (!match) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const accountId = decodeURIComponent(match[1]);
|
|
173
|
+
return accounts.find((account) => account.accountId === accountId) ?? null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readHeaderValue(
|
|
177
|
+
value: string | string[] | undefined,
|
|
178
|
+
): string | undefined {
|
|
179
|
+
if (Array.isArray(value)) {
|
|
180
|
+
return value[0];
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function safeCompare(a: string, b: string): boolean {
|
|
186
|
+
const bufA = Buffer.from(a);
|
|
187
|
+
const bufB = Buffer.from(b);
|
|
188
|
+
if (bufA.length !== bufB.length) {
|
|
189
|
+
// Compare against itself to burn constant time, then return false
|
|
190
|
+
timingSafeEqual(bufA, bufA);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return timingSafeEqual(bufA, bufB);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
|
|
197
|
+
if (!server.listening) {
|
|
198
|
+
return Promise.resolve();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return new Promise((resolve, reject) => {
|
|
202
|
+
server.close((error) => {
|
|
203
|
+
if (error) {
|
|
204
|
+
reject(error);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
resolve();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function normalizePayload(
|
|
213
|
+
payload: Record<string, unknown>,
|
|
214
|
+
): WechatMessageContext | null {
|
|
215
|
+
// Support two payload formats: nested "raw" and flattened "proxy"
|
|
216
|
+
const data =
|
|
217
|
+
(payload.data as Record<string, unknown>) ??
|
|
218
|
+
(payload.content ? payload : null);
|
|
219
|
+
|
|
220
|
+
if (!data) {
|
|
221
|
+
console.warn("[wechat] Unrecognized webhook payload format");
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const typeCode = Number(data.type ?? data.msgType ?? 0);
|
|
226
|
+
const mapping = WECHAT_TYPE_MAP[typeCode];
|
|
227
|
+
|
|
228
|
+
let msgType: WechatMessageType = "unknown";
|
|
229
|
+
let scope: "private" | "group" = "private";
|
|
230
|
+
|
|
231
|
+
if (mapping) {
|
|
232
|
+
msgType = mapping.type;
|
|
233
|
+
scope = mapping.scope;
|
|
234
|
+
} else if (typeCode >= 60006 && typeCode <= 60010) {
|
|
235
|
+
// Unmapped private media — treat as file
|
|
236
|
+
msgType = "file";
|
|
237
|
+
scope = "private";
|
|
238
|
+
} else if (typeCode >= 80006 && typeCode <= 80010) {
|
|
239
|
+
// Unmapped group media — treat as file
|
|
240
|
+
msgType = "file";
|
|
241
|
+
scope = "group";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (msgType === "unknown") {
|
|
245
|
+
console.warn(`[wechat] Unknown message type code: ${typeCode}`);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const sender = String(data.sender ?? data.from ?? "");
|
|
250
|
+
const recipient = String(data.recipient ?? data.to ?? "");
|
|
251
|
+
const content = String(data.content ?? data.text ?? "");
|
|
252
|
+
const timestamp = Number(data.timestamp ?? Date.now());
|
|
253
|
+
const msgId = String(data.msgId ?? data.id ?? `${sender}-${timestamp}`);
|
|
254
|
+
|
|
255
|
+
// Group detection
|
|
256
|
+
const isGroup = scope === "group" || sender.includes("@chatroom");
|
|
257
|
+
const threadId = isGroup
|
|
258
|
+
? String(data.roomId ?? data.threadId ?? sender)
|
|
259
|
+
: undefined;
|
|
260
|
+
const groupSubject = isGroup
|
|
261
|
+
? String(data.roomName ?? data.groupName ?? threadId ?? "")
|
|
262
|
+
: undefined;
|
|
263
|
+
|
|
264
|
+
// Media URL extraction (images, voice, video, files)
|
|
265
|
+
const mediaTypes = new Set(["image", "voice", "video", "file"]);
|
|
266
|
+
const hasMedia = mediaTypes.has(msgType);
|
|
267
|
+
const imageUrl = hasMedia
|
|
268
|
+
? String(data.imageUrl ?? data.mediaUrl ?? data.url ?? data.fileUrl ?? "")
|
|
269
|
+
: undefined;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
id: msgId,
|
|
273
|
+
type: msgType,
|
|
274
|
+
sender,
|
|
275
|
+
recipient,
|
|
276
|
+
content,
|
|
277
|
+
timestamp,
|
|
278
|
+
threadId,
|
|
279
|
+
group: groupSubject ? { subject: groupSubject } : undefined,
|
|
280
|
+
imageUrl: imageUrl || undefined,
|
|
281
|
+
raw: payload,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { WechatChannel } from "./channel";
|
|
3
|
+
import type { WechatMessageContext } from "./types";
|
|
4
|
+
|
|
5
|
+
function createConfig() {
|
|
6
|
+
return {
|
|
7
|
+
accounts: {
|
|
8
|
+
main: {
|
|
9
|
+
apiKey: "main-key",
|
|
10
|
+
proxyUrl: "https://proxy.example.com",
|
|
11
|
+
},
|
|
12
|
+
secondary: {
|
|
13
|
+
apiKey: "secondary-key",
|
|
14
|
+
proxyUrl: "https://proxy.example.com",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createMessage(
|
|
21
|
+
overrides: Partial<WechatMessageContext> = {},
|
|
22
|
+
): WechatMessageContext {
|
|
23
|
+
return {
|
|
24
|
+
id: "wechat-msg-1",
|
|
25
|
+
type: "text",
|
|
26
|
+
sender: "wxid-alice",
|
|
27
|
+
recipient: "wxid-agent",
|
|
28
|
+
content: "hello agent",
|
|
29
|
+
timestamp: 123,
|
|
30
|
+
raw: {},
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.useRealTimers();
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("WechatChannel", () => {
|
|
41
|
+
it("routes inbound messages to the matching account bot", () => {
|
|
42
|
+
const channel = new WechatChannel({
|
|
43
|
+
config: createConfig(),
|
|
44
|
+
onMessage: vi.fn(),
|
|
45
|
+
});
|
|
46
|
+
const mainBot = { handleIncoming: vi.fn(), stop: vi.fn() };
|
|
47
|
+
const secondaryBot = { handleIncoming: vi.fn(), stop: vi.fn() };
|
|
48
|
+
|
|
49
|
+
(
|
|
50
|
+
channel as unknown as {
|
|
51
|
+
accounts: Map<
|
|
52
|
+
string,
|
|
53
|
+
{ client: object; dispatcher: object; bot: typeof mainBot }
|
|
54
|
+
>;
|
|
55
|
+
routeIncoming: (accountId: string, msg: WechatMessageContext) => void;
|
|
56
|
+
}
|
|
57
|
+
).accounts.set("main", {
|
|
58
|
+
client: {},
|
|
59
|
+
dispatcher: {},
|
|
60
|
+
bot: mainBot,
|
|
61
|
+
});
|
|
62
|
+
(
|
|
63
|
+
channel as unknown as {
|
|
64
|
+
accounts: Map<
|
|
65
|
+
string,
|
|
66
|
+
{ client: object; dispatcher: object; bot: typeof secondaryBot }
|
|
67
|
+
>;
|
|
68
|
+
}
|
|
69
|
+
).accounts.set("secondary", {
|
|
70
|
+
client: {},
|
|
71
|
+
dispatcher: {},
|
|
72
|
+
bot: secondaryBot,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
(
|
|
76
|
+
channel as unknown as {
|
|
77
|
+
routeIncoming: (accountId: string, msg: WechatMessageContext) => void;
|
|
78
|
+
}
|
|
79
|
+
).routeIncoming("secondary", createMessage());
|
|
80
|
+
|
|
81
|
+
expect(mainBot.handleIncoming).not.toHaveBeenCalled();
|
|
82
|
+
expect(secondaryBot.handleIncoming).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({ id: "wechat-msg-1" }),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("times out login when QR verification never completes", async () => {
|
|
88
|
+
vi.useFakeTimers();
|
|
89
|
+
vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
90
|
+
|
|
91
|
+
const channel = new WechatChannel({
|
|
92
|
+
config: createConfig(),
|
|
93
|
+
onMessage: vi.fn(),
|
|
94
|
+
});
|
|
95
|
+
const client = {
|
|
96
|
+
getStatus: vi.fn().mockResolvedValue({
|
|
97
|
+
valid: true,
|
|
98
|
+
loginState: "waiting",
|
|
99
|
+
}),
|
|
100
|
+
getQRCode: vi.fn().mockResolvedValue("https://proxy.example.com/qr"),
|
|
101
|
+
checkLogin: vi.fn().mockResolvedValue({ status: "waiting" }),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
(
|
|
105
|
+
channel as unknown as { abortController: AbortController | null }
|
|
106
|
+
).abortController = new AbortController();
|
|
107
|
+
|
|
108
|
+
const loginPromise = (
|
|
109
|
+
channel as unknown as {
|
|
110
|
+
ensureLoggedIn: (
|
|
111
|
+
accountId: string,
|
|
112
|
+
client: typeof client,
|
|
113
|
+
) => Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
).ensureLoggedIn("main", client);
|
|
116
|
+
const rejection = expect(loginPromise).rejects.toThrow(/timed out/i);
|
|
117
|
+
|
|
118
|
+
await vi.advanceTimersByTimeAsync(5 * 60_000 + 5_000);
|
|
119
|
+
await rejection;
|
|
120
|
+
});
|
|
121
|
+
});
|