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/bridge.mjs
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import {
|
|
3
|
+
loadCredentials, loginWithQR, getUpdates,
|
|
4
|
+
sendMessage, sendImageByUrl,
|
|
5
|
+
extractText, extractMedia,
|
|
6
|
+
} from "./weixin.mjs";
|
|
7
|
+
import { downloadAndDecrypt, downloadMediaToFile } from "./cdn.mjs";
|
|
8
|
+
import { callAgentAuto, checkAgent } from "./agent-adapter.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 启动桥:WeChat ilinkai API ←→ Agent HTTP
|
|
12
|
+
* 支持文本 + 图片 + 语音 + 文件,双向
|
|
13
|
+
*/
|
|
14
|
+
export async function start(agents, defaultAgent) {
|
|
15
|
+
// 兼容旧的单 URL 调用
|
|
16
|
+
if (typeof agents === "string") {
|
|
17
|
+
const url = agents;
|
|
18
|
+
agents = new Map([["default", url]]);
|
|
19
|
+
defaultAgent = "default";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const multiMode = agents.size > 1 || !agents.has("default");
|
|
23
|
+
|
|
24
|
+
// 1. 读取或获取 WeChat 登录凭证
|
|
25
|
+
let creds = loadCredentials();
|
|
26
|
+
if (!creds) {
|
|
27
|
+
console.log(pc.yellow("📱 首次使用,请扫码登录微信\n"));
|
|
28
|
+
try {
|
|
29
|
+
creds = await loginWithQR(async (qrUrl) => {
|
|
30
|
+
try {
|
|
31
|
+
const qrt = await import("qrcode-terminal");
|
|
32
|
+
await new Promise((resolve) => {
|
|
33
|
+
qrt.default.generate(qrUrl, { small: true }, (qr) => {
|
|
34
|
+
console.log(qr);
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
console.log(`扫码链接: ${qrUrl}`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
console.log(pc.green("✅ 微信登录成功!"));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(pc.red(`❌ 登录失败: ${err.message}`));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
console.log(pc.green(`✅ 微信已登录`));
|
|
49
|
+
|
|
50
|
+
// 2. 检查所有 Agent 是否可达
|
|
51
|
+
for (const [name, url] of agents) {
|
|
52
|
+
console.log(pc.dim(`🔍 检查 Agent ${name}: ${url}`));
|
|
53
|
+
try {
|
|
54
|
+
await checkAgent(url);
|
|
55
|
+
console.log(pc.green(`✅ ${name} 可达`));
|
|
56
|
+
} catch {
|
|
57
|
+
console.error(pc.red(`❌ 无法连接 ${name}: ${url}`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. 启动消息循环
|
|
63
|
+
console.log(pc.green("🚀 桥已启动(支持文本/图片/语音/文件)"));
|
|
64
|
+
if (multiMode) {
|
|
65
|
+
console.log(pc.dim(` 已注册 ${agents.size} 个 Agent,默认: ${defaultAgent}`));
|
|
66
|
+
console.log(pc.dim(` 发 @list 查看,@切换 <name> 切换默认`));
|
|
67
|
+
} else {
|
|
68
|
+
console.log(pc.dim(" 微信消息 → Agent → 微信回复"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// per-user 默认 Agent
|
|
72
|
+
const userDefaults = new Map();
|
|
73
|
+
console.log();
|
|
74
|
+
|
|
75
|
+
let getUpdatesBuf = "";
|
|
76
|
+
|
|
77
|
+
// 每用户图片缓存:发图片时先存着,等下一条文字消息再合并发出
|
|
78
|
+
const pendingImages = new Map(); // userId → { base64, timestamp }
|
|
79
|
+
const IMAGE_BUFFER_TTL = 5 * 60_000; // 5 min 过期
|
|
80
|
+
|
|
81
|
+
const loop = async () => {
|
|
82
|
+
while (true) {
|
|
83
|
+
try {
|
|
84
|
+
const result = await getUpdates(creds.token, getUpdatesBuf);
|
|
85
|
+
getUpdatesBuf = result.get_updates_buf;
|
|
86
|
+
if (result.msgs.length > 0) {
|
|
87
|
+
console.log(pc.dim(` poll: ${result.msgs.length} 条消息`));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const msg of result.msgs) {
|
|
91
|
+
const from = msg.from_user_id || "";
|
|
92
|
+
const contextToken = msg.context_token || "";
|
|
93
|
+
if (!from) continue;
|
|
94
|
+
|
|
95
|
+
const text = extractText(msg);
|
|
96
|
+
const media = extractMedia(msg);
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// 构建发给 Agent 的消息
|
|
101
|
+
let agentMessages;
|
|
102
|
+
|
|
103
|
+
if (media?.type === "image") {
|
|
104
|
+
// 图片:下载解密,缓存 base64,等待用户发文字
|
|
105
|
+
console.log(pc.cyan(`← [微信] ${from}: [图片] (等待文字问题...)`));
|
|
106
|
+
try {
|
|
107
|
+
const buf = await downloadAndDecrypt(media.encryptQueryParam, media.aesKey);
|
|
108
|
+
pendingImages.set(from, {
|
|
109
|
+
base64: buf.toString("base64"),
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
contextToken,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(pc.red(` 图片下载失败: ${err.message}`));
|
|
115
|
+
}
|
|
116
|
+
continue; // 不发给 Agent,等文字
|
|
117
|
+
|
|
118
|
+
} else if (text) {
|
|
119
|
+
// === 管理命令 ===
|
|
120
|
+
if (multiMode && text.trim() === "@list") {
|
|
121
|
+
const lines = [`📋 已注册 ${agents.size} 个 Agent:`];
|
|
122
|
+
const userDefault = userDefaults.get(from) || defaultAgent;
|
|
123
|
+
for (const [name, url] of agents) {
|
|
124
|
+
lines.push(`${name === userDefault ? " ★" : " ·"} ${name} → ${url}`);
|
|
125
|
+
}
|
|
126
|
+
lines.push(`\n当前默认: ${userDefault}`);
|
|
127
|
+
lines.push(`发 @切换 <name> 切换默认`);
|
|
128
|
+
await sendMessage(creds.token, from, lines.join("\n"), contextToken);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (multiMode && text.trim().startsWith("@切换")) {
|
|
132
|
+
const target = text.trim().replace(/^@切换\s*/, "").toLowerCase();
|
|
133
|
+
if (agents.has(target)) {
|
|
134
|
+
userDefaults.set(from, target);
|
|
135
|
+
await sendMessage(creds.token, from, `✅ 默认 Agent 已切换为 ${target}`, contextToken);
|
|
136
|
+
} else {
|
|
137
|
+
await sendMessage(creds.token, from, `❌ Agent "${target}" 不存在,发 @list 查看可用列表`, contextToken);
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 文字消息:检查是否有缓存的图片
|
|
143
|
+
const pending = pendingImages.get(from);
|
|
144
|
+
if (pending && (Date.now() - pending.timestamp) < IMAGE_BUFFER_TTL) {
|
|
145
|
+
// 有缓存图片 → 合并为多模态消息
|
|
146
|
+
console.log(pc.cyan(`← [微信] ${from}: [图片+文字] ${text.slice(0, 80)}`));
|
|
147
|
+
pendingImages.delete(from);
|
|
148
|
+
agentMessages = [{
|
|
149
|
+
role: "user",
|
|
150
|
+
content: [
|
|
151
|
+
{ type: "text", text },
|
|
152
|
+
{ type: "image_url", image_url: { url: `data:image/jpeg;base64,${pending.base64}` } },
|
|
153
|
+
],
|
|
154
|
+
}];
|
|
155
|
+
} else {
|
|
156
|
+
// 纯文本
|
|
157
|
+
console.log(pc.cyan(`← [微信] ${from}: ${text.slice(0, 80)}${text.length > 80 ? "..." : ""}`));
|
|
158
|
+
agentMessages = [{ role: "user", content: text }];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
} else if (media?.type === "voice") {
|
|
162
|
+
// 打印完整 voice_item 结构,用于对照发送格式
|
|
163
|
+
const voiceItem = (msg.item_list || []).find(i => i.type === 3)?.voice_item;
|
|
164
|
+
if (voiceItem) {
|
|
165
|
+
console.log(pc.yellow("📋 收到的 voice_item 完整结构:"));
|
|
166
|
+
console.log(JSON.stringify(voiceItem, null, 2));
|
|
167
|
+
}
|
|
168
|
+
const voiceText = media.voiceText || text;
|
|
169
|
+
if (voiceText) {
|
|
170
|
+
console.log(pc.cyan(`← [微信] ${from}: [语音] ${voiceText.slice(0, 80)}`));
|
|
171
|
+
agentMessages = [{ role: "user", content: voiceText }];
|
|
172
|
+
} else {
|
|
173
|
+
console.log(pc.cyan(`← [微信] ${from}: [语音] (无法识别)`));
|
|
174
|
+
await sendMessage(creds.token, from, "⚠️ 语音无法识别,请发文字", contextToken);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
} else if (media?.type === "file") {
|
|
178
|
+
console.log(pc.cyan(`← [微信] ${from}: [文件] ${media.fileName}`));
|
|
179
|
+
try {
|
|
180
|
+
const { buffer } = await downloadMediaToFile(media.encryptQueryParam, media.aesKey, media.fileName.split(".").pop());
|
|
181
|
+
if (buffer.length < 100_000) {
|
|
182
|
+
const content = buffer.toString("utf-8");
|
|
183
|
+
if (!content.includes("\x00")) {
|
|
184
|
+
agentMessages = [{ role: "user", content: `用户发送了文件 "${media.fileName}",内容如下:\n\n${content}` }];
|
|
185
|
+
} else {
|
|
186
|
+
agentMessages = [{ role: "user", content: `用户发送了文件 "${media.fileName}"(${(buffer.length / 1024).toFixed(1)} KB,二进制文件)` }];
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
agentMessages = [{ role: "user", content: `用户发送了文件 "${media.fileName}"(${(buffer.length / 1024).toFixed(1)} KB)` }];
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error(pc.red(` 文件下载失败: ${err.message}`));
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
} else if (media?.type === "video") {
|
|
196
|
+
console.log(pc.cyan(`← [微信] ${from}: [视频]`));
|
|
197
|
+
agentMessages = [{ role: "user", content: "用户发送了一段视频" }];
|
|
198
|
+
} else {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// === 语音测试触发器 ===
|
|
203
|
+
if (text === "语音测试") {
|
|
204
|
+
console.log(pc.yellow("🎤 语音测试..."));
|
|
205
|
+
try {
|
|
206
|
+
const { execSync } = await import("node:child_process");
|
|
207
|
+
const { statSync } = await import("node:fs");
|
|
208
|
+
const crypto = await import("node:crypto");
|
|
209
|
+
const { buildHeaders, BASE_URL: baseUrl } = await import("./weixin.mjs");
|
|
210
|
+
const { uploadToCdn } = await import("./cdn.mjs");
|
|
211
|
+
|
|
212
|
+
// TTS → MP3 → PCM(16kHz) → SILK
|
|
213
|
+
execSync(`python3 -m edge_tts --text "你好,这是一条AI语音消息测试" --voice zh-CN-XiaoxiaoNeural --write-media /tmp/tts_bridge.mp3`);
|
|
214
|
+
execSync(`ffmpeg -y -i /tmp/tts_bridge.mp3 -ar 16000 -ac 1 -f s16le /tmp/tts_bridge.pcm 2>/dev/null`);
|
|
215
|
+
execSync(`python3 -c "import pilk; pilk.encode('/tmp/tts_bridge.pcm', '/tmp/tts_bridge.silk', pcm_rate=16000, tencent=True)"`);
|
|
216
|
+
const pcmSize = statSync("/tmp/tts_bridge.pcm").size;
|
|
217
|
+
const durationMs = Math.round((pcmSize / 32000) * 1000);
|
|
218
|
+
console.log(pc.dim(` TTS+SILK 完成 (duration=${durationMs}ms)`));
|
|
219
|
+
|
|
220
|
+
// CDN 上传 (mediaType=4 = 语音)
|
|
221
|
+
const cdn = await uploadToCdn("/tmp/tts_bridge.silk", from, creds.token, 4);
|
|
222
|
+
const aesKeyB64 = Buffer.from(cdn.aeskey).toString("base64");
|
|
223
|
+
console.log(pc.dim(` CDN 上传成功 (mediaType=4)`));
|
|
224
|
+
|
|
225
|
+
// 发送语音消息
|
|
226
|
+
const body = JSON.stringify({
|
|
227
|
+
msg: {
|
|
228
|
+
from_user_id: "", to_user_id: from,
|
|
229
|
+
client_id: crypto.randomUUID(),
|
|
230
|
+
message_type: 2, message_state: 2,
|
|
231
|
+
item_list: [{
|
|
232
|
+
type: 3,
|
|
233
|
+
voice_item: {
|
|
234
|
+
media: {
|
|
235
|
+
encrypt_query_param: cdn.downloadParam,
|
|
236
|
+
aes_key: aesKeyB64,
|
|
237
|
+
},
|
|
238
|
+
encode_type: 4,
|
|
239
|
+
bits_per_sample: 16,
|
|
240
|
+
sample_rate: 16000,
|
|
241
|
+
playtime: durationMs,
|
|
242
|
+
},
|
|
243
|
+
}],
|
|
244
|
+
context_token: contextToken,
|
|
245
|
+
},
|
|
246
|
+
base_info: {},
|
|
247
|
+
});
|
|
248
|
+
const res = await fetch(`${baseUrl}/ilink/bot/sendmessage`, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: buildHeaders(creds.token, body),
|
|
251
|
+
body,
|
|
252
|
+
});
|
|
253
|
+
console.log(pc.green(`→ [语音] status: ${res.status}`));
|
|
254
|
+
await sendMessage(creds.token, from, `🎤 语音已发送 (${durationMs}ms)`, contextToken);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(pc.red(` 语音测试失败: ${err.message}`));
|
|
257
|
+
await sendMessage(creds.token, from, `⚠️ 语音测试失败: ${err.message}`, contextToken);
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 解析 @agentName 路由
|
|
263
|
+
let targetAgent = userDefaults.get(from) || defaultAgent;
|
|
264
|
+
let routedText = text;
|
|
265
|
+
if (multiMode && text) {
|
|
266
|
+
// 先尝试 @name 消息(有空格)
|
|
267
|
+
const atMatch = text.match(/^@(\S+)\s+(.*)$/s);
|
|
268
|
+
if (atMatch && agents.has(atMatch[1].toLowerCase())) {
|
|
269
|
+
targetAgent = atMatch[1].toLowerCase();
|
|
270
|
+
routedText = atMatch[2];
|
|
271
|
+
} else {
|
|
272
|
+
// 再尝试 @name消息(无空格)— 遍历已知 agent 名称
|
|
273
|
+
for (const name of agents.keys()) {
|
|
274
|
+
if (text.toLowerCase().startsWith(`@${name}`)) {
|
|
275
|
+
targetAgent = name;
|
|
276
|
+
routedText = text.slice(1 + name.length).trim() || text;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// 更新 agentMessages 中的文本
|
|
282
|
+
if (routedText !== text && agentMessages.length === 1 && typeof agentMessages[0].content === "string") {
|
|
283
|
+
agentMessages[0].content = routedText;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const agentUrl = agents.get(targetAgent);
|
|
287
|
+
|
|
288
|
+
// 调用 Agent
|
|
289
|
+
try {
|
|
290
|
+
const reply = await callAgentAuto(agentUrl, agentMessages);
|
|
291
|
+
// 检查回复是否包含图片 URL(markdown 格式)
|
|
292
|
+
const imageMatch = reply.match(/!\[.*?\]\((https?:\/\/[^\s)]+)\)/);
|
|
293
|
+
const agentTag = multiMode ? `[${targetAgent}] ` : "";
|
|
294
|
+
if (imageMatch) {
|
|
295
|
+
// Agent 回复了图片 URL → 直接发到微信
|
|
296
|
+
const imageUrl = imageMatch[1];
|
|
297
|
+
const textPart = reply.replace(/!\[.*?\]\(https?:\/\/[^\s)]+\)/g, "").trim();
|
|
298
|
+
console.log(pc.green(`→ [${targetAgent}] [图片] ${imageUrl.slice(0, 60)}`));
|
|
299
|
+
try {
|
|
300
|
+
if (textPart) await sendMessage(creds.token, from, agentTag + textPart, contextToken);
|
|
301
|
+
await sendImageByUrl(creds.token, from, contextToken, imageUrl);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error(pc.red(` 图片发送失败: ${err.message}`));
|
|
304
|
+
await sendMessage(creds.token, from, agentTag + reply, contextToken);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
// 纯文本回复
|
|
308
|
+
console.log(pc.green(`→ [${targetAgent}] ${reply.slice(0, 80)}${reply.length > 80 ? "..." : ""}`));
|
|
309
|
+
await sendMessage(creds.token, from, agentTag + reply, contextToken);
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(pc.red(` ${targetAgent} 错误: ${err.message}`));
|
|
313
|
+
await sendMessage(creds.token, from, `⚠️ ${targetAgent} 错误: ${err.message}`, contextToken);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.error(pc.yellow(`⚠️ ${err.message}`));
|
|
318
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
process.on("SIGINT", () => {
|
|
324
|
+
console.log(pc.dim("\n桥已停止"));
|
|
325
|
+
process.exit(0);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await loop();
|
|
329
|
+
}
|
|
330
|
+
|
package/cli/cdn.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 腾讯 WeChat CDN 加解密 + 上传下载
|
|
3
|
+
*
|
|
4
|
+
* 微信多媒体文件通过 CDN 传输,使用 AES-128-ECB 加密。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
|
|
12
|
+
const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
13
|
+
const MEDIA_DIR = resolve(homedir(), ".wechat-to-anything", "media");
|
|
14
|
+
|
|
15
|
+
/** AES-128-ECB 加密 */
|
|
16
|
+
function encryptAesEcb(plaintext, key) {
|
|
17
|
+
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
18
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** AES-128-ECB 解密 */
|
|
22
|
+
function decryptAesEcb(ciphertext, key) {
|
|
23
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
24
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 计算 AES-128-ECB 密文大小(PKCS7 填充) */
|
|
28
|
+
function aesEcbPaddedSize(plaintextSize) {
|
|
29
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 解析 AES key(两种编码)
|
|
34
|
+
* - base64(16字节) → 图片
|
|
35
|
+
* - base64(32字符hex) → 语音/文件/视频
|
|
36
|
+
*/
|
|
37
|
+
function parseAesKey(aesKeyBase64) {
|
|
38
|
+
const decoded = Buffer.from(aesKeyBase64, "base64");
|
|
39
|
+
if (decoded.length === 16) return decoded;
|
|
40
|
+
if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) {
|
|
41
|
+
return Buffer.from(decoded.toString("ascii"), "hex");
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Invalid AES key length: ${decoded.length}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── 下载 ───────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 从 CDN 下载并解密多媒体文件
|
|
50
|
+
* @returns {Buffer} 解密后的文件内容
|
|
51
|
+
*/
|
|
52
|
+
export async function downloadAndDecrypt(encryptQueryParam, aesKeyBase64) {
|
|
53
|
+
const url = `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`;
|
|
54
|
+
const res = await fetch(url);
|
|
55
|
+
if (!res.ok) throw new Error(`CDN download failed: ${res.status}`);
|
|
56
|
+
const encrypted = Buffer.from(await res.arrayBuffer());
|
|
57
|
+
if (!aesKeyBase64) return encrypted; // 不加密的情况
|
|
58
|
+
const key = parseAesKey(aesKeyBase64);
|
|
59
|
+
return decryptAesEcb(encrypted, key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 下载媒体到本地临时文件
|
|
64
|
+
* @returns {string} 保存的文件路径
|
|
65
|
+
*/
|
|
66
|
+
export async function downloadMediaToFile(encryptQueryParam, aesKeyBase64, ext = "bin") {
|
|
67
|
+
await mkdir(MEDIA_DIR, { recursive: true });
|
|
68
|
+
const buf = await downloadAndDecrypt(encryptQueryParam, aesKeyBase64);
|
|
69
|
+
const fileName = `${Date.now()}-${randomBytes(4).toString("hex")}.${ext}`;
|
|
70
|
+
const filePath = resolve(MEDIA_DIR, fileName);
|
|
71
|
+
await writeFile(filePath, buf);
|
|
72
|
+
return { filePath, buffer: buf };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── 上传 ───────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 上传文件到微信 CDN
|
|
79
|
+
* 流程:读文件 → hash → 生成 AES key → getUploadUrl → 加密上传 → 返回 CDN 引用
|
|
80
|
+
*
|
|
81
|
+
* @param {string} filePath 本地文件路径
|
|
82
|
+
* @param {string} toUserId 目标用户 ID
|
|
83
|
+
* @param {string} token bot token
|
|
84
|
+
* @param {number} mediaType 1=IMAGE, 2=VIDEO, 3=FILE
|
|
85
|
+
* @returns {{ downloadParam, aeskey, fileSize, fileSizeCiphertext, filekey }}
|
|
86
|
+
*/
|
|
87
|
+
export async function uploadToCdn(filePath, toUserId, token, mediaType = 1) {
|
|
88
|
+
const { buildHeaders, BASE_URL } = await import("./weixin.mjs");
|
|
89
|
+
|
|
90
|
+
const plaintext = await readFile(filePath);
|
|
91
|
+
const rawsize = plaintext.length;
|
|
92
|
+
const rawfilemd5 = createHash("md5").update(plaintext).digest("hex");
|
|
93
|
+
const filesize = aesEcbPaddedSize(rawsize);
|
|
94
|
+
const filekey = randomBytes(16).toString("hex");
|
|
95
|
+
const aeskey = randomBytes(16);
|
|
96
|
+
|
|
97
|
+
// 1. 获取上传 URL
|
|
98
|
+
const uploadBody = JSON.stringify({
|
|
99
|
+
filekey,
|
|
100
|
+
media_type: mediaType,
|
|
101
|
+
to_user_id: toUserId,
|
|
102
|
+
rawsize,
|
|
103
|
+
rawfilemd5,
|
|
104
|
+
filesize,
|
|
105
|
+
no_need_thumb: true,
|
|
106
|
+
aeskey: aeskey.toString("hex"),
|
|
107
|
+
base_info: {},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const uploadRes = await fetch(`${BASE_URL}/ilink/bot/getuploadurl`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: buildHeaders(token, uploadBody),
|
|
113
|
+
body: uploadBody,
|
|
114
|
+
});
|
|
115
|
+
if (!uploadRes.ok) throw new Error(`getUploadUrl failed: ${uploadRes.status}`);
|
|
116
|
+
const uploadJson = await uploadRes.json();
|
|
117
|
+
|
|
118
|
+
const { upload_param } = uploadJson;
|
|
119
|
+
if (!upload_param) throw new Error(`getUploadUrl: no upload_param, response: ${JSON.stringify(uploadJson)}`);
|
|
120
|
+
|
|
121
|
+
// 2. 加密 + 上传到 CDN
|
|
122
|
+
const ciphertext = encryptAesEcb(plaintext, aeskey);
|
|
123
|
+
const cdnUrl = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(upload_param)}&filekey=${encodeURIComponent(filekey)}`;
|
|
124
|
+
const cdnRes = await fetch(cdnUrl, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
127
|
+
body: new Uint8Array(ciphertext),
|
|
128
|
+
});
|
|
129
|
+
if (cdnRes.status !== 200) {
|
|
130
|
+
const body = await cdnRes.text();
|
|
131
|
+
throw new Error(`CDN upload failed: ${cdnRes.status} ${body}`);
|
|
132
|
+
}
|
|
133
|
+
// x-encrypted-query-param 匹配下载 URL 参数名 encrypted_query_param
|
|
134
|
+
const downloadParam = cdnRes.headers.get("x-encrypted-query-param") || cdnRes.headers.get("x-encrypted-param");
|
|
135
|
+
if (!downloadParam) throw new Error("CDN upload: missing download param header");
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
downloadParam,
|
|
139
|
+
aeskey: aeskey.toString("hex"),
|
|
140
|
+
fileSize: rawsize,
|
|
141
|
+
fileSizeCiphertext: filesize,
|
|
142
|
+
filekey,
|
|
143
|
+
};
|
|
144
|
+
}
|