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/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 对话。