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 +1 -1
- package/cli/bridge.mjs +88 -5
- package/cli/markdown.mjs +53 -0
- package/cli/typing.mjs +189 -0
- package/cli/weixin.mjs +80 -3
- package/examples/image-test.mjs +37 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ npx wechat-to-anything \
|
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
**图片(Agent → 微信)**:回复中包含 ``
|
|
131
|
+
**图片(Agent → 微信)**:回复中包含 `` 自动发图(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
|
}
|
package/cli/markdown.mjs
ADDED
|
@@ -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
|
*/
|
package/examples/image-test.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
13
|
-
const
|
|
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
|
-
|
|
19
|
-
|
|
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("✅ 图片已发送(含缩略图,原图质量)");
|