wechat-to-anything 0.5.6 → 0.6.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/README.md CHANGED
@@ -128,7 +128,7 @@ npx wechat-to-anything \
128
128
  }
129
129
  ```
130
130
 
131
- **图片(Agent → 微信)**:回复中包含 `![desc](https://...)` 自动发图。
131
+ **图片(Agent → 微信)**:回复中包含 `![desc](https://...)` 自动发图(HD 原图质量,自动生成缩略图)。支持 URL、本地路径、data URI。
132
132
 
133
133
  **语音(Agent → 微信)**:回复中包含 `[audio:path 或 URL]` 自动发语音气泡。支持 MP3、WAV、OGG 等。需要 `ffmpeg` 和 `pip install pilk`。
134
134
 
package/cli/bridge.mjs CHANGED
@@ -1,11 +1,14 @@
1
1
  import pc from "picocolors";
2
2
  import {
3
3
  loadCredentials, loginWithQR, getUpdates,
4
- sendMessage, sendImageByUrl,
4
+ sendMessage, sendImageByUrl, sendVideoByUrl,
5
5
  extractText, extractMedia,
6
+ getConfig, sendTyping,
6
7
  } from "./weixin.mjs";
7
- import { downloadAndDecrypt, downloadMediaToFile } from "./cdn.mjs";
8
+ import { downloadAndDecrypt, downloadMediaToFile, uploadToCdn } from "./cdn.mjs";
8
9
  import { callAgentAuto, checkAgent } from "./agent-adapter.mjs";
10
+ import { createTypingController } from "./typing.mjs";
11
+ import { stripMarkdown } from "./markdown.mjs";
9
12
 
10
13
  /**
11
14
  * 启动桥:WeChat ilinkai API ←→ Agent HTTP
@@ -74,6 +77,28 @@ export async function start(agents, defaultAgent) {
74
77
 
75
78
  let getUpdatesBuf = "";
76
79
 
80
+ // typing_ticket 缓存(参考 openclaw-weixin config-cache.ts: 24h TTL)
81
+ const typingTickets = new Map(); // userId → { ticket, fetchedAt }
82
+ const TICKET_TTL = 24 * 60 * 60_000; // 24h
83
+
84
+ async function getTypingTicket(userId, contextToken) {
85
+ const cached = typingTickets.get(userId);
86
+ if (cached && (Date.now() - cached.fetchedAt) < TICKET_TTL) {
87
+ return cached.ticket;
88
+ }
89
+ try {
90
+ const cfg = await getConfig(creds.token, userId, contextToken);
91
+ const ticket = cfg?.typing_ticket || "";
92
+ if (ticket) {
93
+ typingTickets.set(userId, { ticket, fetchedAt: Date.now() });
94
+ console.log(pc.dim(` typing_ticket cached for ${userId.slice(0, 8)}...`));
95
+ }
96
+ return ticket;
97
+ } catch {
98
+ return cached?.ticket || "";
99
+ }
100
+ }
101
+
77
102
  // 每用户图片缓存:发图片时先存着,等下一条文字消息再合并发出
78
103
  const pendingImages = new Map(); // userId → { base64, timestamp }
79
104
  const IMAGE_BUFFER_TTL = 5 * 60_000; // 5 min 过期
@@ -285,15 +310,33 @@ export async function start(agents, defaultAgent) {
285
310
  }
286
311
  const agentUrl = agents.get(targetAgent);
287
312
 
313
+ // Typing 指示器:Agent 思考期间显示"正在输入..."
314
+ const typingTicket = await getTypingTicket(from, contextToken);
315
+ const typing = typingTicket
316
+ ? createTypingController({
317
+ start: () => sendTyping(creds.token, from, typingTicket, 1),
318
+ stop: () => sendTyping(creds.token, from, typingTicket, 2),
319
+ onError: (err) => console.error(pc.dim(` typing: ${err.message}`)),
320
+ keepaliveMs: 5000,
321
+ maxDurationMs: 60_000,
322
+ })
323
+ : null;
324
+
288
325
  // 调用 Agent
289
326
  try {
327
+ if (typing) await typing.onReplyStart();
290
328
  const reply = await callAgentAuto(agentUrl, agentMessages);
329
+ if (typing) typing.onIdle();
291
330
  const agentTag = multiMode ? `[${targetAgent}] ` : "";
292
331
 
293
332
  // 检查回复是否包含 [audio:path/url]
294
333
  const audioMatch = reply.match(/\[audio:(.*?)\]/);
295
334
  // 检查回复是否包含图片(markdown 格式,支持 URL 和 data URI)
296
335
  const imageMatch = reply.match(/!\[.*?\]\(((?:https?:\/\/|data:image\/)[^\s)]+)\)/);
336
+ // 检查回复是否包含 [video:path/url]
337
+ const videoMatch = reply.match(/\[video:(.*?)\]/);
338
+ // 检查回复是否包含 [file:path/url]
339
+ const fileMatch = reply.match(/\[file:(.*?)\]/);
297
340
 
298
341
  if (audioMatch) {
299
342
  const audioSrc = audioMatch[1];
@@ -353,7 +396,7 @@ export async function start(agents, defaultAgent) {
353
396
  body,
354
397
  });
355
398
  console.log(pc.green(`→ [语音] 已发送 (${durationMs}ms)`));
356
- if (textPart) await sendMessage(creds.token, from, agentTag + textPart, contextToken);
399
+ if (textPart) await sendMessage(creds.token, from, agentTag + stripMarkdown(textPart), contextToken);
357
400
  } catch (err) {
358
401
  console.error(pc.red(` 语音发送失败: ${err.message}`));
359
402
  await sendMessage(creds.token, from, agentTag + reply.replace(/\[audio:.*?\]/g, "").trim() || reply, contextToken);
@@ -364,18 +407,58 @@ export async function start(agents, defaultAgent) {
364
407
  const textPart = reply.replace(/!\[.*?\]\(https?:\/\/[^\s)]+\)/g, "").trim();
365
408
  console.log(pc.green(`→ [${targetAgent}] [图片] ${imageUrl.slice(0, 60)}`));
366
409
  try {
367
- if (textPart) await sendMessage(creds.token, from, agentTag + textPart, contextToken);
410
+ if (textPart) await sendMessage(creds.token, from, agentTag + stripMarkdown(textPart), contextToken);
368
411
  await sendImageByUrl(creds.token, from, contextToken, imageUrl);
369
412
  } catch (err) {
370
413
  console.error(pc.red(` 图片发送失败: ${err.message}`));
371
414
  await sendMessage(creds.token, from, agentTag + reply, contextToken);
372
415
  }
416
+ } else if (videoMatch) {
417
+ // Agent 回复了视频 → CDN 上传发到微信
418
+ const videoSrc = videoMatch[1];
419
+ const textPart = reply.replace(/\[video:.*?\]/g, "").trim();
420
+ console.log(pc.green(`→ [${targetAgent}] [视频] ${videoSrc.slice(0, 60)}`));
421
+ try {
422
+ if (textPart) await sendMessage(creds.token, from, agentTag + stripMarkdown(textPart), contextToken);
423
+ await sendVideoByUrl(creds.token, from, contextToken, videoSrc);
424
+ } catch (err) {
425
+ console.error(pc.red(` 视频发送失败: ${err.message}`));
426
+ await sendMessage(creds.token, from, agentTag + stripMarkdown(reply), contextToken);
427
+ }
428
+ } else if (fileMatch) {
429
+ // Agent 回复了文件 → CDN 上传发到微信
430
+ const fileSrc = fileMatch[1];
431
+ const textPart = reply.replace(/\[file:.*?\]/g, "").trim();
432
+ const fileName = fileSrc.split("/").pop() || "file";
433
+ console.log(pc.green(`→ [${targetAgent}] [文件] ${fileSrc.slice(0, 60)}`));
434
+ try {
435
+ const { writeFileSync, unlinkSync } = await import("node:fs");
436
+ const { tmpdir } = await import("node:os");
437
+ const { join } = await import("node:path");
438
+ const resp = await fetch(fileSrc);
439
+ if (!resp.ok) throw new Error(`file download failed: ${resp.status}`);
440
+ const buf = Buffer.from(await resp.arrayBuffer());
441
+ const tmpPath = join(tmpdir(), `wx-file-${Date.now()}-${fileName}`);
442
+ writeFileSync(tmpPath, buf);
443
+ try {
444
+ const uploaded = await uploadToCdn(tmpPath, from, creds.token, 3);
445
+ const { sendFileMessage } = await import("./weixin.mjs");
446
+ await sendFileMessage(creds.token, from, contextToken, uploaded, fileName);
447
+ if (textPart) await sendMessage(creds.token, from, agentTag + stripMarkdown(textPart), contextToken);
448
+ } finally {
449
+ try { unlinkSync(tmpPath); } catch {}
450
+ }
451
+ } catch (err) {
452
+ console.error(pc.red(` 文件发送失败: ${err.message}`));
453
+ await sendMessage(creds.token, from, agentTag + stripMarkdown(reply), contextToken);
454
+ }
373
455
  } else {
374
456
  // 纯文本回复
375
457
  console.log(pc.green(`→ [${targetAgent}] ${reply.slice(0, 80)}${reply.length > 80 ? "..." : ""}`));
376
- await sendMessage(creds.token, from, agentTag + reply, contextToken);
458
+ await sendMessage(creds.token, from, agentTag + stripMarkdown(reply), contextToken);
377
459
  }
378
460
  } catch (err) {
461
+ if (typing) typing.onCleanup();
379
462
  console.error(pc.red(` ${targetAgent} 错误: ${err.message}`));
380
463
  await sendMessage(creds.token, from, `⚠️ ${targetAgent} 错误: ${err.message}`, contextToken);
381
464
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Markdown → 纯文本
3
+ *
4
+ * 微信不渲染 markdown,需要剥离语法。
5
+ *
6
+ * 2 层剥离,参考 OpenClaw:
7
+ * 层 1: openclaw-weixin send.ts#L20-35 — 代码块/图片/链接/表格
8
+ * 层 2: OpenClaw markdown-to-line.ts#L344-375 — bold/italic/headers/quotes/rules
9
+ */
10
+
11
+ /**
12
+ * 剥离 markdown 语法,返回纯文本
13
+ * @param {string} text
14
+ * @returns {string}
15
+ */
16
+ export function stripMarkdown(text) {
17
+ let r = text;
18
+
19
+ // === 层 1: 结构化 markdown(weixin send.ts) ===
20
+ // 代码块 → 保留内容
21
+ r = r.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code) => code.trim());
22
+ // 图片标记 → 删除
23
+ r = r.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
24
+ // 链接 → 保留文字
25
+ r = r.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
26
+ // 表格分隔行 → 删除
27
+ r = r.replace(/^\|[\s:|-]+\|$/gm, "");
28
+ // 表格行 → 空格分隔
29
+ r = r.replace(/^\|(.+)\|$/gm, (_, inner) =>
30
+ inner.split("|").map((c) => c.trim()).join(" "));
31
+
32
+ // === 层 2: inline markdown(markdown-to-line.ts) ===
33
+ // **粗体** / __粗体__
34
+ r = r.replace(/\*\*(.+?)\*\*/g, "$1");
35
+ r = r.replace(/__(.+?)__/g, "$1");
36
+ // *斜体*(lookbehind 避免误匹配 **)
37
+ r = r.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "$1");
38
+ r = r.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, "$1");
39
+ // ~~删除线~~
40
+ r = r.replace(/~~(.+?)~~/g, "$1");
41
+ // # 标题 → 保留文字
42
+ r = r.replace(/^#{1,6}\s+(.+)$/gm, "$1");
43
+ // > 引用 → 保留内容
44
+ r = r.replace(/^>\s?(.*)$/gm, "$1");
45
+ // ---, ***, ___ 分隔线 → 删除
46
+ r = r.replace(/^[-*_]{3,}$/gm, "");
47
+ // `行内代码` → 保留内容
48
+ r = r.replace(/`([^`]+)`/g, "$1");
49
+ // 多余空行
50
+ r = r.replace(/\n{3,}/g, "\n\n");
51
+
52
+ return r.trim();
53
+ }
package/cli/typing.mjs ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Typing 指示器控制器
3
+ *
4
+ * 学习 OpenClaw 框架的 3 层架构:
5
+ * 1. StartGuard — 熔断器,连续失败 N 次停止重试
6
+ * 2. KeepaliveLoop — 定时续发 typing(微信 ~10s 自动取消)
7
+ * 3. TypingController — 组合上述两层 + TTL 安全阀
8
+ *
9
+ * 参考:
10
+ * OpenClaw/src/channels/typing-start-guard.ts
11
+ * OpenClaw/src/channels/typing-lifecycle.ts
12
+ * OpenClaw/src/channels/typing.ts
13
+ *
14
+ * 用法:
15
+ * const ctrl = createTypingController({ start, stop, onError });
16
+ * await ctrl.onReplyStart(); // 收到消息,准备调 Agent 前
17
+ * const reply = await callAgent();
18
+ * ctrl.onIdle(); // Agent 完成,发消息前
19
+ */
20
+
21
+ // ═══════════════════════════════════════════════════════════
22
+ // 层 1: StartGuard — 熔断器
23
+ // 参考: OpenClaw/src/channels/typing-start-guard.ts
24
+ // ═══════════════════════════════════════════════════════════
25
+
26
+ /**
27
+ * 连续失败 maxFailures 次后熔断(tripped),停止重试。
28
+ * 错误不对外 throw,只回调 onError → 不影响主流程。
29
+ */
30
+ function createStartGuard({ onError, maxFailures = 2 } = {}) {
31
+ let consecutiveFailures = 0;
32
+ let tripped = false;
33
+
34
+ return {
35
+ async run(fn) {
36
+ if (tripped) return "skipped";
37
+ try {
38
+ await fn();
39
+ consecutiveFailures = 0;
40
+ return "started";
41
+ } catch (err) {
42
+ consecutiveFailures += 1;
43
+ onError?.(err);
44
+ if (consecutiveFailures >= maxFailures) {
45
+ tripped = true;
46
+ return "tripped";
47
+ }
48
+ return "failed";
49
+ }
50
+ },
51
+ reset() {
52
+ consecutiveFailures = 0;
53
+ tripped = false;
54
+ },
55
+ isTripped() {
56
+ return tripped;
57
+ },
58
+ };
59
+ }
60
+
61
+ // ═══════════════════════════════════════════════════════════
62
+ // 层 2: KeepaliveLoop — 心跳循环
63
+ // 参考: OpenClaw/src/channels/typing-lifecycle.ts
64
+ // ═══════════════════════════════════════════════════════════
65
+
66
+ /**
67
+ * 每 intervalMs 毫秒执行一次 onTick。
68
+ * tickInFlight 锁防止重入(上一次未完成不发新请求)。
69
+ */
70
+ function createKeepaliveLoop({ intervalMs, onTick }) {
71
+ let timer = undefined;
72
+ let tickInFlight = false;
73
+
74
+ const tick = async () => {
75
+ if (tickInFlight) return;
76
+ tickInFlight = true;
77
+ try {
78
+ await onTick();
79
+ } finally {
80
+ tickInFlight = false;
81
+ }
82
+ };
83
+
84
+ return {
85
+ start() {
86
+ if (intervalMs <= 0 || timer) return;
87
+ timer = setInterval(() => { void tick(); }, intervalMs);
88
+ },
89
+ stop() {
90
+ if (!timer) return;
91
+ clearInterval(timer);
92
+ timer = undefined;
93
+ tickInFlight = false;
94
+ },
95
+ isRunning() {
96
+ return timer !== undefined;
97
+ },
98
+ };
99
+ }
100
+
101
+ // ═══════════════════════════════════════════════════════════
102
+ // 层 3: TypingController — 对外接口
103
+ // 参考: OpenClaw/src/channels/typing.ts
104
+ // ═══════════════════════════════════════════════════════════
105
+
106
+ /**
107
+ * 创建 typing 控制器。
108
+ *
109
+ * @param {Object} params
110
+ * @param {() => Promise<void>} params.start — 发 sendTyping(TYPING)
111
+ * @param {() => Promise<void>} [params.stop] — 发 sendTyping(CANCEL)
112
+ * @param {(err: Error) => void} params.onError — 错误回调(不影响主流程)
113
+ * @param {number} [params.keepaliveMs=5000] — 心跳间隔
114
+ * @param {number} [params.maxDurationMs=60000] — TTL 安全阀
115
+ * @param {number} [params.maxFailures=2] — 熔断阈值
116
+ * @returns {{ onReplyStart: () => Promise<void>, onIdle: () => void, onCleanup: () => void }}
117
+ */
118
+ export function createTypingController({
119
+ start,
120
+ stop,
121
+ onError,
122
+ keepaliveMs = 5000,
123
+ maxDurationMs = 60_000,
124
+ maxFailures = 2,
125
+ } = {}) {
126
+ let stopSent = false;
127
+ let closed = false;
128
+ let ttlTimer = undefined;
129
+
130
+ const guard = createStartGuard({ onError, maxFailures });
131
+
132
+ const fireStart = async () => {
133
+ const result = await guard.run(() => start());
134
+ if (result === "tripped") {
135
+ keepalive.stop();
136
+ }
137
+ return result;
138
+ };
139
+
140
+ const keepalive = createKeepaliveLoop({
141
+ intervalMs: keepaliveMs,
142
+ onTick: fireStart,
143
+ });
144
+
145
+ // TTL 安全阀:超过 maxDurationMs 自动停止
146
+ const startTtlTimer = () => {
147
+ if (maxDurationMs <= 0) return;
148
+ clearTtlTimer();
149
+ ttlTimer = setTimeout(() => {
150
+ if (!closed) fireStop();
151
+ }, maxDurationMs);
152
+ };
153
+
154
+ const clearTtlTimer = () => {
155
+ if (ttlTimer) {
156
+ clearTimeout(ttlTimer);
157
+ ttlTimer = undefined;
158
+ }
159
+ };
160
+
161
+ // 开始 typing(调 Agent 前调用)
162
+ const onReplyStart = async () => {
163
+ if (closed) return;
164
+ stopSent = false;
165
+ guard.reset();
166
+ keepalive.stop();
167
+ clearTtlTimer();
168
+ await fireStart();
169
+ if (guard.isTripped()) return;
170
+ keepalive.start();
171
+ startTtlTimer();
172
+ };
173
+
174
+ // 停止 typing(Agent 完成后调用)
175
+ const fireStop = () => {
176
+ closed = true;
177
+ keepalive.stop();
178
+ clearTtlTimer();
179
+ if (!stop || stopSent) return;
180
+ stopSent = true;
181
+ void stop().catch((err) => onError?.(err));
182
+ };
183
+
184
+ return {
185
+ onReplyStart,
186
+ onIdle: fireStop,
187
+ onCleanup: fireStop,
188
+ };
189
+ }
package/cli/weixin.mjs CHANGED
@@ -10,9 +10,9 @@
10
10
  */
11
11
 
12
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";
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
14
+ import { resolve, join } from "node:path";
15
+ import { homedir, tmpdir } from "node:os";
16
16
 
17
17
  export const BASE_URL = "https://ilinkai.weixin.qq.com";
18
18
  const LONG_POLL_TIMEOUT_MS = 35_000;
@@ -318,6 +318,83 @@ export async function sendFileMessage(token, to, contextToken, uploaded, fileNam
318
318
  );
319
319
  }
320
320
 
321
+ /**
322
+ * 发送视频消息(通过 URL 下载 → CDN 上传 → 发送)
323
+ * 参考: openclaw-weixin send.ts#L209-233 (sendVideoMessageWeixin)
324
+ */
325
+ export async function sendVideoByUrl(token, to, contextToken, videoUrl) {
326
+ const { uploadToCdn } = await import("./cdn.mjs");
327
+
328
+ // 下载视频
329
+ const resp = await fetch(videoUrl);
330
+ if (!resp.ok) throw new Error(`video download failed: ${resp.status}`);
331
+ const buf = Buffer.from(await resp.arrayBuffer());
332
+ const tmpPath = join(tmpdir(), `wx-video-${Date.now()}.mp4`);
333
+ writeFileSync(tmpPath, buf);
334
+
335
+ try {
336
+ // CDN 上传 (mediaType=2=VIDEO)
337
+ const uploaded = await uploadToCdn(tmpPath, to, token, 2);
338
+
339
+ // 发送 type:5 video_item
340
+ await apiPost(
341
+ "ilink/bot/sendmessage",
342
+ {
343
+ msg: {
344
+ from_user_id: "",
345
+ to_user_id: to,
346
+ client_id: crypto.randomUUID(),
347
+ message_type: 2,
348
+ message_state: 2,
349
+ item_list: [{
350
+ type: 5, // VIDEO
351
+ video_item: {
352
+ media: {
353
+ encrypt_query_param: uploaded.downloadParam,
354
+ aes_key: Buffer.from(uploaded.aeskey, "hex").toString("base64"),
355
+ },
356
+ video_size: uploaded.ciphertextSize,
357
+ },
358
+ }],
359
+ context_token: contextToken,
360
+ },
361
+ base_info: {},
362
+ },
363
+ token,
364
+ API_TIMEOUT_MS
365
+ );
366
+ } finally {
367
+ try { unlinkSync(tmpPath); } catch {}
368
+ }
369
+ }
370
+
371
+ /**
372
+ * 获取 bot 配置(含 typing_ticket)
373
+ * 参考: openclaw-weixin api.ts#L209-226
374
+ */
375
+ export async function getConfig(token, userId, contextToken) {
376
+ return apiPost(
377
+ "ilink/bot/getconfig",
378
+ { ilink_user_id: userId, context_token: contextToken },
379
+ token,
380
+ 10_000
381
+ );
382
+ }
383
+
384
+ /**
385
+ * 发送打字指示器
386
+ * 参考: openclaw-weixin api.ts#L228-240
387
+ * @param {number} status — 1=typing, 2=cancel
388
+ */
389
+ export async function sendTyping(token, userId, typingTicket, status = 1) {
390
+ return apiPost(
391
+ "ilink/bot/sendtyping",
392
+ { ilink_user_id: userId, typing_ticket: typingTicket, status },
393
+ token,
394
+ 10_000
395
+ );
396
+ }
397
+
321
398
  /**
322
399
  * 提取消息文本(支持语音转文字)
323
400
  */
@@ -1,21 +1,45 @@
1
+ /**
2
+ * 图片发送测试 — HD 原图质量,自动生成缩略图
3
+ *
4
+ * 支持三种输入:
5
+ * - URL: https://example.com/photo.jpg
6
+ * - 本地路径: /path/to/photo.jpg
7
+ * - data URI: data:image/jpeg;base64,...
8
+ *
9
+ * 用法: node examples/image-test.mjs [图片URL或路径]
10
+ *
11
+ * ── 关键参数说明 ──
12
+ *
13
+ * CDN 上传阶段 (getUploadUrl):
14
+ * no_need_thumb: false — 必须为 false,否则微信只显示模糊压缩图
15
+ * thumb_rawsize — 缩略图明文大小
16
+ * thumb_rawfilemd5 — 缩略图明文 MD5
17
+ * thumb_filesize — 缩略图密文大小 (AES-128-ECB padding 后)
18
+ *
19
+ * 发送消息阶段 (sendmessage image_item):
20
+ * media.encrypt_query_param — 原图 CDN 下载参数
21
+ * media.aes_key — base64(hex_aeskey) 解密用
22
+ * thumb_media — 缩略图 CDN 引用(没有则显示模糊/不显示)
23
+ * mid_size — 原图密文大小,微信用于预加载
24
+ * thumb_size — 缩略图密文大小
25
+ * thumb_width — 缩略图宽度 px,用于聊天列表预留正确尺寸
26
+ * thumb_height — 缩略图高度 px,同上
27
+ *
28
+ * ⚠️ hd_size — 设置后"查看原图"下载卡 0%,不要用
29
+ * ⚠️ encrypt_type — 设置为 1 会导致图片不显示,不要用
30
+ */
1
31
  import { readFileSync } from "fs";
2
32
  import { homedir } from "os";
3
33
 
4
- // 凭证
5
34
  const creds = JSON.parse(readFileSync(homedir() + "/.wechat-to-anything/credentials.json", "utf-8"));
6
- const token = creds.token;
7
- const to = creds.userId;
8
35
 
9
- // 测试图片 URL(可替换为任意图片地址)
10
- const imageUrl = "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png";
36
+ // 命令行参数 或 默认测试图片
37
+ const imageUrl = process.argv[2] || "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png";
11
38
 
12
- // 获取 contextToken
13
- const { getUpdates, buildHeaders, BASE_URL } = await import("../cli/weixin.mjs");
14
- const msgs = await getUpdates(token);
39
+ const { sendImageByUrl, getUpdates } = await import("../cli/weixin.mjs");
40
+ const msgs = await getUpdates(creds.token);
15
41
  const contextToken = msgs?.context_token || "";
16
42
 
17
- // 发送图片
18
- console.log("发送图片:", imageUrl.slice(0, 60) + "...");
19
- const { sendImageByUrl } = await import("../cli/weixin.mjs");
20
- await sendImageByUrl(token, to, contextToken, imageUrl);
21
- console.log("✅ 图片已发送");
43
+ console.log("发送图片 (HD):", imageUrl.slice(0, 60) + (imageUrl.length > 60 ? "..." : ""));
44
+ await sendImageByUrl(creds.token, creds.userId, contextToken, imageUrl);
45
+ console.log("✅ 图片已发送(含缩略图,原图质量)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wechat-to-anything",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "一条命令,把微信变成任何 AI Agent 的入口",
5
5
  "type": "module",
6
6
  "bin": {