cc-wechat 0.1.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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +8 -0
  3. package/.pace/stop-block-count +1 -0
  4. package/LICENSE +21 -0
  5. package/README.md +83 -0
  6. package/dist/auth.d.ts +18 -0
  7. package/dist/auth.js +351 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/cdn.d.ts +39 -0
  10. package/dist/cdn.js +228 -0
  11. package/dist/cdn.js.map +1 -0
  12. package/dist/cli.d.ts +5 -0
  13. package/dist/cli.js +127 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/ilink-api.d.ts +33 -0
  16. package/dist/ilink-api.js +206 -0
  17. package/dist/ilink-api.js.map +1 -0
  18. package/dist/patch.d.ts +7 -0
  19. package/dist/patch.js +165 -0
  20. package/dist/patch.js.map +1 -0
  21. package/dist/server.d.ts +6 -0
  22. package/dist/server.js +406 -0
  23. package/dist/server.js.map +1 -0
  24. package/dist/store.d.ts +24 -0
  25. package/dist/store.js +57 -0
  26. package/dist/store.js.map +1 -0
  27. package/dist/text-utils.d.ts +7 -0
  28. package/dist/text-utils.js +56 -0
  29. package/dist/text-utils.js.map +1 -0
  30. package/dist/types.d.ts +98 -0
  31. package/dist/types.js +13 -0
  32. package/dist/types.js.map +1 -0
  33. package/package.json +24 -0
  34. package/packages/cc-channel-patch/README.md +36 -0
  35. package/packages/cc-channel-patch/index.mjs +228 -0
  36. package/packages/cc-channel-patch/package.json +11 -0
  37. package/skills/configure/SKILL.md +32 -0
  38. package/src/auth.ts +400 -0
  39. package/src/cdn.ts +261 -0
  40. package/src/cli.ts +121 -0
  41. package/src/ilink-api.ts +279 -0
  42. package/src/patch.ts +182 -0
  43. package/src/qrcode-terminal.d.ts +10 -0
  44. package/src/server.ts +445 -0
  45. package/src/store.ts +62 -0
  46. package/src/text-utils.ts +56 -0
  47. package/src/types.ts +94 -0
  48. package/tsconfig.json +17 -0
package/src/auth.ts ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * QR 扫码登录 — 终端 ASCII 模式 + 浏览器弹窗模式
3
+ */
4
+
5
+ import http from 'node:http';
6
+ import { exec } from 'node:child_process';
7
+ import { getQRCode, pollQRStatus, DEFAULT_BASE_URL } from './ilink-api.js';
8
+
9
+ // ─── 公共类型 ────────────────────────────────────────
10
+
11
+ export interface LoginResult {
12
+ token: string;
13
+ accountId: string;
14
+ baseUrl?: string;
15
+ }
16
+
17
+ // ─── 常量 ─────────────────────────────────────────────
18
+
19
+ const MAX_QR_REFRESH = 3;
20
+ const LOGIN_TIMEOUT_MS = 5 * 60_000; // 5 分钟
21
+ const QR_WEB_PORT_START = 18891;
22
+ const QR_WEB_PORT_END = 18899;
23
+
24
+ // ─── 终端 ASCII 二维码模式 ────────────────────────────
25
+
26
+ /**
27
+ * 终端 ASCII 二维码登录,适用于 CLI 调用
28
+ * 输出到 stderr 避免干扰 MCP stdio
29
+ */
30
+ export async function loginTerminal(baseUrl?: string): Promise<LoginResult> {
31
+ const qrMod = await import('qrcode-terminal');
32
+ const qrTerminal = (qrMod as unknown as { default: { generate: (text: string, opts: { small: boolean }, cb: (qr: string) => void) => void } }).default ?? qrMod;
33
+
34
+ for (let qrRefreshCount = 0; qrRefreshCount < MAX_QR_REFRESH; qrRefreshCount++) {
35
+ const qrResp = await getQRCode(baseUrl);
36
+
37
+ // 输出 ASCII 二维码到 stderr
38
+ await new Promise<void>((resolve) => {
39
+ qrTerminal.generate(qrResp.qrcode_img_content, { small: true }, (qr: string) => {
40
+ process.stderr.write('\n' + qr + '\n');
41
+ process.stderr.write('请使用微信扫描上方二维码登录\n\n');
42
+ resolve();
43
+ });
44
+ });
45
+
46
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
47
+ let scannedNotified = false;
48
+
49
+ // 内层轮询循环
50
+ while (Date.now() < deadline) {
51
+ const status = await pollQRStatus(qrResp.qrcode, baseUrl);
52
+
53
+ switch (status.status) {
54
+ case 'wait':
55
+ break;
56
+
57
+ case 'scaned':
58
+ if (!scannedNotified) {
59
+ process.stderr.write('已扫码,请在手机上确认...\n');
60
+ scannedNotified = true;
61
+ }
62
+ break;
63
+
64
+ case 'expired':
65
+ process.stderr.write('二维码已过期,正在刷新...\n');
66
+ break;
67
+
68
+ case 'confirmed': {
69
+ if (!status.bot_token || !status.ilink_bot_id) {
70
+ throw new Error('登录确认但缺少 bot_token 或 ilink_bot_id');
71
+ }
72
+ process.stderr.write('登录成功!\n');
73
+ return {
74
+ token: status.bot_token,
75
+ accountId: status.ilink_bot_id,
76
+ baseUrl: status.baseurl ?? baseUrl ?? DEFAULT_BASE_URL,
77
+ };
78
+ }
79
+ }
80
+
81
+ if (status.status === 'expired') break;
82
+
83
+ // 轮询间隔 1 秒
84
+ await new Promise((r) => setTimeout(r, 1000));
85
+ }
86
+ }
87
+
88
+ throw new Error(`二维码已刷新 ${MAX_QR_REFRESH} 次仍未登录,请重试`);
89
+ }
90
+
91
+ // ─── 浏览器弹窗模式 ──────────────────────────────────
92
+
93
+ /**
94
+ * 浏览器弹窗二维码登录,适用于 MCP 内 login tool
95
+ * 启动本地 HTTP 服务展示二维码页面
96
+ */
97
+ export async function loginBrowser(baseUrl?: string): Promise<LoginResult> {
98
+ const qrResp = await getQRCode(baseUrl);
99
+
100
+ // 状态变量
101
+ let currentStatus: string = 'wait';
102
+ let currentQrUrl: string = qrResp.qrcode_img_content;
103
+ let currentQrCode: string = qrResp.qrcode;
104
+ let failMessage: string = '';
105
+
106
+ // 创建 HTTP 服务
107
+ const server = http.createServer((req, res) => {
108
+ const url = new URL(req.url ?? '/', `http://localhost`);
109
+
110
+ if (url.pathname === '/' && req.method === 'GET') {
111
+ // 主页面
112
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
113
+ res.end(buildLoginPage(currentQrUrl));
114
+ return;
115
+ }
116
+
117
+ if (url.pathname === '/status' && req.method === 'GET') {
118
+ res.writeHead(200, { 'Content-Type': 'application/json' });
119
+ res.end(JSON.stringify({ status: currentStatus, message: failMessage }));
120
+ return;
121
+ }
122
+
123
+ if (url.pathname === '/qr-refresh' && req.method === 'GET') {
124
+ res.writeHead(200, { 'Content-Type': 'application/json' });
125
+ res.end(JSON.stringify({ url: currentQrUrl }));
126
+ return;
127
+ }
128
+
129
+ res.writeHead(404);
130
+ res.end('Not Found');
131
+ });
132
+
133
+ // 尝试绑定端口
134
+ const port = await findAvailablePort(server);
135
+
136
+ try {
137
+ // 打开浏览器
138
+ openBrowser(`http://localhost:${port}`);
139
+
140
+ process.stderr.write(`登录页面已打开: http://localhost:${port}\n`);
141
+
142
+ // 后台轮询循环
143
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
144
+ let qrRefreshCount = 0;
145
+
146
+ while (Date.now() < deadline) {
147
+ const status = await pollQRStatus(currentQrCode, baseUrl);
148
+ currentStatus = status.status;
149
+
150
+ switch (status.status) {
151
+ case 'wait':
152
+ break;
153
+
154
+ case 'scaned':
155
+ break;
156
+
157
+ case 'expired': {
158
+ qrRefreshCount++;
159
+ if (qrRefreshCount >= MAX_QR_REFRESH) {
160
+ failMessage = '二维码已多次过期,请重新发起登录';
161
+ throw new Error(failMessage);
162
+ }
163
+ // 刷新二维码
164
+ const newQr = await getQRCode(baseUrl);
165
+ currentQrUrl = newQr.qrcode_img_content;
166
+ currentQrCode = newQr.qrcode;
167
+ currentStatus = 'wait';
168
+ break;
169
+ }
170
+
171
+ case 'confirmed': {
172
+ if (!status.bot_token || !status.ilink_bot_id) {
173
+ throw new Error('登录确认但缺少 bot_token 或 ilink_bot_id');
174
+ }
175
+ currentStatus = 'success';
176
+ return {
177
+ token: status.bot_token,
178
+ accountId: status.ilink_bot_id,
179
+ baseUrl: status.baseurl ?? baseUrl ?? DEFAULT_BASE_URL,
180
+ };
181
+ }
182
+ }
183
+
184
+ await new Promise((r) => setTimeout(r, 1000));
185
+ }
186
+
187
+ throw new Error('登录超时,请重试');
188
+ } finally {
189
+ server.close();
190
+ }
191
+ }
192
+
193
+ // ─── 辅助函数 ─────────────────────────────────────────
194
+
195
+ /**
196
+ * 在端口范围内寻找可用端口并监听
197
+ */
198
+ function findAvailablePort(server: http.Server): Promise<number> {
199
+ return new Promise((resolve, reject) => {
200
+ let port = QR_WEB_PORT_START;
201
+
202
+ const tryListen = (): void => {
203
+ if (port > QR_WEB_PORT_END) {
204
+ reject(new Error(`端口 ${QR_WEB_PORT_START}-${QR_WEB_PORT_END} 均不可用`));
205
+ return;
206
+ }
207
+
208
+ server.once('error', () => {
209
+ port++;
210
+ tryListen();
211
+ });
212
+
213
+ server.listen(port, '127.0.0.1', () => {
214
+ // 移除之前绑定的 error 监听器
215
+ server.removeAllListeners('error');
216
+ resolve(port);
217
+ });
218
+ };
219
+
220
+ tryListen();
221
+ });
222
+ }
223
+
224
+ /**
225
+ * 跨平台打开浏览器
226
+ */
227
+ function openBrowser(url: string): void {
228
+ switch (process.platform) {
229
+ case 'win32':
230
+ exec(`start ${url}`);
231
+ break;
232
+ case 'darwin':
233
+ exec(`open ${url}`);
234
+ break;
235
+ default:
236
+ exec(`xdg-open ${url}`);
237
+ break;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 构建登录 HTML 页面
243
+ */
244
+ function buildLoginPage(qrUrl: string): string {
245
+ const qrImgUrl = `https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(qrUrl)}`;
246
+
247
+ return `<!DOCTYPE html>
248
+ <html lang="zh-CN">
249
+ <head>
250
+ <meta charset="utf-8">
251
+ <meta name="viewport" content="width=device-width, initial-scale=1">
252
+ <title>WeChat × Claude Code</title>
253
+ <style>
254
+ * { margin: 0; padding: 0; box-sizing: border-box; }
255
+ body {
256
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
257
+ background: #1a1a2e;
258
+ color: #e0e0e0;
259
+ display: flex;
260
+ justify-content: center;
261
+ align-items: center;
262
+ min-height: 100vh;
263
+ }
264
+ .container {
265
+ text-align: center;
266
+ padding: 2rem;
267
+ }
268
+ h1 {
269
+ font-size: 1.8rem;
270
+ margin-bottom: 0.5rem;
271
+ color: #fff;
272
+ }
273
+ .subtitle {
274
+ font-size: 1rem;
275
+ color: #888;
276
+ margin-bottom: 2rem;
277
+ }
278
+ .qr-wrapper {
279
+ background: #fff;
280
+ border-radius: 16px;
281
+ padding: 20px;
282
+ display: inline-block;
283
+ margin-bottom: 1.5rem;
284
+ }
285
+ .qr-wrapper img {
286
+ width: 280px;
287
+ height: 280px;
288
+ display: block;
289
+ }
290
+ .status {
291
+ font-size: 1.1rem;
292
+ min-height: 2rem;
293
+ transition: color 0.3s;
294
+ }
295
+ .status.scaned { color: #f0a030; }
296
+ .status.success { color: #4caf50; }
297
+ .status.expired { color: #f44336; }
298
+ .checkmark {
299
+ font-size: 3rem;
300
+ color: #4caf50;
301
+ display: none;
302
+ margin-bottom: 1rem;
303
+ }
304
+ .cmd-hint {
305
+ display: none;
306
+ margin-top: 1rem;
307
+ background: #16213e;
308
+ border: 1px solid #333;
309
+ border-radius: 8px;
310
+ padding: 1rem;
311
+ font-family: monospace;
312
+ font-size: 0.9rem;
313
+ color: #7ec8e3;
314
+ cursor: pointer;
315
+ position: relative;
316
+ }
317
+ .cmd-hint:hover { background: #1a2744; }
318
+ .copied {
319
+ position: absolute;
320
+ top: -1.5rem;
321
+ right: 0.5rem;
322
+ font-size: 0.75rem;
323
+ color: #4caf50;
324
+ opacity: 0;
325
+ transition: opacity 0.3s;
326
+ }
327
+ .copied.show { opacity: 1; }
328
+ </style>
329
+ </head>
330
+ <body>
331
+ <div class="container">
332
+ <h1>WeChat &times; Claude Code</h1>
333
+ <p class="subtitle">使用微信扫码连接</p>
334
+ <div class="checkmark" id="checkmark">&#10003;</div>
335
+ <div class="qr-wrapper" id="qr-wrapper">
336
+ <img id="qr-img" src="${qrImgUrl}" alt="QR Code">
337
+ </div>
338
+ <div class="status" id="status">等待扫码...</div>
339
+ <div class="cmd-hint" id="cmd-hint" onclick="copyCmd()">
340
+ npx cc-wechat
341
+ <span class="copied" id="copied">已复制</span>
342
+ </div>
343
+ </div>
344
+ <script>
345
+ const statusEl = document.getElementById('status');
346
+ const qrImg = document.getElementById('qr-img');
347
+ const checkmark = document.getElementById('checkmark');
348
+ const qrWrapper = document.getElementById('qr-wrapper');
349
+ const cmdHint = document.getElementById('cmd-hint');
350
+
351
+ setInterval(async () => {
352
+ try {
353
+ const res = await fetch('/status');
354
+ const data = await res.json();
355
+ statusEl.className = 'status ' + data.status;
356
+
357
+ switch (data.status) {
358
+ case 'wait':
359
+ statusEl.textContent = '等待扫码...';
360
+ break;
361
+ case 'scaned':
362
+ statusEl.textContent = '已扫码,请在手机上确认...';
363
+ break;
364
+ case 'expired':
365
+ statusEl.textContent = '二维码已过期,正在刷新...';
366
+ refreshQR();
367
+ break;
368
+ case 'success':
369
+ statusEl.textContent = '登录成功!';
370
+ checkmark.style.display = 'block';
371
+ qrWrapper.style.display = 'none';
372
+ cmdHint.style.display = 'block';
373
+ copyCmd();
374
+ break;
375
+ default:
376
+ if (data.message) statusEl.textContent = data.message;
377
+ }
378
+ } catch {}
379
+ }, 2000);
380
+
381
+ async function refreshQR() {
382
+ try {
383
+ const res = await fetch('/qr-refresh');
384
+ const data = await res.json();
385
+ const newUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=' + encodeURIComponent(data.url);
386
+ qrImg.src = newUrl;
387
+ } catch {}
388
+ }
389
+
390
+ function copyCmd() {
391
+ navigator.clipboard.writeText('npx cc-wechat').then(() => {
392
+ const copied = document.getElementById('copied');
393
+ copied.classList.add('show');
394
+ setTimeout(() => copied.classList.remove('show'), 1500);
395
+ }).catch(() => {});
396
+ }
397
+ </script>
398
+ </body>
399
+ </html>`;
400
+ }
package/src/cdn.ts ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * cc-wechat CDN 媒体操作 — AES-128-ECB 加解密 + 上传/下载
3
+ */
4
+ import * as crypto from 'node:crypto';
5
+ import * as fs from 'node:fs';
6
+ import * as os from 'node:os';
7
+ import * as path from 'node:path';
8
+ import { apiFetch, buildHeaders, getUploadUrl, buildBaseInfo } from './ilink-api.js';
9
+
10
+ const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
11
+
12
+ // ─── AES-128-ECB 加解密 ──────────────────────────────
13
+
14
+ /** AES-128-ECB 加密(PKCS7 padding 由 Node.js 自动处理) */
15
+ export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
16
+ const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
17
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
18
+ }
19
+
20
+ /** AES-128-ECB 解密 */
21
+ export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
22
+ const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
23
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
24
+ }
25
+
26
+ /** 计算 AES-ECB PKCS7 padding 后的密文大小 */
27
+ export function aesEcbPaddedSize(plaintextSize: number): number {
28
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
29
+ }
30
+
31
+ // ─── 文件类型检测 ─────────────────────────────────────
32
+
33
+ /** 通过文件头 magic bytes 检测扩展名 */
34
+ function detectExtByMagic(buf: Buffer): string {
35
+ if (buf.length < 4) return '';
36
+ // JPEG: FF D8 FF
37
+ if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return '.jpg';
38
+ // PNG: 89 50 4E 47
39
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return '.png';
40
+ // GIF: 47 49 46 38
41
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) return '.gif';
42
+ // BMP: 42 4D
43
+ if (buf[0] === 0x42 && buf[1] === 0x4D) return '.bmp';
44
+ // WEBP: 52 49 46 46 ... 57 45 42 50
45
+ if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[8] === 0x57 && buf[9] === 0x45) return '.webp';
46
+ // MP4: ... 66 74 79 70 (ftyp at offset 4)
47
+ if (buf.length >= 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return '.mp4';
48
+ // PDF: 25 50 44 46
49
+ if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46) return '.pdf';
50
+ return '';
51
+ }
52
+
53
+ /** 根据扩展名检测媒体类型:IMAGE=1, VIDEO=2, FILE=3 */
54
+ function detectMediaType(filePath: string): number {
55
+ const ext = path.extname(filePath).toLowerCase();
56
+ if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) return 1;
57
+ if (['.mp4', '.mov', '.avi', '.mkv'].includes(ext)) return 2;
58
+ return 3;
59
+ }
60
+
61
+ // ─── 上传媒体 ─────────────────────────────────────────
62
+
63
+ /**
64
+ * 上传媒体文件到微信 CDN 并发送消息
65
+ * @param params.token - Bot 认证 token
66
+ * @param params.toUser - 目标用户 ID
67
+ * @param params.contextToken - 会话上下文 token
68
+ * @param params.filePath - 本地文件路径
69
+ * @param params.baseUrl - iLink API 基址(可选)
70
+ * @param params.cdnBaseUrl - CDN 基址(可选)
71
+ */
72
+ export async function uploadMedia(params: {
73
+ token: string;
74
+ toUser: string;
75
+ contextToken: string;
76
+ filePath: string;
77
+ baseUrl?: string;
78
+ cdnBaseUrl?: string;
79
+ }): Promise<void> {
80
+ const { token, toUser, contextToken, filePath, baseUrl, cdnBaseUrl } = params;
81
+
82
+ // 1. 读取文件
83
+ const fileData = fs.readFileSync(filePath);
84
+ const rawsize = fileData.length;
85
+ const rawfilemd5 = crypto.createHash('md5').update(fileData).digest('hex');
86
+
87
+ // 2. 生成 AES key 并检测媒体类型
88
+ const aesKey = crypto.randomBytes(16);
89
+ const mediaType = detectMediaType(filePath);
90
+
91
+ // 3. 加密文件
92
+ const ciphertext = encryptAesEcb(fileData, aesKey);
93
+
94
+ // 4. 构造 filekey
95
+ const extname = path.extname(filePath);
96
+ const rand = crypto.randomBytes(3).toString('hex');
97
+ const filekey = `cc-wechat-${Date.now()}-${rand}${extname}`;
98
+
99
+ // 5. 获取上传地址
100
+ const uploadResp = await getUploadUrl(token, {
101
+ filekey,
102
+ media_type: mediaType,
103
+ to_user_id: toUser,
104
+ rawsize,
105
+ rawfilemd5,
106
+ filesize: ciphertext.length,
107
+ no_need_thumb: true,
108
+ aeskey: aesKey.toString('hex'),
109
+ base_info: buildBaseInfo(),
110
+ }, baseUrl);
111
+
112
+ const uploadParam = uploadResp.upload_param ?? '';
113
+ const serverFilekey = uploadResp.filekey ?? filekey;
114
+
115
+ // 6. 上传到 CDN
116
+ const cdnUrl =
117
+ `${cdnBaseUrl ?? CDN_BASE_URL}/upload` +
118
+ `?encrypted_query_param=${encodeURIComponent(uploadParam)}` +
119
+ `&filekey=${encodeURIComponent(serverFilekey)}`;
120
+
121
+ const authHeaders = buildHeaders(token);
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => controller.abort(), 60_000);
124
+
125
+ let downloadParam: string;
126
+ try {
127
+ const cdnResp = await fetch(cdnUrl, {
128
+ method: 'POST',
129
+ headers: {
130
+ ...authHeaders,
131
+ 'Content-Type': 'application/octet-stream',
132
+ },
133
+ body: new Uint8Array(ciphertext) as unknown as BodyInit,
134
+ signal: controller.signal,
135
+ });
136
+ if (!cdnResp.ok) {
137
+ const errText = await cdnResp.text();
138
+ throw new Error(`[uploadMedia] CDN HTTP ${cdnResp.status}: ${errText}`);
139
+ }
140
+ downloadParam = cdnResp.headers.get('x-encrypted-param') ?? '';
141
+ } finally {
142
+ clearTimeout(timer);
143
+ }
144
+
145
+ // 7. 构造媒体信息
146
+ const aesKeyBase64 = Buffer.from(aesKey.toString('hex')).toString('base64');
147
+ const mediaInfo = {
148
+ encrypt_query_param: downloadParam,
149
+ aes_key: aesKeyBase64,
150
+ encrypt_type: 1,
151
+ };
152
+
153
+ // 8. 根据媒体类型构造 MessageItem
154
+ let mediaItem: Record<string, unknown>;
155
+ if (mediaType === 1) {
156
+ // 图片
157
+ mediaItem = { type: 2, image_item: { media: mediaInfo, mid_size: ciphertext.length } };
158
+ } else if (mediaType === 2) {
159
+ // 视频
160
+ mediaItem = { type: 5, video_item: { media: mediaInfo, video_size: ciphertext.length } };
161
+ } else {
162
+ // 文件
163
+ mediaItem = {
164
+ type: 4,
165
+ file_item: {
166
+ media: mediaInfo,
167
+ file_name: path.basename(filePath),
168
+ len: String(rawsize),
169
+ md5: rawfilemd5,
170
+ },
171
+ };
172
+ }
173
+
174
+ // 9. 发送消息
175
+ const clientId = `cc-wechat-${crypto.randomBytes(4).toString('hex')}`;
176
+ const body = JSON.stringify({
177
+ msg: {
178
+ from_user_id: '',
179
+ to_user_id: toUser,
180
+ client_id: clientId,
181
+ message_type: 2,
182
+ message_state: 2,
183
+ item_list: [mediaItem],
184
+ context_token: contextToken,
185
+ },
186
+ base_info: buildBaseInfo(),
187
+ });
188
+
189
+ await apiFetch({
190
+ baseUrl,
191
+ endpoint: 'ilink/bot/sendmessage',
192
+ body,
193
+ token,
194
+ timeoutMs: 10_000,
195
+ label: 'uploadMedia',
196
+ });
197
+ }
198
+
199
+ // ─── 下载媒体 ─────────────────────────────────────────
200
+
201
+ /**
202
+ * 从微信 CDN 下载并解密媒体文件
203
+ * @param params.encryptQueryParam - CDN 加密查询参数
204
+ * @param params.aesKeyBase64 - Base64 编码的 AES key
205
+ * @param params.cdnBaseUrl - CDN 基址(可选)
206
+ * @param params.outDir - 输出目录(可选,默认临时目录)
207
+ * @param params.fileName - 输出文件名(可选)
208
+ * @returns 文件绝对路径
209
+ */
210
+ export async function downloadMedia(params: {
211
+ encryptQueryParam: string;
212
+ aesKeyBase64: string;
213
+ cdnBaseUrl?: string;
214
+ outDir?: string;
215
+ fileName?: string;
216
+ }): Promise<string> {
217
+ const { encryptQueryParam, aesKeyBase64, cdnBaseUrl, outDir, fileName } = params;
218
+
219
+ // 1. 构造下载 URL
220
+ const downloadUrl =
221
+ `${cdnBaseUrl ?? CDN_BASE_URL}/download` +
222
+ `?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`;
223
+
224
+ // 2. 下载密文
225
+ const controller = new AbortController();
226
+ const timer = setTimeout(() => controller.abort(), 60_000);
227
+
228
+ let ciphertext: Buffer;
229
+ try {
230
+ const resp = await fetch(downloadUrl, { signal: controller.signal });
231
+ if (!resp.ok) {
232
+ const errText = await resp.text();
233
+ throw new Error(`[downloadMedia] CDN HTTP ${resp.status}: ${errText}`);
234
+ }
235
+ ciphertext = Buffer.from(await resp.arrayBuffer());
236
+ } finally {
237
+ clearTimeout(timer);
238
+ }
239
+
240
+ // 3. 解码 AES key(base64 → hex string → 16 字节 key)
241
+ const hexStr = Buffer.from(aesKeyBase64, 'base64').toString('utf-8');
242
+ const aesKey = Buffer.from(hexStr, 'hex');
243
+
244
+ // 4. 解密
245
+ const plaintext = decryptAesEcb(ciphertext, aesKey);
246
+
247
+ // 5. 通过文件头检测类型并加后缀
248
+ const ext = detectExtByMagic(plaintext);
249
+ const targetDir = outDir ?? path.join(os.tmpdir(), 'cc-wechat', 'media');
250
+ fs.mkdirSync(targetDir, { recursive: true });
251
+
252
+ let targetName = fileName ?? `media-${Date.now()}`;
253
+ // 如果文件名没有后缀,根据 magic bytes 补上
254
+ if (!path.extname(targetName) && ext) {
255
+ targetName += ext;
256
+ }
257
+ const targetPath = path.join(targetDir, targetName);
258
+ fs.writeFileSync(targetPath, plaintext);
259
+
260
+ return path.resolve(targetPath);
261
+ }