@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,35 @@
|
|
|
1
|
+
import { AccountStatus, ResolvedWechatAccount } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/proxy-client.d.ts
|
|
4
|
+
declare class ProxyClient {
|
|
5
|
+
private readonly apiKey;
|
|
6
|
+
private readonly baseUrl;
|
|
7
|
+
private readonly accountId;
|
|
8
|
+
private readonly deviceType;
|
|
9
|
+
constructor(account: ResolvedWechatAccount);
|
|
10
|
+
private request;
|
|
11
|
+
getStatus(): Promise<AccountStatus>;
|
|
12
|
+
getQRCode(): Promise<string>;
|
|
13
|
+
checkLogin(): Promise<{
|
|
14
|
+
status: "waiting" | "need_verify" | "logged_in";
|
|
15
|
+
verifyUrl?: string;
|
|
16
|
+
wcId?: string;
|
|
17
|
+
nickName?: string;
|
|
18
|
+
}>;
|
|
19
|
+
sendText(to: string, text: string): Promise<void>;
|
|
20
|
+
sendImage(to: string, imagePath: string, text?: string): Promise<void>;
|
|
21
|
+
getContacts(): Promise<{
|
|
22
|
+
friends: Array<{
|
|
23
|
+
wxid: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}>;
|
|
26
|
+
chatrooms: Array<{
|
|
27
|
+
wxid: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
registerWebhook(url: string): Promise<void>;
|
|
32
|
+
get needsLogin(): boolean;
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
export { ProxyClient };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//#region src/proxy-client.ts
|
|
2
|
+
const SUCCESS = 1e3;
|
|
3
|
+
const LOGIN_NEEDED = 1001;
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
5
|
+
var ProxyClient = class {
|
|
6
|
+
apiKey;
|
|
7
|
+
baseUrl;
|
|
8
|
+
accountId;
|
|
9
|
+
deviceType;
|
|
10
|
+
constructor(account) {
|
|
11
|
+
this.apiKey = account.apiKey;
|
|
12
|
+
this.baseUrl = normalizeProxyUrl(account.proxyUrl);
|
|
13
|
+
this.accountId = account.id;
|
|
14
|
+
this.deviceType = account.deviceType ?? "ipad";
|
|
15
|
+
}
|
|
16
|
+
async request(path, body) {
|
|
17
|
+
const url = `${this.baseUrl}${path}`;
|
|
18
|
+
const headers = {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"X-API-Key": this.apiKey,
|
|
21
|
+
"X-Account-ID": this.accountId,
|
|
22
|
+
"X-Device-Type": this.deviceType
|
|
23
|
+
};
|
|
24
|
+
let lastError;
|
|
25
|
+
for (let attempt = 0; attempt < 3; attempt++) try {
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers,
|
|
29
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
30
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
31
|
+
});
|
|
32
|
+
if (res.status === 429) {
|
|
33
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
34
|
+
const delay = retryAfter ? Number.parseInt(retryAfter, 10) * 1e3 : Math.min(1e3 * 2 ** attempt, 8e3);
|
|
35
|
+
await res.text().catch(() => {});
|
|
36
|
+
await sleep(delay);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return await res.json();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
42
|
+
await sleep(Math.min(1e3 * 2 ** attempt, 8e3));
|
|
43
|
+
}
|
|
44
|
+
throw lastError ?? /* @__PURE__ */ new Error(`Request failed after 3 attempts: ${path}`);
|
|
45
|
+
}
|
|
46
|
+
async getStatus() {
|
|
47
|
+
const res = await this.request("/api/status");
|
|
48
|
+
if (res.code === LOGIN_NEEDED) return {
|
|
49
|
+
valid: true,
|
|
50
|
+
loginState: "waiting"
|
|
51
|
+
};
|
|
52
|
+
if (res.code !== SUCCESS && res.code !== 1002) throw new Error(`getStatus failed: ${res.message ?? res.code}`);
|
|
53
|
+
return requireData(res, "getStatus");
|
|
54
|
+
}
|
|
55
|
+
async getQRCode() {
|
|
56
|
+
const res = await this.request("/api/qrcode");
|
|
57
|
+
if (res.code !== SUCCESS) throw new Error(`getQRCode failed: ${res.message ?? res.code}`);
|
|
58
|
+
return requireData(res, "getQRCode").qrCodeUrl;
|
|
59
|
+
}
|
|
60
|
+
async checkLogin() {
|
|
61
|
+
const res = await this.request("/api/check-login");
|
|
62
|
+
if (res.code !== SUCCESS && res.code !== 1002) throw new Error(`checkLogin failed: ${res.message ?? res.code}`);
|
|
63
|
+
return requireData(res, "checkLogin");
|
|
64
|
+
}
|
|
65
|
+
async sendText(to, text) {
|
|
66
|
+
const res = await this.request("/api/send-text", {
|
|
67
|
+
to,
|
|
68
|
+
text
|
|
69
|
+
});
|
|
70
|
+
if (res.code === LOGIN_NEEDED) throw new LoginExpiredError();
|
|
71
|
+
if (res.code !== SUCCESS && res.code !== 1002) throw new Error(`sendText failed: ${res.message ?? res.code}`);
|
|
72
|
+
}
|
|
73
|
+
async sendImage(to, imagePath, text) {
|
|
74
|
+
const res = await this.request("/api/send-image", {
|
|
75
|
+
to,
|
|
76
|
+
imagePath,
|
|
77
|
+
text
|
|
78
|
+
});
|
|
79
|
+
if (res.code === LOGIN_NEEDED) throw new LoginExpiredError();
|
|
80
|
+
if (res.code !== SUCCESS && res.code !== 1002) throw new Error(`sendImage failed: ${res.message ?? res.code}`);
|
|
81
|
+
}
|
|
82
|
+
async getContacts() {
|
|
83
|
+
const res = await this.request("/api/contacts");
|
|
84
|
+
if (res.code !== SUCCESS) throw new Error(`getContacts failed: ${res.message ?? res.code}`);
|
|
85
|
+
return requireData(res, "getContacts");
|
|
86
|
+
}
|
|
87
|
+
async registerWebhook(url) {
|
|
88
|
+
const res = await this.request("/api/webhook/register", { webhookUrl: url });
|
|
89
|
+
if (res.code !== SUCCESS && res.code !== 1002) throw new Error(`registerWebhook failed: ${res.message ?? res.code}`);
|
|
90
|
+
}
|
|
91
|
+
get needsLogin() {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
var LoginExpiredError = class extends Error {
|
|
96
|
+
constructor() {
|
|
97
|
+
super("WeChat login expired — re-login required");
|
|
98
|
+
this.name = "LoginExpiredError";
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
function sleep(ms) {
|
|
102
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
103
|
+
}
|
|
104
|
+
function normalizeProxyUrl(proxyUrl) {
|
|
105
|
+
const parsed = new URL(proxyUrl);
|
|
106
|
+
if (parsed.protocol !== "https:") throw new Error("[wechat] proxyUrl must use https://");
|
|
107
|
+
if (parsed.username || parsed.password) throw new Error("[wechat] proxyUrl must not include credentials");
|
|
108
|
+
parsed.hash = "";
|
|
109
|
+
return parsed.toString().replace(/\/$/, "");
|
|
110
|
+
}
|
|
111
|
+
function requireData(response, action) {
|
|
112
|
+
if (response.data === void 0) throw new Error(`${action} failed: missing response data`);
|
|
113
|
+
return response.data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
export { LoginExpiredError, ProxyClient };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ProxyClient } from "./proxy-client.js";
|
|
2
|
+
|
|
3
|
+
//#region src/reply-dispatcher.d.ts
|
|
4
|
+
interface ReplyDispatcherOptions {
|
|
5
|
+
client: ProxyClient;
|
|
6
|
+
chunkSize?: number;
|
|
7
|
+
}
|
|
8
|
+
declare class ReplyDispatcher {
|
|
9
|
+
private readonly client;
|
|
10
|
+
private readonly chunkSize;
|
|
11
|
+
constructor(options: ReplyDispatcherOptions);
|
|
12
|
+
sendText(to: string, text: string): Promise<void>;
|
|
13
|
+
sendImage(to: string, imagePath: string, caption?: string): Promise<void>;
|
|
14
|
+
private chunk;
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
export { ReplyDispatcher };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/reply-dispatcher.ts
|
|
2
|
+
const DEFAULT_CHUNK_SIZE = 2e3;
|
|
3
|
+
var ReplyDispatcher = class {
|
|
4
|
+
client;
|
|
5
|
+
chunkSize;
|
|
6
|
+
constructor(options) {
|
|
7
|
+
this.client = options.client;
|
|
8
|
+
this.chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
9
|
+
}
|
|
10
|
+
async sendText(to, text) {
|
|
11
|
+
const chunks = this.chunk(text);
|
|
12
|
+
for (const chunk of chunks) try {
|
|
13
|
+
await this.client.sendText(to, chunk);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.error(`[wechat] Failed to send text to ${to}:`, err);
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async sendImage(to, imagePath, caption) {
|
|
20
|
+
try {
|
|
21
|
+
await this.client.sendImage(to, imagePath, caption);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(`[wechat] Failed to send image to ${to}:`, err);
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
chunk(text) {
|
|
28
|
+
if (text.length <= this.chunkSize) return [text];
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let remaining = text;
|
|
31
|
+
while (remaining.length > 0) {
|
|
32
|
+
if (remaining.length <= this.chunkSize) {
|
|
33
|
+
chunks.push(remaining);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
let breakAt = remaining.lastIndexOf("\n", this.chunkSize);
|
|
37
|
+
if (breakAt <= 0) breakAt = remaining.lastIndexOf(" ", this.chunkSize);
|
|
38
|
+
if (breakAt <= 0) breakAt = this.chunkSize;
|
|
39
|
+
chunks.push(remaining.slice(0, breakAt));
|
|
40
|
+
remaining = remaining.slice(breakAt).trimStart();
|
|
41
|
+
}
|
|
42
|
+
return chunks;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
export { ReplyDispatcher };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { WechatMessageContext } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/runtime-bridge.d.ts
|
|
4
|
+
interface IncomingWechatDeliveryOptions {
|
|
5
|
+
runtime: unknown;
|
|
6
|
+
accountId: string;
|
|
7
|
+
message: WechatMessageContext;
|
|
8
|
+
sendText: (accountId: string, to: string, text: string) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
declare function deliverIncomingWechatMessage(options: IncomingWechatDeliveryOptions): Promise<void>;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { deliverIncomingWechatMessage };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { stringToUuid } from "@elizaos/core";
|
|
2
|
+
|
|
3
|
+
//#region src/runtime-bridge.ts
|
|
4
|
+
async function deliverIncomingWechatMessage(options) {
|
|
5
|
+
const runtime = options.runtime;
|
|
6
|
+
const agentId = typeof runtime.agentId === "string" && runtime.agentId.length > 0 ? runtime.agentId : stringToUuid("wechat-agent");
|
|
7
|
+
const incomingMemory = buildIncomingMemory(agentId, options.accountId, options.message);
|
|
8
|
+
const replyTarget = resolveReplyTarget(options.message);
|
|
9
|
+
let replyIndex = 0;
|
|
10
|
+
let replyDelivered = false;
|
|
11
|
+
const onResponse = async (content) => {
|
|
12
|
+
const replyText = extractReplyText(content);
|
|
13
|
+
if (!replyText) return [];
|
|
14
|
+
replyDelivered = true;
|
|
15
|
+
await options.sendText(options.accountId, replyTarget, replyText);
|
|
16
|
+
const replyMemory = buildReplyMemory(agentId, options.accountId, options.message, replyText, replyIndex);
|
|
17
|
+
replyIndex += 1;
|
|
18
|
+
await runtime.createMemory?.(replyMemory, "messages");
|
|
19
|
+
return [replyMemory];
|
|
20
|
+
};
|
|
21
|
+
await runtime.ensureConnection?.({
|
|
22
|
+
entityId: incomingMemory.entityId,
|
|
23
|
+
roomId: incomingMemory.roomId,
|
|
24
|
+
worldId: stringToUuid(`wechat:world:${options.accountId}`),
|
|
25
|
+
userName: options.message.sender,
|
|
26
|
+
userId: options.message.sender,
|
|
27
|
+
name: options.message.sender,
|
|
28
|
+
source: "wechat",
|
|
29
|
+
type: getChannelType(options.message),
|
|
30
|
+
channelId: resolveChannelId(options.message),
|
|
31
|
+
worldName: "WeChat"
|
|
32
|
+
});
|
|
33
|
+
if (typeof runtime.elizaOS?.sendMessage === "function") {
|
|
34
|
+
await maybeHandleResponseContent(await runtime.elizaOS.sendMessage(options.runtime, incomingMemory, { onResponse }), replyDelivered, onResponse);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (typeof runtime.messageService?.handleMessage === "function") {
|
|
38
|
+
await maybeHandleResponseContent(await runtime.messageService.handleMessage(options.runtime, incomingMemory, onResponse), replyDelivered, onResponse);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (typeof runtime.emitEvent === "function") {
|
|
42
|
+
await runtime.emitEvent(["MESSAGE_RECEIVED"], {
|
|
43
|
+
runtime: options.runtime,
|
|
44
|
+
message: incomingMemory,
|
|
45
|
+
callback: onResponse,
|
|
46
|
+
source: "wechat"
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
runtime.logger?.warn?.("[wechat] No inbound runtime message pipeline is available");
|
|
51
|
+
}
|
|
52
|
+
function buildIncomingMemory(agentId, accountId, message) {
|
|
53
|
+
return {
|
|
54
|
+
id: stringToUuid(`wechat:incoming:${accountId}:${message.id}`),
|
|
55
|
+
agentId,
|
|
56
|
+
entityId: stringToUuid(`wechat:entity:${accountId}:${message.sender}`),
|
|
57
|
+
roomId: stringToUuid(`wechat:room:${accountId}:${resolveChannelId(message)}`),
|
|
58
|
+
createdAt: message.timestamp,
|
|
59
|
+
content: {
|
|
60
|
+
text: message.content,
|
|
61
|
+
source: "wechat",
|
|
62
|
+
channelType: getChannelType(message),
|
|
63
|
+
metadata: {
|
|
64
|
+
accountId,
|
|
65
|
+
sender: message.sender,
|
|
66
|
+
recipient: message.recipient,
|
|
67
|
+
messageType: message.type,
|
|
68
|
+
threadId: message.threadId,
|
|
69
|
+
groupSubject: message.group?.subject,
|
|
70
|
+
imageUrl: message.imageUrl
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
metadata: {
|
|
74
|
+
type: "message",
|
|
75
|
+
source: "wechat",
|
|
76
|
+
provider: "wechat",
|
|
77
|
+
timestamp: message.timestamp,
|
|
78
|
+
entityName: message.sender,
|
|
79
|
+
entityUserName: message.sender,
|
|
80
|
+
fromId: message.sender,
|
|
81
|
+
sourceId: stringToUuid(`wechat:entity:${accountId}:${message.sender}`),
|
|
82
|
+
chatType: getChannelType(message),
|
|
83
|
+
messageIdFull: message.id,
|
|
84
|
+
sender: {
|
|
85
|
+
id: message.sender,
|
|
86
|
+
name: message.sender,
|
|
87
|
+
username: message.sender
|
|
88
|
+
},
|
|
89
|
+
wechat: {
|
|
90
|
+
id: message.sender,
|
|
91
|
+
userId: message.sender,
|
|
92
|
+
username: message.sender,
|
|
93
|
+
userName: message.sender,
|
|
94
|
+
name: message.sender,
|
|
95
|
+
messageId: message.id,
|
|
96
|
+
accountId,
|
|
97
|
+
recipient: message.recipient,
|
|
98
|
+
threadId: message.threadId,
|
|
99
|
+
groupSubject: message.group?.subject
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function buildReplyMemory(agentId, accountId, message, text, replyIndex) {
|
|
105
|
+
return {
|
|
106
|
+
id: stringToUuid(`wechat:reply:${accountId}:${message.id}:${replyIndex}`),
|
|
107
|
+
agentId,
|
|
108
|
+
entityId: agentId,
|
|
109
|
+
roomId: stringToUuid(`wechat:room:${accountId}:${resolveChannelId(message)}`),
|
|
110
|
+
createdAt: Date.now(),
|
|
111
|
+
content: {
|
|
112
|
+
text,
|
|
113
|
+
source: "wechat",
|
|
114
|
+
channelType: getChannelType(message),
|
|
115
|
+
inReplyTo: message.id,
|
|
116
|
+
metadata: {
|
|
117
|
+
accountId,
|
|
118
|
+
recipient: resolveReplyTarget(message)
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
metadata: {
|
|
122
|
+
type: "message",
|
|
123
|
+
source: "wechat",
|
|
124
|
+
provider: "wechat",
|
|
125
|
+
timestamp: Date.now(),
|
|
126
|
+
fromBot: true,
|
|
127
|
+
fromId: agentId,
|
|
128
|
+
sourceId: agentId,
|
|
129
|
+
chatType: getChannelType(message),
|
|
130
|
+
messageIdFull: `wechat:reply:${message.id}:${replyIndex}`,
|
|
131
|
+
wechat: {
|
|
132
|
+
accountId,
|
|
133
|
+
recipient: resolveReplyTarget(message),
|
|
134
|
+
threadId: message.threadId
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function getChannelType(message) {
|
|
140
|
+
return message.group ? "GROUP" : "DM";
|
|
141
|
+
}
|
|
142
|
+
function resolveChannelId(message) {
|
|
143
|
+
return message.threadId ?? message.sender;
|
|
144
|
+
}
|
|
145
|
+
function resolveReplyTarget(message) {
|
|
146
|
+
return message.threadId ?? message.sender;
|
|
147
|
+
}
|
|
148
|
+
function extractReplyText(content) {
|
|
149
|
+
if (typeof content.text !== "string") return null;
|
|
150
|
+
const trimmed = content.text.trim();
|
|
151
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
152
|
+
}
|
|
153
|
+
async function maybeHandleResponseContent(result, replyDelivered, onResponse) {
|
|
154
|
+
if (replyDelivered || !result?.responseContent) return;
|
|
155
|
+
await onResponse(result.responseContent);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
//#endregion
|
|
159
|
+
export { deliverIncomingWechatMessage };
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
export { AccountStatus, ResolvedWechatAccount, WechatConfig, WechatMessageContext };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/utils/qrcode.ts
|
|
2
|
+
/**
|
|
3
|
+
* Display a QR code URL to the terminal.
|
|
4
|
+
* Prints the URL for the user to open in a browser.
|
|
5
|
+
* A vendored text-based QR renderer could be added here later.
|
|
6
|
+
*/
|
|
7
|
+
function displayQRUrl(url) {
|
|
8
|
+
console.log("");
|
|
9
|
+
console.log("╔══════════════════════════════════════════╗");
|
|
10
|
+
console.log("║ Scan this QR code with WeChat to login ║");
|
|
11
|
+
console.log("╠══════════════════════════════════════════╣");
|
|
12
|
+
console.log(`║ ${url}`);
|
|
13
|
+
console.log("╚══════════════════════════════════════════╝");
|
|
14
|
+
console.log("");
|
|
15
|
+
console.log("Open the URL above in your browser to see the QR code.");
|
|
16
|
+
console.log("");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
export { displayQRUrl };
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elizaos/plugin-wechat",
|
|
3
|
+
"version": "2.0.0-alpha.537",
|
|
4
|
+
"description": "WeChat connector plugin for elizaOS via proxy API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/eliza-ai/plugin-wechat",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/eliza-ai/plugin-wechat.git"
|
|
11
|
+
},
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"import": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsdown src/index.ts --format esm --dts --clean && mv dist/index-*.d.ts dist/index.d.ts 2>/dev/null || true",
|
|
29
|
+
"check": "tsc --noEmit",
|
|
30
|
+
"test": "vitest run --config ./vitest.config.ts",
|
|
31
|
+
"test:watch": "vitest --config ./vitest.config.ts",
|
|
32
|
+
"clean": "rm -rf dist"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@elizaos/core": "^2.0.0-alpha.537"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@elizaos/core": "workspace:*",
|
|
42
|
+
"tsdown": "^0.21.0",
|
|
43
|
+
"typescript": "^6.0.0",
|
|
44
|
+
"vitest": "^4.0.18"
|
|
45
|
+
},
|
|
46
|
+
"agentConfig": {
|
|
47
|
+
"pluginType": "elizaos:plugin:1.0.0",
|
|
48
|
+
"pluginParameters": {
|
|
49
|
+
"WECHAT_API_KEY": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"required": true,
|
|
52
|
+
"sensitive": true,
|
|
53
|
+
"description": "WeChat proxy service API key"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"keywords": [
|
|
58
|
+
"plugin",
|
|
59
|
+
"elizaos",
|
|
60
|
+
"wechat",
|
|
61
|
+
"connector",
|
|
62
|
+
"messaging"
|
|
63
|
+
],
|
|
64
|
+
"packageType": "plugin"
|
|
65
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { WechatMessageContext } from "./types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DEDUP_WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
|
4
|
+
const DEDUP_MAX_ENTRIES = 1000;
|
|
5
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
|
|
7
|
+
export interface BotOptions {
|
|
8
|
+
onMessage: (msg: WechatMessageContext) => void | Promise<void>;
|
|
9
|
+
featuresGroups?: boolean;
|
|
10
|
+
featuresImages?: boolean;
|
|
11
|
+
/** Deduplication window in milliseconds. Defaults to 30 minutes. */
|
|
12
|
+
dedupWindowMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Bot {
|
|
16
|
+
private readonly seen = new Map<string, number>();
|
|
17
|
+
private readonly onMessage: (
|
|
18
|
+
msg: WechatMessageContext,
|
|
19
|
+
) => void | Promise<void>;
|
|
20
|
+
private readonly featuresGroups: boolean;
|
|
21
|
+
private readonly featuresImages: boolean;
|
|
22
|
+
private readonly dedupWindowMs: number;
|
|
23
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(options: BotOptions) {
|
|
26
|
+
this.onMessage = options.onMessage;
|
|
27
|
+
this.featuresGroups = options.featuresGroups ?? true;
|
|
28
|
+
this.featuresImages = options.featuresImages ?? true;
|
|
29
|
+
this.dedupWindowMs = options.dedupWindowMs ?? DEFAULT_DEDUP_WINDOW_MS;
|
|
30
|
+
|
|
31
|
+
this.cleanupTimer = setInterval(
|
|
32
|
+
() => this.cleanup(),
|
|
33
|
+
DEDUP_CLEANUP_INTERVAL_MS,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
handleIncoming(message: WechatMessageContext): void {
|
|
38
|
+
// Deduplication
|
|
39
|
+
if (this.isDuplicate(message.id)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Feature gate: groups
|
|
44
|
+
if (message.group && !this.featuresGroups) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Feature gate: images
|
|
49
|
+
if (message.type === "image" && !this.featuresImages) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Skip unsupported types
|
|
54
|
+
if (message.type === "unknown") {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
void Promise.resolve(this.onMessage(message)).catch((error: unknown) => {
|
|
59
|
+
console.error("[wechat] Failed to process inbound message:", error);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private isDuplicate(messageId: string): boolean {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
|
|
66
|
+
if (this.seen.has(messageId)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Evict if at capacity
|
|
71
|
+
if (this.seen.size >= DEDUP_MAX_ENTRIES) {
|
|
72
|
+
this.cleanup();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.seen.set(messageId, now);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private cleanup(): void {
|
|
80
|
+
const cutoff = Date.now() - this.dedupWindowMs;
|
|
81
|
+
for (const [id, ts] of this.seen) {
|
|
82
|
+
if (ts < cutoff) {
|
|
83
|
+
this.seen.delete(id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
stop(): void {
|
|
89
|
+
if (this.cleanupTimer) {
|
|
90
|
+
clearInterval(this.cleanupTimer);
|
|
91
|
+
this.cleanupTimer = null;
|
|
92
|
+
}
|
|
93
|
+
this.seen.clear();
|
|
94
|
+
}
|
|
95
|
+
}
|