wechat-to-anything 0.5.0
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/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/cli.mjs +120 -0
- package/cli/agent-adapter.mjs +250 -0
- package/cli/bridge.mjs +330 -0
- package/cli/cdn.mjs +144 -0
- package/cli/weixin.mjs +343 -0
- package/docs/wechat-image-receive.png +0 -0
- package/docs/wechat-image-send.png +0 -0
- package/docs/wechat-voice-demo.gif +0 -0
- package/docs/wechat-voice-demo.mp4 +0 -0
- package/examples/claude-code/README.md +19 -0
- package/examples/claude-code/package-lock.json +340 -0
- package/examples/claude-code/package.json +11 -0
- package/examples/claude-code/server.mjs +98 -0
- package/examples/gemini/README.md +32 -0
- package/examples/gemini/server.mjs +98 -0
- package/examples/openai/README.md +35 -0
- package/examples/openai/server.mjs +119 -0
- package/package.json +39 -0
package/cli/weixin.mjs
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ilinkai WeChat API — 直接调用腾讯 ilinkai 接口
|
|
3
|
+
*
|
|
4
|
+
* 完全独立,不依赖 OpenClaw。
|
|
5
|
+
*
|
|
6
|
+
* 三类 API:
|
|
7
|
+
* 1. 登录:get_bot_qrcode + get_qrcode_status(获取 token)
|
|
8
|
+
* 2. 收消息:getupdates(long-poll)
|
|
9
|
+
* 3. 发消息:sendmessage
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
export const BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
18
|
+
const LONG_POLL_TIMEOUT_MS = 35_000;
|
|
19
|
+
const API_TIMEOUT_MS = 15_000;
|
|
20
|
+
const BOT_TYPE = "3";
|
|
21
|
+
|
|
22
|
+
// ─── 凭证管理 ───────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const CRED_DIR = resolve(homedir(), ".wechat-to-anything");
|
|
25
|
+
const CRED_FILE = resolve(CRED_DIR, "credentials.json");
|
|
26
|
+
|
|
27
|
+
export function loadCredentials() {
|
|
28
|
+
try {
|
|
29
|
+
if (!existsSync(CRED_FILE)) return null;
|
|
30
|
+
const data = JSON.parse(readFileSync(CRED_FILE, "utf-8"));
|
|
31
|
+
if (!data.token) return null;
|
|
32
|
+
return data;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveCredentials(data) {
|
|
39
|
+
mkdirSync(CRED_DIR, { recursive: true });
|
|
40
|
+
writeFileSync(CRED_FILE, JSON.stringify(data, null, 2) + "\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── QR 扫码登录 ────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 获取登录二维码
|
|
47
|
+
* @returns {{ qrcode: string, qrcode_img_content: string }}
|
|
48
|
+
*/
|
|
49
|
+
export async function getQRCode() {
|
|
50
|
+
const url = `${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=${BOT_TYPE}`;
|
|
51
|
+
const res = await fetch(url);
|
|
52
|
+
if (!res.ok) throw new Error(`获取二维码失败: ${res.status}`);
|
|
53
|
+
return res.json();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 轮询二维码状态(long-poll)
|
|
58
|
+
* @returns {{ status: 'wait'|'scaned'|'confirmed'|'expired', bot_token?, ilink_bot_id?, baseurl?, ilink_user_id? }}
|
|
59
|
+
*/
|
|
60
|
+
export async function pollQRStatus(qrcode) {
|
|
61
|
+
const url = `${BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => controller.abort(), LONG_POLL_TIMEOUT_MS);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
headers: { "iLink-App-ClientVersion": "1" },
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (!res.ok) throw new Error(`轮询状态失败: ${res.status}`);
|
|
71
|
+
return res.json();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
if (err.name === "AbortError") return { status: "wait" };
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 完整 QR 登录流程
|
|
81
|
+
* @returns {{ token, accountId, baseUrl, userId }}
|
|
82
|
+
*/
|
|
83
|
+
export async function loginWithQR(onQrCode) {
|
|
84
|
+
const qr = await getQRCode();
|
|
85
|
+
await onQrCode(qr.qrcode_img_content);
|
|
86
|
+
|
|
87
|
+
const deadline = Date.now() + 5 * 60_000; // 5 min
|
|
88
|
+
while (Date.now() < deadline) {
|
|
89
|
+
const status = await pollQRStatus(qr.qrcode);
|
|
90
|
+
|
|
91
|
+
if (status.status === "scaned") {
|
|
92
|
+
process.stdout.write("👀 已扫码,请在微信确认...\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (status.status === "confirmed") {
|
|
96
|
+
const creds = {
|
|
97
|
+
token: status.bot_token,
|
|
98
|
+
accountId: status.ilink_bot_id,
|
|
99
|
+
baseUrl: status.baseurl || BASE_URL,
|
|
100
|
+
userId: status.ilink_user_id,
|
|
101
|
+
savedAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
saveCredentials(creds);
|
|
104
|
+
return creds;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (status.status === "expired") {
|
|
108
|
+
throw new Error("二维码已过期,请重试");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
112
|
+
}
|
|
113
|
+
throw new Error("登录超时");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── 消息 API ───────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export function buildHeaders(token, bodyStr) {
|
|
119
|
+
const uin = crypto.randomBytes(4).readUInt32BE(0);
|
|
120
|
+
return {
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
AuthorizationType: "ilink_bot_token",
|
|
123
|
+
Authorization: `Bearer ${token}`,
|
|
124
|
+
"Content-Length": String(Buffer.byteLength(bodyStr, "utf-8")),
|
|
125
|
+
"X-WECHAT-UIN": Buffer.from(String(uin), "utf-8").toString("base64"),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function apiPost(endpoint, body, token, timeoutMs) {
|
|
130
|
+
const url = `${BASE_URL}/${endpoint}`;
|
|
131
|
+
const bodyStr = JSON.stringify(body);
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(url, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: buildHeaders(token, bodyStr),
|
|
138
|
+
body: bodyStr,
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
});
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
const text = await res.text();
|
|
143
|
+
if (!res.ok) throw new Error(`${endpoint} ${res.status}: ${text}`);
|
|
144
|
+
return JSON.parse(text);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
if (err.name === "AbortError") return null;
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function getUpdates(token, getUpdatesBuf = "") {
|
|
153
|
+
const resp = await apiPost(
|
|
154
|
+
"ilink/bot/getupdates",
|
|
155
|
+
{ get_updates_buf: getUpdatesBuf, base_info: {} },
|
|
156
|
+
token,
|
|
157
|
+
LONG_POLL_TIMEOUT_MS
|
|
158
|
+
);
|
|
159
|
+
if (!resp) return { msgs: [], get_updates_buf: getUpdatesBuf };
|
|
160
|
+
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
161
|
+
throw new Error(`getUpdates: ret=${resp.ret} errcode=${resp.errcode} ${resp.errmsg || ""}`);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
msgs: resp.msgs || [],
|
|
165
|
+
get_updates_buf: resp.get_updates_buf || getUpdatesBuf,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function sendMessage(token, to, text, contextToken) {
|
|
170
|
+
await apiPost(
|
|
171
|
+
"ilink/bot/sendmessage",
|
|
172
|
+
{
|
|
173
|
+
msg: {
|
|
174
|
+
from_user_id: "",
|
|
175
|
+
to_user_id: to,
|
|
176
|
+
client_id: crypto.randomUUID(),
|
|
177
|
+
message_type: 2,
|
|
178
|
+
message_state: 2,
|
|
179
|
+
item_list: [{ type: 1, text_item: { text } }],
|
|
180
|
+
context_token: contextToken,
|
|
181
|
+
},
|
|
182
|
+
base_info: {},
|
|
183
|
+
},
|
|
184
|
+
token,
|
|
185
|
+
API_TIMEOUT_MS
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 发送图片消息(通过 URL 直接发送)
|
|
191
|
+
*/
|
|
192
|
+
export async function sendImageByUrl(token, to, contextToken, imageUrl) {
|
|
193
|
+
await apiPost(
|
|
194
|
+
"ilink/bot/sendmessage",
|
|
195
|
+
{
|
|
196
|
+
msg: {
|
|
197
|
+
from_user_id: "",
|
|
198
|
+
to_user_id: to,
|
|
199
|
+
client_id: crypto.randomUUID(),
|
|
200
|
+
message_type: 2,
|
|
201
|
+
message_state: 2,
|
|
202
|
+
item_list: [{
|
|
203
|
+
type: 2, // IMAGE
|
|
204
|
+
image_item: {
|
|
205
|
+
url: imageUrl,
|
|
206
|
+
},
|
|
207
|
+
}],
|
|
208
|
+
context_token: contextToken,
|
|
209
|
+
},
|
|
210
|
+
base_info: {},
|
|
211
|
+
},
|
|
212
|
+
token,
|
|
213
|
+
API_TIMEOUT_MS
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 发送语音消息(base64 音频数据)
|
|
219
|
+
*/
|
|
220
|
+
export async function sendVoiceMessage(token, to, contextToken, audioBase64, durationSec) {
|
|
221
|
+
await apiPost(
|
|
222
|
+
"ilink/bot/sendmessage",
|
|
223
|
+
{
|
|
224
|
+
msg: {
|
|
225
|
+
from_user_id: "",
|
|
226
|
+
to_user_id: to,
|
|
227
|
+
client_id: crypto.randomUUID(),
|
|
228
|
+
message_type: 2,
|
|
229
|
+
message_state: 2,
|
|
230
|
+
item_list: [{
|
|
231
|
+
type: 3, // VOICE
|
|
232
|
+
voice_item: {
|
|
233
|
+
url: `data:audio/mpeg;base64,${audioBase64}`,
|
|
234
|
+
duration: durationSec || 5,
|
|
235
|
+
},
|
|
236
|
+
}],
|
|
237
|
+
context_token: contextToken,
|
|
238
|
+
},
|
|
239
|
+
base_info: {},
|
|
240
|
+
},
|
|
241
|
+
token,
|
|
242
|
+
API_TIMEOUT_MS
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 发送文件消息(通过 CDN 引用)
|
|
249
|
+
*/
|
|
250
|
+
export async function sendFileMessage(token, to, contextToken, uploaded, fileName) {
|
|
251
|
+
await apiPost(
|
|
252
|
+
"ilink/bot/sendmessage",
|
|
253
|
+
{
|
|
254
|
+
msg: {
|
|
255
|
+
from_user_id: "",
|
|
256
|
+
to_user_id: to,
|
|
257
|
+
client_id: crypto.randomUUID(),
|
|
258
|
+
message_type: 2,
|
|
259
|
+
message_state: 2,
|
|
260
|
+
item_list: [{
|
|
261
|
+
type: 4, // FILE
|
|
262
|
+
file_item: {
|
|
263
|
+
media: {
|
|
264
|
+
encrypt_query_param: uploaded.downloadParam,
|
|
265
|
+
aes_key: Buffer.from(uploaded.aeskey, "hex").toString("base64"),
|
|
266
|
+
encrypt_type: 1,
|
|
267
|
+
},
|
|
268
|
+
file_name: fileName,
|
|
269
|
+
len: String(uploaded.fileSize),
|
|
270
|
+
},
|
|
271
|
+
}],
|
|
272
|
+
context_token: contextToken,
|
|
273
|
+
},
|
|
274
|
+
base_info: {},
|
|
275
|
+
},
|
|
276
|
+
token,
|
|
277
|
+
API_TIMEOUT_MS
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 提取消息文本(支持语音转文字)
|
|
283
|
+
*/
|
|
284
|
+
export function extractText(msg) {
|
|
285
|
+
const items = msg.item_list || [];
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
if (item.type === 1 && item.text_item?.text) return item.text_item.text;
|
|
288
|
+
// 语音转文字(微信自带)
|
|
289
|
+
if (item.type === 3 && item.voice_item?.text) return item.voice_item.text;
|
|
290
|
+
}
|
|
291
|
+
return "";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 提取多媒体信息
|
|
296
|
+
* @returns {{ type: 'image'|'voice'|'file'|'video', encryptQueryParam, aesKey, fileName?, voiceText? } | null}
|
|
297
|
+
*/
|
|
298
|
+
export function extractMedia(msg) {
|
|
299
|
+
const items = msg.item_list || [];
|
|
300
|
+
for (const item of items) {
|
|
301
|
+
// 图片
|
|
302
|
+
if (item.type === 2 && item.image_item?.media?.encrypt_query_param) {
|
|
303
|
+
const img = item.image_item;
|
|
304
|
+
// 图片 AES key 可能在 image_item.aeskey (hex) 或 media.aes_key (base64)
|
|
305
|
+
const aesKey = img.aeskey
|
|
306
|
+
? Buffer.from(img.aeskey, "hex").toString("base64")
|
|
307
|
+
: img.media.aes_key;
|
|
308
|
+
return {
|
|
309
|
+
type: "image",
|
|
310
|
+
encryptQueryParam: img.media.encrypt_query_param,
|
|
311
|
+
aesKey, // 可能 undefined(不加密的图片)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// 语音
|
|
315
|
+
if (item.type === 3 && item.voice_item?.media?.encrypt_query_param) {
|
|
316
|
+
return {
|
|
317
|
+
type: "voice",
|
|
318
|
+
encryptQueryParam: item.voice_item.media.encrypt_query_param,
|
|
319
|
+
aesKey: item.voice_item.media.aes_key,
|
|
320
|
+
voiceText: item.voice_item.text || null, // 微信自带语音转文字
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// 文件
|
|
324
|
+
if (item.type === 4 && item.file_item?.media?.encrypt_query_param) {
|
|
325
|
+
return {
|
|
326
|
+
type: "file",
|
|
327
|
+
encryptQueryParam: item.file_item.media.encrypt_query_param,
|
|
328
|
+
aesKey: item.file_item.media.aes_key,
|
|
329
|
+
fileName: item.file_item.file_name || "file.bin",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// 视频
|
|
333
|
+
if (item.type === 5 && item.video_item?.media?.encrypt_query_param) {
|
|
334
|
+
return {
|
|
335
|
+
type: "video",
|
|
336
|
+
encryptQueryParam: item.video_item.media.encrypt_query_param,
|
|
337
|
+
aesKey: item.video_item.media.aes_key,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 示例:Claude Code Agent
|
|
2
|
+
|
|
3
|
+
把 Claude Code 包装成 HTTP 服务,供 `wechat-to-anything` 连接。
|
|
4
|
+
|
|
5
|
+
## 运行
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd examples/claude-code
|
|
9
|
+
npm install
|
|
10
|
+
ANTHROPIC_API_KEY=sk-ant-xxx npm start
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
然后在另一个终端:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx wechat-to-anything http://localhost:3000/v1
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
微信扫码 → 在微信里和 Claude Code 对话。
|