@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
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @elizaos/plugin-wechat
|
|
2
|
+
|
|
3
|
+
WeChat connector plugin for [elizaOS](https://github.com/elizaOS/eliza) via proxy API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- Text and image messaging
|
|
7
|
+
- DM and group support
|
|
8
|
+
- Multi-account support
|
|
9
|
+
- QR code login flow
|
|
10
|
+
- Webhook-based message delivery
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx elizaos plugins add @elizaos/plugin-wechat
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configuration
|
|
19
|
+
|
|
20
|
+
| Env Var | Description |
|
|
21
|
+
|---------|-------------|
|
|
22
|
+
| `WECHAT_API_KEY` | Proxy service API key |
|
|
23
|
+
| `WECHAT_WEBHOOK_PORT` | Webhook listener port (default: 18790) |
|
|
24
|
+
|
|
25
|
+
## License
|
|
26
|
+
MIT
|
package/dist/bot.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { WechatMessageContext } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/bot.d.ts
|
|
4
|
+
interface BotOptions {
|
|
5
|
+
onMessage: (msg: WechatMessageContext) => void | Promise<void>;
|
|
6
|
+
featuresGroups?: boolean;
|
|
7
|
+
featuresImages?: boolean;
|
|
8
|
+
/** Deduplication window in milliseconds. Defaults to 30 minutes. */
|
|
9
|
+
dedupWindowMs?: number;
|
|
10
|
+
}
|
|
11
|
+
declare class Bot {
|
|
12
|
+
private readonly seen;
|
|
13
|
+
private readonly onMessage;
|
|
14
|
+
private readonly featuresGroups;
|
|
15
|
+
private readonly featuresImages;
|
|
16
|
+
private readonly dedupWindowMs;
|
|
17
|
+
private cleanupTimer;
|
|
18
|
+
constructor(options: BotOptions);
|
|
19
|
+
handleIncoming(message: WechatMessageContext): void;
|
|
20
|
+
private isDuplicate;
|
|
21
|
+
private cleanup;
|
|
22
|
+
stop(): void;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { Bot };
|
package/dist/bot.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/bot.ts
|
|
2
|
+
const DEFAULT_DEDUP_WINDOW_MS = 1800 * 1e3;
|
|
3
|
+
const DEDUP_MAX_ENTRIES = 1e3;
|
|
4
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 300 * 1e3;
|
|
5
|
+
var Bot = class {
|
|
6
|
+
seen = /* @__PURE__ */ new Map();
|
|
7
|
+
onMessage;
|
|
8
|
+
featuresGroups;
|
|
9
|
+
featuresImages;
|
|
10
|
+
dedupWindowMs;
|
|
11
|
+
cleanupTimer = null;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.onMessage = options.onMessage;
|
|
14
|
+
this.featuresGroups = options.featuresGroups ?? true;
|
|
15
|
+
this.featuresImages = options.featuresImages ?? true;
|
|
16
|
+
this.dedupWindowMs = options.dedupWindowMs ?? DEFAULT_DEDUP_WINDOW_MS;
|
|
17
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), DEDUP_CLEANUP_INTERVAL_MS);
|
|
18
|
+
}
|
|
19
|
+
handleIncoming(message) {
|
|
20
|
+
if (this.isDuplicate(message.id)) return;
|
|
21
|
+
if (message.group && !this.featuresGroups) return;
|
|
22
|
+
if (message.type === "image" && !this.featuresImages) return;
|
|
23
|
+
if (message.type === "unknown") return;
|
|
24
|
+
Promise.resolve(this.onMessage(message)).catch((error) => {
|
|
25
|
+
console.error("[wechat] Failed to process inbound message:", error);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
isDuplicate(messageId) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
if (this.seen.has(messageId)) return true;
|
|
31
|
+
if (this.seen.size >= DEDUP_MAX_ENTRIES) this.cleanup();
|
|
32
|
+
this.seen.set(messageId, now);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
cleanup() {
|
|
36
|
+
const cutoff = Date.now() - this.dedupWindowMs;
|
|
37
|
+
for (const [id, ts] of this.seen) if (ts < cutoff) this.seen.delete(id);
|
|
38
|
+
}
|
|
39
|
+
stop() {
|
|
40
|
+
if (this.cleanupTimer) {
|
|
41
|
+
clearInterval(this.cleanupTimer);
|
|
42
|
+
this.cleanupTimer = null;
|
|
43
|
+
}
|
|
44
|
+
this.seen.clear();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
export { Bot };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
//#region src/callback-server.ts
|
|
5
|
+
const WECHAT_TYPE_MAP = {
|
|
6
|
+
60001: {
|
|
7
|
+
type: "text",
|
|
8
|
+
scope: "private"
|
|
9
|
+
},
|
|
10
|
+
60002: {
|
|
11
|
+
type: "image",
|
|
12
|
+
scope: "private"
|
|
13
|
+
},
|
|
14
|
+
60003: {
|
|
15
|
+
type: "voice",
|
|
16
|
+
scope: "private"
|
|
17
|
+
},
|
|
18
|
+
60004: {
|
|
19
|
+
type: "video",
|
|
20
|
+
scope: "private"
|
|
21
|
+
},
|
|
22
|
+
60005: {
|
|
23
|
+
type: "file",
|
|
24
|
+
scope: "private"
|
|
25
|
+
},
|
|
26
|
+
80001: {
|
|
27
|
+
type: "text",
|
|
28
|
+
scope: "group"
|
|
29
|
+
},
|
|
30
|
+
80002: {
|
|
31
|
+
type: "image",
|
|
32
|
+
scope: "group"
|
|
33
|
+
},
|
|
34
|
+
80003: {
|
|
35
|
+
type: "voice",
|
|
36
|
+
scope: "group"
|
|
37
|
+
},
|
|
38
|
+
80004: {
|
|
39
|
+
type: "video",
|
|
40
|
+
scope: "group"
|
|
41
|
+
},
|
|
42
|
+
80005: {
|
|
43
|
+
type: "file",
|
|
44
|
+
scope: "group"
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
|
|
48
|
+
async function startCallbackServer(options) {
|
|
49
|
+
const { port, accounts, onMessage, signal, maxBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES } = options;
|
|
50
|
+
const server = createServer((req, res) => {
|
|
51
|
+
const account = resolveWebhookAccount(req.url, accounts);
|
|
52
|
+
if (req.method !== "POST" || !account) {
|
|
53
|
+
res.writeHead(404);
|
|
54
|
+
res.end("Not Found");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const incomingKey = readHeaderValue(req.headers["x-api-key"]);
|
|
58
|
+
if (!incomingKey || !safeCompare(incomingKey, account.apiKey)) {
|
|
59
|
+
res.writeHead(401);
|
|
60
|
+
res.end("Unauthorized");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let body = "";
|
|
64
|
+
let bodyBytes = 0;
|
|
65
|
+
req.on("data", (chunk) => {
|
|
66
|
+
bodyBytes += chunk.length;
|
|
67
|
+
if (bodyBytes > maxBodyBytes) {
|
|
68
|
+
res.writeHead(413);
|
|
69
|
+
res.end("Payload Too Large");
|
|
70
|
+
req.destroy();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
body += chunk.toString();
|
|
74
|
+
});
|
|
75
|
+
req.on("end", () => {
|
|
76
|
+
if (res.writableEnded) return;
|
|
77
|
+
try {
|
|
78
|
+
const message = normalizePayload(JSON.parse(body));
|
|
79
|
+
if (message) onMessage(account.accountId, message);
|
|
80
|
+
res.writeHead(200);
|
|
81
|
+
res.end("OK");
|
|
82
|
+
} catch {
|
|
83
|
+
res.writeHead(400);
|
|
84
|
+
res.end("Bad Request");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
req.on("error", () => {
|
|
88
|
+
if (res.writableEnded) return;
|
|
89
|
+
res.writeHead(400);
|
|
90
|
+
res.end("Bad Request");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
const handleListening = () => {
|
|
95
|
+
server.off("error", handleError);
|
|
96
|
+
resolve();
|
|
97
|
+
};
|
|
98
|
+
const handleError = (error) => {
|
|
99
|
+
server.off("listening", handleListening);
|
|
100
|
+
reject(error);
|
|
101
|
+
};
|
|
102
|
+
server.once("listening", handleListening);
|
|
103
|
+
server.once("error", handleError);
|
|
104
|
+
server.listen(port);
|
|
105
|
+
});
|
|
106
|
+
const listeningPort = server.address()?.port ?? port;
|
|
107
|
+
console.log(`[wechat] Webhook server listening on port ${listeningPort}`);
|
|
108
|
+
server.on("error", (err) => {
|
|
109
|
+
if (err.code === "EADDRINUSE") console.error(`[wechat] Port ${listeningPort} already in use — webhook server failed to start`);
|
|
110
|
+
else console.error(`[wechat] Webhook server error:`, err);
|
|
111
|
+
});
|
|
112
|
+
if (signal) signal.addEventListener("abort", () => {
|
|
113
|
+
closeServer(server);
|
|
114
|
+
}, { once: true });
|
|
115
|
+
return {
|
|
116
|
+
close: () => closeServer(server),
|
|
117
|
+
port: listeningPort
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function resolveWebhookAccount(rawUrl, accounts) {
|
|
121
|
+
if (!rawUrl) return null;
|
|
122
|
+
const pathname = new URL(rawUrl, "http://localhost").pathname;
|
|
123
|
+
if (pathname === "/webhook/wechat" && accounts.length === 1) return accounts[0];
|
|
124
|
+
const match = /^\/webhook\/wechat\/([^/]+)$/.exec(pathname);
|
|
125
|
+
if (!match) return null;
|
|
126
|
+
const accountId = decodeURIComponent(match[1]);
|
|
127
|
+
return accounts.find((account) => account.accountId === accountId) ?? null;
|
|
128
|
+
}
|
|
129
|
+
function readHeaderValue(value) {
|
|
130
|
+
if (Array.isArray(value)) return value[0];
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
function safeCompare(a, b) {
|
|
134
|
+
const bufA = Buffer.from(a);
|
|
135
|
+
const bufB = Buffer.from(b);
|
|
136
|
+
if (bufA.length !== bufB.length) {
|
|
137
|
+
timingSafeEqual(bufA, bufA);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return timingSafeEqual(bufA, bufB);
|
|
141
|
+
}
|
|
142
|
+
function closeServer(server) {
|
|
143
|
+
if (!server.listening) return Promise.resolve();
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
server.close((error) => {
|
|
146
|
+
if (error) {
|
|
147
|
+
reject(error);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function normalizePayload(payload) {
|
|
155
|
+
const data = payload.data ?? (payload.content ? payload : null);
|
|
156
|
+
if (!data) {
|
|
157
|
+
console.warn("[wechat] Unrecognized webhook payload format");
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const typeCode = Number(data.type ?? data.msgType ?? 0);
|
|
161
|
+
const mapping = WECHAT_TYPE_MAP[typeCode];
|
|
162
|
+
let msgType = "unknown";
|
|
163
|
+
let scope = "private";
|
|
164
|
+
if (mapping) {
|
|
165
|
+
msgType = mapping.type;
|
|
166
|
+
scope = mapping.scope;
|
|
167
|
+
} else if (typeCode >= 60006 && typeCode <= 60010) {
|
|
168
|
+
msgType = "file";
|
|
169
|
+
scope = "private";
|
|
170
|
+
} else if (typeCode >= 80006 && typeCode <= 80010) {
|
|
171
|
+
msgType = "file";
|
|
172
|
+
scope = "group";
|
|
173
|
+
}
|
|
174
|
+
if (msgType === "unknown") {
|
|
175
|
+
console.warn(`[wechat] Unknown message type code: ${typeCode}`);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const sender = String(data.sender ?? data.from ?? "");
|
|
179
|
+
const recipient = String(data.recipient ?? data.to ?? "");
|
|
180
|
+
const content = String(data.content ?? data.text ?? "");
|
|
181
|
+
const timestamp = Number(data.timestamp ?? Date.now());
|
|
182
|
+
const msgId = String(data.msgId ?? data.id ?? `${sender}-${timestamp}`);
|
|
183
|
+
const isGroup = scope === "group" || sender.includes("@chatroom");
|
|
184
|
+
const threadId = isGroup ? String(data.roomId ?? data.threadId ?? sender) : void 0;
|
|
185
|
+
const groupSubject = isGroup ? String(data.roomName ?? data.groupName ?? threadId ?? "") : void 0;
|
|
186
|
+
const imageUrl = new Set([
|
|
187
|
+
"image",
|
|
188
|
+
"voice",
|
|
189
|
+
"video",
|
|
190
|
+
"file"
|
|
191
|
+
]).has(msgType) ? String(data.imageUrl ?? data.mediaUrl ?? data.url ?? data.fileUrl ?? "") : void 0;
|
|
192
|
+
return {
|
|
193
|
+
id: msgId,
|
|
194
|
+
type: msgType,
|
|
195
|
+
sender,
|
|
196
|
+
recipient,
|
|
197
|
+
content,
|
|
198
|
+
timestamp,
|
|
199
|
+
threadId,
|
|
200
|
+
group: groupSubject ? { subject: groupSubject } : void 0,
|
|
201
|
+
imageUrl: imageUrl || void 0,
|
|
202
|
+
raw: payload
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//#endregion
|
|
207
|
+
export { startCallbackServer };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { WechatConfig, WechatMessageContext } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/channel.d.ts
|
|
4
|
+
interface ChannelOptions {
|
|
5
|
+
config: WechatConfig;
|
|
6
|
+
onMessage: (accountId: string, msg: WechatMessageContext) => void | Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
declare class WechatChannel {
|
|
9
|
+
private readonly config;
|
|
10
|
+
private readonly onMessage;
|
|
11
|
+
private readonly accounts;
|
|
12
|
+
private readonly callbackServers;
|
|
13
|
+
private readonly loginPromises;
|
|
14
|
+
private healthTimer;
|
|
15
|
+
private abortController;
|
|
16
|
+
constructor(options: ChannelOptions);
|
|
17
|
+
start(): Promise<void>;
|
|
18
|
+
stop(): Promise<void>;
|
|
19
|
+
sendText(accountId: string, to: string, text: string): Promise<void>;
|
|
20
|
+
sendImage(accountId: string, to: string, imagePath: string, caption?: string): Promise<void>;
|
|
21
|
+
private routeIncoming;
|
|
22
|
+
private ensureLoggedIn;
|
|
23
|
+
private doLogin;
|
|
24
|
+
private healthCheck;
|
|
25
|
+
private resolveAccounts;
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { WechatChannel };
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Bot } from "./bot.js";
|
|
2
|
+
import { startCallbackServer } from "./callback-server.js";
|
|
3
|
+
import { LoginExpiredError, ProxyClient } from "./proxy-client.js";
|
|
4
|
+
import { ReplyDispatcher } from "./reply-dispatcher.js";
|
|
5
|
+
import { displayQRUrl } from "./utils/qrcode.js";
|
|
6
|
+
|
|
7
|
+
//#region src/channel.ts
|
|
8
|
+
const HEALTH_CHECK_INTERVAL_MS = 6e4;
|
|
9
|
+
const LOGIN_POLL_INTERVAL_MS = 5e3;
|
|
10
|
+
const LOGIN_TIMEOUT_MS = 5 * 6e4;
|
|
11
|
+
var WechatChannel = class {
|
|
12
|
+
config;
|
|
13
|
+
onMessage;
|
|
14
|
+
accounts = /* @__PURE__ */ new Map();
|
|
15
|
+
callbackServers = [];
|
|
16
|
+
loginPromises = /* @__PURE__ */ new Map();
|
|
17
|
+
healthTimer = null;
|
|
18
|
+
abortController = null;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.config = options.config;
|
|
21
|
+
this.onMessage = options.onMessage;
|
|
22
|
+
}
|
|
23
|
+
async start() {
|
|
24
|
+
this.abortController = new AbortController();
|
|
25
|
+
const resolved = this.resolveAccounts();
|
|
26
|
+
if (resolved.length === 0) {
|
|
27
|
+
console.warn("[wechat] No configured accounts found");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const webhookAccountsByPort = /* @__PURE__ */ new Map();
|
|
31
|
+
for (const account of resolved) {
|
|
32
|
+
const existing = webhookAccountsByPort.get(account.webhookPort) ?? [];
|
|
33
|
+
existing.push({
|
|
34
|
+
accountId: account.id,
|
|
35
|
+
apiKey: account.apiKey
|
|
36
|
+
});
|
|
37
|
+
webhookAccountsByPort.set(account.webhookPort, existing);
|
|
38
|
+
}
|
|
39
|
+
for (const [webhookPort, accounts] of webhookAccountsByPort) try {
|
|
40
|
+
this.callbackServers.push(await startCallbackServer({
|
|
41
|
+
port: webhookPort,
|
|
42
|
+
accounts,
|
|
43
|
+
onMessage: (accountId, msg) => this.routeIncoming(accountId, msg),
|
|
44
|
+
signal: this.abortController.signal
|
|
45
|
+
}));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const accountIds = accounts.map((a) => a.accountId).join(", ");
|
|
48
|
+
console.error(`[wechat] Failed to bind webhook server on port ${webhookPort} for accounts [${accountIds}]:`, err);
|
|
49
|
+
}
|
|
50
|
+
for (const account of resolved) {
|
|
51
|
+
const client = new ProxyClient(account);
|
|
52
|
+
const dispatcher = new ReplyDispatcher({ client });
|
|
53
|
+
const bot = new Bot({
|
|
54
|
+
onMessage: (msg) => this.onMessage(account.id, msg),
|
|
55
|
+
featuresGroups: this.config.features?.groups,
|
|
56
|
+
featuresImages: this.config.features?.images
|
|
57
|
+
});
|
|
58
|
+
this.accounts.set(account.id, {
|
|
59
|
+
client,
|
|
60
|
+
dispatcher,
|
|
61
|
+
bot
|
|
62
|
+
});
|
|
63
|
+
await this.ensureLoggedIn(account.id, client);
|
|
64
|
+
const webhookUrl = `http://localhost:${account.webhookPort}/webhook/wechat/${account.id}`;
|
|
65
|
+
try {
|
|
66
|
+
await client.registerWebhook(webhookUrl);
|
|
67
|
+
console.log(`[wechat] Account "${account.id}" registered webhook at ${webhookUrl}`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`[wechat] Failed to register webhook for "${account.id}":`, err);
|
|
70
|
+
throw new Error(`Webhook registration failed for account "${account.id}": ${err instanceof Error ? err.message : String(err)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
this.healthTimer = setInterval(() => this.healthCheck(), HEALTH_CHECK_INTERVAL_MS);
|
|
74
|
+
}
|
|
75
|
+
async stop() {
|
|
76
|
+
if (this.healthTimer) {
|
|
77
|
+
clearInterval(this.healthTimer);
|
|
78
|
+
this.healthTimer = null;
|
|
79
|
+
}
|
|
80
|
+
for (const [, { bot }] of this.accounts) bot.stop();
|
|
81
|
+
this.accounts.clear();
|
|
82
|
+
if (this.abortController) {
|
|
83
|
+
this.abortController.abort();
|
|
84
|
+
this.abortController = null;
|
|
85
|
+
}
|
|
86
|
+
const servers = this.callbackServers.splice(0);
|
|
87
|
+
await Promise.all(servers.map((server) => server.close().catch(() => void 0)));
|
|
88
|
+
}
|
|
89
|
+
async sendText(accountId, to, text) {
|
|
90
|
+
const entry = this.accounts.get(accountId);
|
|
91
|
+
if (!entry) throw new Error(`Unknown account: ${accountId}`);
|
|
92
|
+
try {
|
|
93
|
+
await entry.dispatcher.sendText(to, text);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err instanceof LoginExpiredError) {
|
|
96
|
+
await this.ensureLoggedIn(accountId, entry.client);
|
|
97
|
+
await entry.dispatcher.sendText(to, text);
|
|
98
|
+
} else throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async sendImage(accountId, to, imagePath, caption) {
|
|
102
|
+
const entry = this.accounts.get(accountId);
|
|
103
|
+
if (!entry) throw new Error(`Unknown account: ${accountId}`);
|
|
104
|
+
try {
|
|
105
|
+
await entry.dispatcher.sendImage(to, imagePath, caption);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof LoginExpiredError) {
|
|
108
|
+
await this.ensureLoggedIn(accountId, entry.client);
|
|
109
|
+
await entry.dispatcher.sendImage(to, imagePath, caption);
|
|
110
|
+
} else throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
routeIncoming(accountId, msg) {
|
|
114
|
+
const entry = this.accounts.get(accountId);
|
|
115
|
+
if (!entry) {
|
|
116
|
+
console.warn(`[wechat] Received webhook for unknown account "${accountId}"`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
entry.bot.handleIncoming(msg);
|
|
120
|
+
}
|
|
121
|
+
async ensureLoggedIn(accountId, client) {
|
|
122
|
+
const existing = this.loginPromises.get(accountId);
|
|
123
|
+
if (existing) return existing;
|
|
124
|
+
const promise = this.doLogin(accountId, client).finally(() => {
|
|
125
|
+
this.loginPromises.delete(accountId);
|
|
126
|
+
});
|
|
127
|
+
this.loginPromises.set(accountId, promise);
|
|
128
|
+
return promise;
|
|
129
|
+
}
|
|
130
|
+
async doLogin(accountId, client) {
|
|
131
|
+
const status = await client.getStatus();
|
|
132
|
+
if (status.loginState === "logged_in") {
|
|
133
|
+
console.log(`[wechat] Account "${accountId}" logged in as ${status.nickName ?? status.wcId}`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
console.log(`[wechat] Account "${accountId}" needs login — generating QR code...`);
|
|
137
|
+
displayQRUrl(await client.getQRCode());
|
|
138
|
+
const timeoutMs = this.config.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
|
|
139
|
+
const deadline = Date.now() + timeoutMs;
|
|
140
|
+
while (Date.now() < deadline) {
|
|
141
|
+
await sleep(LOGIN_POLL_INTERVAL_MS);
|
|
142
|
+
if (this.abortController?.signal.aborted) throw new Error("Login aborted");
|
|
143
|
+
const result = await client.checkLogin();
|
|
144
|
+
if (result.status === "logged_in") {
|
|
145
|
+
console.log(`[wechat] Account "${accountId}" logged in as ${result.nickName ?? result.wcId}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (result.status === "need_verify") console.log(`[wechat] Verification needed: ${result.verifyUrl ?? "check your phone"}`);
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`[wechat] Login timed out for account "${accountId}" after ${Math.round(timeoutMs / 1e3)} seconds`);
|
|
151
|
+
}
|
|
152
|
+
async healthCheck() {
|
|
153
|
+
for (const [accountId, { client }] of this.accounts) try {
|
|
154
|
+
if ((await client.getStatus()).loginState !== "logged_in") {
|
|
155
|
+
console.warn(`[wechat] Account "${accountId}" login expired — attempting re-login`);
|
|
156
|
+
await this.ensureLoggedIn(accountId, client);
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(`[wechat] Health check failed for "${accountId}":`, err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
resolveAccounts() {
|
|
163
|
+
const accounts = [];
|
|
164
|
+
const rawPort = Number(process.env.ELIZA_WECHAT_WEBHOOK_PORT);
|
|
165
|
+
const defaultPort = (Number.isFinite(rawPort) && rawPort > 0 ? rawPort : void 0) ?? this.config.webhookPort ?? 18790;
|
|
166
|
+
const defaultDevice = this.config.deviceType ?? "ipad";
|
|
167
|
+
if (this.config.accounts) for (const [id, acc] of Object.entries(this.config.accounts)) {
|
|
168
|
+
if (acc.enabled === false) continue;
|
|
169
|
+
accounts.push({
|
|
170
|
+
id,
|
|
171
|
+
apiKey: acc.apiKey,
|
|
172
|
+
proxyUrl: acc.proxyUrl,
|
|
173
|
+
deviceType: acc.deviceType ?? defaultDevice,
|
|
174
|
+
webhookPort: acc.webhookPort ?? defaultPort,
|
|
175
|
+
wcId: acc.wcId,
|
|
176
|
+
nickName: acc.nickName
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else if (this.config.apiKey && this.config.proxyUrl) accounts.push({
|
|
180
|
+
id: "default",
|
|
181
|
+
apiKey: this.config.apiKey,
|
|
182
|
+
proxyUrl: this.config.proxyUrl,
|
|
183
|
+
deviceType: defaultDevice,
|
|
184
|
+
webhookPort: defaultPort
|
|
185
|
+
});
|
|
186
|
+
return accounts;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
function sleep(ms) {
|
|
190
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
//#endregion
|
|
194
|
+
export { WechatChannel };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
type DeviceType = "ipad" | "mac";
|
|
3
|
+
type LoginStatus = "waiting" | "need_verify" | "logged_in";
|
|
4
|
+
interface WechatAccountConfig {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
name?: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
proxyUrl: string;
|
|
9
|
+
deviceType?: DeviceType;
|
|
10
|
+
webhookPort?: number;
|
|
11
|
+
webhookUrl?: string;
|
|
12
|
+
wcId?: string;
|
|
13
|
+
nickName?: string;
|
|
14
|
+
}
|
|
15
|
+
interface WechatConfig {
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
proxyUrl?: string;
|
|
19
|
+
webhookPort?: number;
|
|
20
|
+
deviceType?: DeviceType;
|
|
21
|
+
loginTimeoutMs?: number;
|
|
22
|
+
accounts?: Record<string, WechatAccountConfig>;
|
|
23
|
+
features?: {
|
|
24
|
+
images?: boolean;
|
|
25
|
+
groups?: boolean;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
interface ResolvedWechatAccount {
|
|
29
|
+
id: string;
|
|
30
|
+
apiKey: string;
|
|
31
|
+
proxyUrl: string;
|
|
32
|
+
deviceType: DeviceType;
|
|
33
|
+
webhookPort: number;
|
|
34
|
+
wcId?: string;
|
|
35
|
+
nickName?: string;
|
|
36
|
+
}
|
|
37
|
+
type WechatMessageType = "text" | "image" | "video" | "file" | "voice" | "unknown";
|
|
38
|
+
interface WechatMessageContext {
|
|
39
|
+
id: string;
|
|
40
|
+
type: WechatMessageType;
|
|
41
|
+
sender: string;
|
|
42
|
+
recipient: string;
|
|
43
|
+
content: string;
|
|
44
|
+
timestamp: number;
|
|
45
|
+
threadId?: string;
|
|
46
|
+
group?: {
|
|
47
|
+
subject: string;
|
|
48
|
+
};
|
|
49
|
+
imageUrl?: string;
|
|
50
|
+
raw: unknown;
|
|
51
|
+
}
|
|
52
|
+
interface AccountStatus {
|
|
53
|
+
valid: boolean;
|
|
54
|
+
wcId?: string;
|
|
55
|
+
loginState: LoginStatus;
|
|
56
|
+
nickName?: string;
|
|
57
|
+
tier?: string;
|
|
58
|
+
quota?: number;
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/bot.d.ts
|
|
62
|
+
interface BotOptions {
|
|
63
|
+
onMessage: (msg: WechatMessageContext) => void | Promise<void>;
|
|
64
|
+
featuresGroups?: boolean;
|
|
65
|
+
featuresImages?: boolean;
|
|
66
|
+
/** Deduplication window in milliseconds. Defaults to 30 minutes. */
|
|
67
|
+
dedupWindowMs?: number;
|
|
68
|
+
}
|
|
69
|
+
declare class Bot {
|
|
70
|
+
private readonly seen;
|
|
71
|
+
private readonly onMessage;
|
|
72
|
+
private readonly featuresGroups;
|
|
73
|
+
private readonly featuresImages;
|
|
74
|
+
private readonly dedupWindowMs;
|
|
75
|
+
private cleanupTimer;
|
|
76
|
+
constructor(options: BotOptions);
|
|
77
|
+
handleIncoming(message: WechatMessageContext): void;
|
|
78
|
+
private isDuplicate;
|
|
79
|
+
private cleanup;
|
|
80
|
+
stop(): void;
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/channel.d.ts
|
|
84
|
+
interface ChannelOptions {
|
|
85
|
+
config: WechatConfig;
|
|
86
|
+
onMessage: (accountId: string, msg: WechatMessageContext) => void | Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
declare class WechatChannel {
|
|
89
|
+
private readonly config;
|
|
90
|
+
private readonly onMessage;
|
|
91
|
+
private readonly accounts;
|
|
92
|
+
private readonly callbackServers;
|
|
93
|
+
private readonly loginPromises;
|
|
94
|
+
private healthTimer;
|
|
95
|
+
private abortController;
|
|
96
|
+
constructor(options: ChannelOptions);
|
|
97
|
+
start(): Promise<void>;
|
|
98
|
+
stop(): Promise<void>;
|
|
99
|
+
sendText(accountId: string, to: string, text: string): Promise<void>;
|
|
100
|
+
sendImage(accountId: string, to: string, imagePath: string, caption?: string): Promise<void>;
|
|
101
|
+
private routeIncoming;
|
|
102
|
+
private ensureLoggedIn;
|
|
103
|
+
private doLogin;
|
|
104
|
+
private healthCheck;
|
|
105
|
+
private resolveAccounts;
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/proxy-client.d.ts
|
|
109
|
+
declare class ProxyClient {
|
|
110
|
+
private readonly apiKey;
|
|
111
|
+
private readonly baseUrl;
|
|
112
|
+
private readonly accountId;
|
|
113
|
+
private readonly deviceType;
|
|
114
|
+
constructor(account: ResolvedWechatAccount);
|
|
115
|
+
private request;
|
|
116
|
+
getStatus(): Promise<AccountStatus>;
|
|
117
|
+
getQRCode(): Promise<string>;
|
|
118
|
+
checkLogin(): Promise<{
|
|
119
|
+
status: "waiting" | "need_verify" | "logged_in";
|
|
120
|
+
verifyUrl?: string;
|
|
121
|
+
wcId?: string;
|
|
122
|
+
nickName?: string;
|
|
123
|
+
}>;
|
|
124
|
+
sendText(to: string, text: string): Promise<void>;
|
|
125
|
+
sendImage(to: string, imagePath: string, text?: string): Promise<void>;
|
|
126
|
+
getContacts(): Promise<{
|
|
127
|
+
friends: Array<{
|
|
128
|
+
wxid: string;
|
|
129
|
+
name: string;
|
|
130
|
+
}>;
|
|
131
|
+
chatrooms: Array<{
|
|
132
|
+
wxid: string;
|
|
133
|
+
name: string;
|
|
134
|
+
}>;
|
|
135
|
+
}>;
|
|
136
|
+
registerWebhook(url: string): Promise<void>;
|
|
137
|
+
get needsLogin(): boolean;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/reply-dispatcher.d.ts
|
|
141
|
+
interface ReplyDispatcherOptions {
|
|
142
|
+
client: ProxyClient;
|
|
143
|
+
chunkSize?: number;
|
|
144
|
+
}
|
|
145
|
+
declare class ReplyDispatcher {
|
|
146
|
+
private readonly client;
|
|
147
|
+
private readonly chunkSize;
|
|
148
|
+
constructor(options: ReplyDispatcherOptions);
|
|
149
|
+
sendText(to: string, text: string): Promise<void>;
|
|
150
|
+
sendImage(to: string, imagePath: string, caption?: string): Promise<void>;
|
|
151
|
+
private chunk;
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/runtime-bridge.d.ts
|
|
155
|
+
interface IncomingWechatDeliveryOptions {
|
|
156
|
+
runtime: unknown;
|
|
157
|
+
accountId: string;
|
|
158
|
+
message: WechatMessageContext;
|
|
159
|
+
sendText: (accountId: string, to: string, text: string) => Promise<void>;
|
|
160
|
+
}
|
|
161
|
+
declare function deliverIncomingWechatMessage(options: IncomingWechatDeliveryOptions): Promise<void>;
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/index.d.ts
|
|
164
|
+
declare const WECHAT_PLUGIN_PACKAGE: "@elizaos/plugin-wechat";
|
|
165
|
+
declare function isWechatConnectorConfigured(config: WechatConfig | Record<string, unknown> | null | undefined): boolean;
|
|
166
|
+
interface Plugin {
|
|
167
|
+
name: string;
|
|
168
|
+
description: string;
|
|
169
|
+
init?: (config: Record<string, unknown>, runtime: unknown) => Promise<void | (() => Promise<void>)>;
|
|
170
|
+
}
|
|
171
|
+
declare const wechatPlugin: Plugin;
|
|
172
|
+
//#endregion
|
|
173
|
+
export { Bot, Plugin, ProxyClient, ReplyDispatcher, WECHAT_PLUGIN_PACKAGE, WechatChannel, type WechatConfig, type WechatMessageContext, wechatPlugin as default, wechatPlugin, deliverIncomingWechatMessage, isWechatConnectorConfigured };
|