@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.
@@ -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
+ });