wechat-to-anything 0.6.5 → 0.6.7
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/.claude/settings.local.json +11 -0
- package/README.en.md +30 -1
- package/README.md +30 -1
- package/bin/cli.mjs +15 -1
- package/cli/bridge.mjs +187 -195
- package/cli/cdn.mjs +7 -7
- package/cli/weixin.mjs +0 -28
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"mcp__filesystem__directory_tree",
|
|
5
|
+
"Bash(find /Users/zxw/AITOOL/wechat-to-anything/examples -name *.mjs ! -path */node_modules/* -exec head -40 {})",
|
|
6
|
+
"Bash(2)",
|
|
7
|
+
"Bash(ls -la /Users/zxw/AITOOL/wechat-to-anything/examples/*/package.json)",
|
|
8
|
+
"mcp__filesystem__read_text_file"
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
}
|
package/README.en.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
15
|
-
<a href="#quick-start">Quick Start</a> · <a href="#full-multimodal-matrix">Multimodal</a> · <a href="#media-protocol">Media Protocol</a> · <a href="#multi-agent-mode">Multi-Agent</a> · <a href="#bring-your-own-agent">Custom Agent</a>
|
|
15
|
+
<a href="#quick-start">Quick Start</a> · <a href="#full-multimodal-matrix">Multimodal</a> · <a href="#media-protocol">Media Protocol</a> · <a href="#multi-agent-mode">Multi-Agent</a> · <a href="#proactive-send-api">Send API</a> · <a href="#bring-your-own-agent">Custom Agent</a>
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
- 📡 **Full multimodal** — Text, images, voice, video, files — bidirectional
|
|
39
39
|
- 🤖 **Multi-Agent** — Connect multiple Agents simultaneously, route with `@` prefix
|
|
40
40
|
- ⌨️ **Typing indicator** — Shows "typing..." while Agent is thinking
|
|
41
|
+
- 📤 **Proactive Send API** — Agent can push multiple messages to simulate human typing rhythm
|
|
41
42
|
|
|
42
43
|
### Full Multimodal Matrix
|
|
43
44
|
|
|
@@ -152,6 +153,34 @@ npx wechat-to-anything \
|
|
|
152
153
|
| `@list` | List all Agents |
|
|
153
154
|
| `@switch gemini` | Switch default |
|
|
154
155
|
|
|
156
|
+
## Proactive Send API
|
|
157
|
+
|
|
158
|
+
Bridge starts an HTTP API on `localhost:9099`. Agents can proactively push multiple messages (simulating human typing rhythm):
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
curl -X POST http://localhost:9099/api/send \
|
|
162
|
+
-H "Content-Type: application/json" \
|
|
163
|
+
-d '{"to": "user_id", "content": "Hmm..."}'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- `to` — WeChat user ID (bridge passes this via the `user` field when calling agents)
|
|
167
|
+
- `content` — Same formats as agent responses (plain text, ``, `[audio:path]`, etc.)
|
|
168
|
+
- Use `--port PORT` to customize the port
|
|
169
|
+
|
|
170
|
+
**Use case**: Agent splits one reply into multiple segments with controlled timing:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import requests, time
|
|
174
|
+
def send(to, text):
|
|
175
|
+
requests.post("http://localhost:9099/api/send", json={"to": to, "content": text})
|
|
176
|
+
|
|
177
|
+
send(user_id, "Hmm...")
|
|
178
|
+
time.sleep(1.5)
|
|
179
|
+
send(user_id, "Let me think")
|
|
180
|
+
time.sleep(2)
|
|
181
|
+
# Final segment returned as normal response
|
|
182
|
+
```
|
|
183
|
+
|
|
155
184
|
## Credentials
|
|
156
185
|
|
|
157
186
|
Login credentials are saved in `~/.wechat-to-anything/credentials.json`. Delete to re-login.
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
15
|
-
<a href="#快速开始">快速开始</a> · <a href="#全模态支持矩阵">全模态</a> · <a href="#多媒体协议">多媒体协议</a> · <a href="#多-agent-模式">多 Agent</a> · <a href="#接入自己的-agent">自定义 Agent</a>
|
|
15
|
+
<a href="#快速开始">快速开始</a> · <a href="#全模态支持矩阵">全模态</a> · <a href="#多媒体协议">多媒体协议</a> · <a href="#多-agent-模式">多 Agent</a> · <a href="#主动发送-api">主动发送</a> · <a href="#接入自己的-agent">自定义 Agent</a>
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
- 📡 **全模态** — 文本、图片、语音、视频、文件,双向全覆盖
|
|
39
39
|
- 🤖 **多 Agent** — 同时接入多个 Agent,`@` 路由切换
|
|
40
40
|
- ⌨️ **打字指示器** — Agent 思考时显示"对方正在输入"
|
|
41
|
+
- 📤 **主动发送 API** — Agent 可推送多条消息,模拟真人打字节奏
|
|
41
42
|
|
|
42
43
|
### 全模态支持矩阵
|
|
43
44
|
|
|
@@ -152,6 +153,34 @@ npx wechat-to-anything \
|
|
|
152
153
|
| `@list` | 查看所有 Agent |
|
|
153
154
|
| `@切换 gemini` | 切换默认 |
|
|
154
155
|
|
|
156
|
+
## 主动发送 API
|
|
157
|
+
|
|
158
|
+
Bridge 启动时会在 `localhost:9099` 暴露 HTTP API,Agent 可主动推送多条消息(模拟真人打字节奏):
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
curl -X POST http://localhost:9099/api/send \
|
|
162
|
+
-H "Content-Type: application/json" \
|
|
163
|
+
-d '{"to": "user_id", "content": "嗯……"}'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- `to` — 微信用户 ID(bridge 调 agent 时通过 `user` 字段传入)
|
|
167
|
+
- `content` — 支持和 Agent 回复相同的格式(纯文本、``、`[audio:path]` 等)
|
|
168
|
+
- 用 `--port PORT` 自定义端口
|
|
169
|
+
|
|
170
|
+
**用途**:Agent 对一条消息可分多段回复,控制发送间隔:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import requests, time
|
|
174
|
+
def send(to, text):
|
|
175
|
+
requests.post("http://localhost:9099/api/send", json={"to": to, "content": text})
|
|
176
|
+
|
|
177
|
+
send(user_id, "嗯……")
|
|
178
|
+
time.sleep(1.5)
|
|
179
|
+
send(user_id, "让我想想")
|
|
180
|
+
time.sleep(2)
|
|
181
|
+
# 最后一段作为正常 response 返回
|
|
182
|
+
```
|
|
183
|
+
|
|
155
184
|
## 凭证
|
|
156
185
|
|
|
157
186
|
登录凭证保存在 `~/.wechat-to-anything/credentials.json`,删除即可重新登录。
|
package/bin/cli.mjs
CHANGED
|
@@ -32,6 +32,12 @@ ${pc.bold("参数:")}
|
|
|
32
32
|
--openclaw ${pc.dim("内置 OpenClaw(需先 npm i -g openclaw)")}
|
|
33
33
|
--agent ${pc.dim("name=url")} ${pc.dim("注册自定义 Agent")}
|
|
34
34
|
--default ${pc.dim("name")} ${pc.dim("设置默认 Agent")}
|
|
35
|
+
--port ${pc.dim("PORT")} ${pc.dim("API 端口(默认 9099),暴露 POST /api/send")}
|
|
36
|
+
|
|
37
|
+
${pc.bold("API:")}
|
|
38
|
+
POST http://localhost:PORT/api/send
|
|
39
|
+
${pc.dim('{ "to": "user_id", "content": "消息内容" }')}
|
|
40
|
+
${pc.dim("Agent 可主动推送多条消息,模拟真人节奏")}
|
|
35
41
|
|
|
36
42
|
${pc.dim("Docs: https://github.com/kellyvv/wechat-to-anything")}
|
|
37
43
|
`);
|
|
@@ -40,6 +46,7 @@ ${pc.dim("Docs: https://github.com/kellyvv/wechat-to-anything")}
|
|
|
40
46
|
|
|
41
47
|
// 解析参数
|
|
42
48
|
let i = 0;
|
|
49
|
+
let port = 9099;
|
|
43
50
|
while (i < args.length) {
|
|
44
51
|
if (args[i] === "--codex") {
|
|
45
52
|
agents.set("codex", "cli://codex");
|
|
@@ -71,6 +78,13 @@ while (i < args.length) {
|
|
|
71
78
|
} else if (args[i] === "--default" && args[i + 1]) {
|
|
72
79
|
defaultAgent = args[i + 1].toLowerCase();
|
|
73
80
|
i += 2;
|
|
81
|
+
} else if (args[i] === "--port" && args[i + 1]) {
|
|
82
|
+
port = parseInt(args[i + 1], 10);
|
|
83
|
+
if (isNaN(port)) {
|
|
84
|
+
console.error(pc.red(`无效的端口号: ${args[i + 1]}`));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
i += 2;
|
|
74
88
|
} else if (!args[i].startsWith("--")) {
|
|
75
89
|
if (!args[i].startsWith("acp://")) {
|
|
76
90
|
try { new URL(args[i]); } catch {
|
|
@@ -114,7 +128,7 @@ if (agents.size === 1 && agents.has("default")) {
|
|
|
114
128
|
}
|
|
115
129
|
console.log();
|
|
116
130
|
|
|
117
|
-
import("../cli/bridge.mjs").then((mod) => mod.start(agents, defaultAgent)).catch((err) => {
|
|
131
|
+
import("../cli/bridge.mjs").then((mod) => mod.start(agents, defaultAgent, { port })).catch((err) => {
|
|
118
132
|
console.error(pc.red(err.message));
|
|
119
133
|
process.exit(1);
|
|
120
134
|
});
|
package/cli/bridge.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
+
import { createServer } from "node:http";
|
|
2
3
|
import {
|
|
3
4
|
loadCredentials, loginWithQR, getUpdates,
|
|
4
5
|
sendMessage, sendImageByUrl, sendVideoByUrl,
|
|
@@ -14,7 +15,7 @@ import { stripMarkdown } from "./markdown.mjs";
|
|
|
14
15
|
* 启动桥:WeChat ilinkai API ←→ Agent HTTP
|
|
15
16
|
* 支持文本 + 图片 + 语音 + 文件,双向
|
|
16
17
|
*/
|
|
17
|
-
export async function start(agents, defaultAgent) {
|
|
18
|
+
export async function start(agents, defaultAgent, { port = 9099 } = {}) {
|
|
18
19
|
// 兼容旧的单 URL 调用
|
|
19
20
|
if (typeof agents === "string") {
|
|
20
21
|
const url = agents;
|
|
@@ -103,6 +104,186 @@ export async function start(agents, defaultAgent) {
|
|
|
103
104
|
const pendingImages = new Map(); // userId → { base64, timestamp }
|
|
104
105
|
const IMAGE_BUFFER_TTL = 5 * 60_000; // 5 min 过期
|
|
105
106
|
|
|
107
|
+
// per-user contextToken 缓存(供 /api/send 使用)
|
|
108
|
+
const userContextTokens = new Map(); // userId → contextToken
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 统一发送内容(纯文本 / 图片 / 语音 / 视频 / 文件)
|
|
112
|
+
* 复用 Agent 回复的多媒体协议格式
|
|
113
|
+
*/
|
|
114
|
+
async function sendContent(to, content, tag = "") {
|
|
115
|
+
const ct = userContextTokens.get(to) || "";
|
|
116
|
+
|
|
117
|
+
// 检查回复是否包含 [audio:path/url]
|
|
118
|
+
const audioMatch = content.match(/\[audio:(.*?)\]/);
|
|
119
|
+
// 检查回复是否包含图片(markdown 格式,支持 URL 和 data URI)
|
|
120
|
+
const imageMatch = content.match(/!\[.*?\]\(((?:https?:\/\/|data:image\/|\/).+?)\)/);
|
|
121
|
+
// 检查回复是否包含 [video:path/url]
|
|
122
|
+
const videoMatch = content.match(/\[video:(.*?)\]/);
|
|
123
|
+
// 检查回复是否包含 [file:path/url]
|
|
124
|
+
const fileMatch = content.match(/\[file:(.*?)\]/);
|
|
125
|
+
|
|
126
|
+
if (audioMatch) {
|
|
127
|
+
const audioSrc = audioMatch[1];
|
|
128
|
+
const textPart = content.replace(/\[audio:.*?\]/g, "").trim();
|
|
129
|
+
console.log(pc.green(`→ [send] [语音] ${audioSrc.slice(0, 60)}`));
|
|
130
|
+
try {
|
|
131
|
+
const { execFileSync } = await import("node:child_process");
|
|
132
|
+
const { statSync, writeFileSync } = await import("node:fs");
|
|
133
|
+
const { uploadToCdn } = await import("./cdn.mjs");
|
|
134
|
+
const { buildHeaders, BASE_URL: baseUrl } = await import("./weixin.mjs");
|
|
135
|
+
|
|
136
|
+
let audioFile = audioSrc;
|
|
137
|
+
if (audioSrc.startsWith("http://") || audioSrc.startsWith("https://")) {
|
|
138
|
+
const resp = await fetch(audioSrc);
|
|
139
|
+
if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
|
|
140
|
+
writeFileSync("/tmp/wxta_audio_in.mp3", Buffer.from(await resp.arrayBuffer()));
|
|
141
|
+
audioFile = "/tmp/wxta_audio_in.mp3";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
execFileSync("ffmpeg", ["-y", "-i", audioFile, "-ar", "16000", "-ac", "1", "-f", "s16le", "/tmp/wxta_audio.pcm"], { stdio: "ignore" });
|
|
145
|
+
execFileSync("python3", ["-c", "import pilk; pilk.encode('/tmp/wxta_audio.pcm', '/tmp/wxta_audio.silk', pcm_rate=16000, tencent=True)"]);
|
|
146
|
+
const pcmSize = statSync("/tmp/wxta_audio.pcm").size;
|
|
147
|
+
const durationMs = Math.round((pcmSize / 32000) * 1000);
|
|
148
|
+
|
|
149
|
+
const cdn = await uploadToCdn("/tmp/wxta_audio.silk", to, creds.token, 4);
|
|
150
|
+
const aesKeyB64 = Buffer.from(cdn.aeskey).toString("base64");
|
|
151
|
+
const crypto = await import("node:crypto");
|
|
152
|
+
|
|
153
|
+
const body = JSON.stringify({
|
|
154
|
+
msg: {
|
|
155
|
+
from_user_id: "", to_user_id: to,
|
|
156
|
+
client_id: crypto.randomUUID(),
|
|
157
|
+
message_type: 2, message_state: 2,
|
|
158
|
+
item_list: [{
|
|
159
|
+
type: 3,
|
|
160
|
+
voice_item: {
|
|
161
|
+
media: { encrypt_query_param: cdn.downloadParam, aes_key: aesKeyB64 },
|
|
162
|
+
encode_type: 4, bits_per_sample: 16, sample_rate: 16000, playtime: durationMs,
|
|
163
|
+
},
|
|
164
|
+
}],
|
|
165
|
+
context_token: ct,
|
|
166
|
+
},
|
|
167
|
+
base_info: {},
|
|
168
|
+
});
|
|
169
|
+
await fetch(`${baseUrl}/ilink/bot/sendmessage`, {
|
|
170
|
+
method: "POST", headers: buildHeaders(creds.token, body), body,
|
|
171
|
+
});
|
|
172
|
+
console.log(pc.green(`→ [语音] 已发送 (${durationMs}ms)`));
|
|
173
|
+
if (textPart) await sendMessage(creds.token, to, tag + stripMarkdown(textPart), ct);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(pc.red(` 语音发送失败: ${err.message}`));
|
|
176
|
+
await sendMessage(creds.token, to, tag + content.replace(/\[audio:.*?\]/g, "").trim() || content, ct);
|
|
177
|
+
}
|
|
178
|
+
} else if (imageMatch) {
|
|
179
|
+
const imageUrl = imageMatch[1];
|
|
180
|
+
const textPart = content.replace(/!\[.*?\]\(((?:https?:\/\/|data:image\/|\/).+?)\)/g, "").trim();
|
|
181
|
+
console.log(pc.green(`→ [send] [图片] ${imageUrl.slice(0, 60)}`));
|
|
182
|
+
try {
|
|
183
|
+
if (textPart) await sendMessage(creds.token, to, tag + stripMarkdown(textPart), ct);
|
|
184
|
+
await sendImageByUrl(creds.token, to, ct, imageUrl);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error(pc.red(` 图片发送失败: ${err.message}`));
|
|
187
|
+
await sendMessage(creds.token, to, tag + content, ct);
|
|
188
|
+
}
|
|
189
|
+
} else if (videoMatch) {
|
|
190
|
+
const videoSrc = videoMatch[1];
|
|
191
|
+
const textPart = content.replace(/\[video:.*?\]/g, "").trim();
|
|
192
|
+
console.log(pc.green(`→ [send] [视频] ${videoSrc.slice(0, 60)}`));
|
|
193
|
+
try {
|
|
194
|
+
if (textPart) await sendMessage(creds.token, to, tag + stripMarkdown(textPart), ct);
|
|
195
|
+
await sendVideoByUrl(creds.token, to, ct, videoSrc);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(pc.red(` 视频发送失败: ${err.message}`));
|
|
198
|
+
await sendMessage(creds.token, to, tag + stripMarkdown(content), ct);
|
|
199
|
+
}
|
|
200
|
+
} else if (fileMatch) {
|
|
201
|
+
const fileSrc = fileMatch[1];
|
|
202
|
+
const textPart = content.replace(/\[file:.*?\]/g, "").trim();
|
|
203
|
+
const fileName = fileSrc.split("/").pop() || "file";
|
|
204
|
+
console.log(pc.green(`→ [send] [文件] ${fileSrc.slice(0, 60)}`));
|
|
205
|
+
try {
|
|
206
|
+
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
207
|
+
const { tmpdir } = await import("node:os");
|
|
208
|
+
const { join } = await import("node:path");
|
|
209
|
+
const resp = await fetch(fileSrc);
|
|
210
|
+
if (!resp.ok) throw new Error(`file download failed: ${resp.status}`);
|
|
211
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
212
|
+
const tmpPath = join(tmpdir(), `wx-file-${Date.now()}-${fileName}`);
|
|
213
|
+
writeFileSync(tmpPath, buf);
|
|
214
|
+
try {
|
|
215
|
+
const uploaded = await uploadToCdn(tmpPath, to, creds.token, 3);
|
|
216
|
+
const { sendFileMessage } = await import("./weixin.mjs");
|
|
217
|
+
await sendFileMessage(creds.token, to, ct, uploaded, fileName);
|
|
218
|
+
if (textPart) await sendMessage(creds.token, to, tag + stripMarkdown(textPart), ct);
|
|
219
|
+
} finally {
|
|
220
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error(pc.red(` 文件发送失败: ${err.message}`));
|
|
224
|
+
await sendMessage(creds.token, to, tag + stripMarkdown(content), ct);
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
// 纯文本
|
|
228
|
+
console.log(pc.green(`→ [send] ${content.slice(0, 80)}${content.length > 80 ? "..." : ""}`));
|
|
229
|
+
await sendMessage(creds.token, to, tag + stripMarkdown(content), ct);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── HTTP API Server (/api/send) ────────────────────────────────
|
|
234
|
+
const httpServer = createServer(async (req, res) => {
|
|
235
|
+
// CORS
|
|
236
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
237
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
238
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
239
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
240
|
+
|
|
241
|
+
if (req.method === "POST" && req.url === "/api/send") {
|
|
242
|
+
let body = "";
|
|
243
|
+
let bodySize = 0;
|
|
244
|
+
for await (const chunk of req) {
|
|
245
|
+
bodySize += chunk.length;
|
|
246
|
+
if (bodySize > 1_048_576) { // 1MB limit
|
|
247
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
248
|
+
res.end(JSON.stringify({ error: "body too large (max 1MB)" }));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
body += chunk;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const { to, content } = JSON.parse(body);
|
|
255
|
+
if (!to || !content) {
|
|
256
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
257
|
+
res.end(JSON.stringify({ error: "missing 'to' or 'content'" }));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
console.log(pc.cyan(`← [API] → ${to.slice(0, 12)}...: ${content.slice(0, 60)}`));
|
|
261
|
+
await sendContent(to, content);
|
|
262
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
263
|
+
res.end("{}");
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(pc.red(` /api/send 错误: ${err.message}`));
|
|
266
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
267
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
273
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
httpServer.on("error", (err) => {
|
|
277
|
+
if (err.code === "EADDRINUSE") {
|
|
278
|
+
console.warn(pc.yellow(`⚠️ 端口 ${port} 已被占用,API 未启动(bridge 继续运行)`));
|
|
279
|
+
} else {
|
|
280
|
+
console.error(pc.red(`API 服务器错误: ${err.message}`));
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
httpServer.listen(port, () => {
|
|
284
|
+
console.log(pc.green(`📡 API 已启动: http://localhost:${port}/api/send`));
|
|
285
|
+
});
|
|
286
|
+
|
|
106
287
|
const loop = async () => {
|
|
107
288
|
while (true) {
|
|
108
289
|
try {
|
|
@@ -117,6 +298,9 @@ export async function start(agents, defaultAgent) {
|
|
|
117
298
|
const contextToken = msg.context_token || "";
|
|
118
299
|
if (!from) continue;
|
|
119
300
|
|
|
301
|
+
// 缓存 contextToken 供 /api/send 使用
|
|
302
|
+
if (contextToken) userContextTokens.set(from, contextToken);
|
|
303
|
+
|
|
120
304
|
const text = extractText(msg);
|
|
121
305
|
const media = extractMedia(msg);
|
|
122
306
|
|
|
@@ -184,12 +368,6 @@ export async function start(agents, defaultAgent) {
|
|
|
184
368
|
}
|
|
185
369
|
|
|
186
370
|
} else if (media?.type === "voice") {
|
|
187
|
-
// 打印完整 voice_item 结构,用于对照发送格式
|
|
188
|
-
const voiceItem = (msg.item_list || []).find(i => i.type === 3)?.voice_item;
|
|
189
|
-
if (voiceItem) {
|
|
190
|
-
console.log(pc.yellow("📋 收到的 voice_item 完整结构:"));
|
|
191
|
-
console.log(JSON.stringify(voiceItem, null, 2));
|
|
192
|
-
}
|
|
193
371
|
const voiceText = media.voiceText || text;
|
|
194
372
|
if (voiceText) {
|
|
195
373
|
console.log(pc.cyan(`← [微信] ${from}: [语音] ${voiceText.slice(0, 80)}`));
|
|
@@ -224,65 +402,6 @@ export async function start(agents, defaultAgent) {
|
|
|
224
402
|
continue;
|
|
225
403
|
}
|
|
226
404
|
|
|
227
|
-
// === 语音测试触发器 ===
|
|
228
|
-
if (text === "语音测试") {
|
|
229
|
-
console.log(pc.yellow("🎤 语音测试..."));
|
|
230
|
-
try {
|
|
231
|
-
const { execSync } = await import("node:child_process");
|
|
232
|
-
const { statSync } = await import("node:fs");
|
|
233
|
-
const crypto = await import("node:crypto");
|
|
234
|
-
const { buildHeaders, BASE_URL: baseUrl } = await import("./weixin.mjs");
|
|
235
|
-
const { uploadToCdn } = await import("./cdn.mjs");
|
|
236
|
-
|
|
237
|
-
// TTS → MP3 → PCM(16kHz) → SILK
|
|
238
|
-
execSync(`python3 -m edge_tts --text "你好,这是一条AI语音消息测试" --voice zh-CN-XiaoxiaoNeural --write-media /tmp/tts_bridge.mp3`);
|
|
239
|
-
execSync(`ffmpeg -y -i /tmp/tts_bridge.mp3 -ar 16000 -ac 1 -f s16le /tmp/tts_bridge.pcm 2>/dev/null`);
|
|
240
|
-
execSync(`python3 -c "import pilk; pilk.encode('/tmp/tts_bridge.pcm', '/tmp/tts_bridge.silk', pcm_rate=16000, tencent=True)"`);
|
|
241
|
-
const pcmSize = statSync("/tmp/tts_bridge.pcm").size;
|
|
242
|
-
const durationMs = Math.round((pcmSize / 32000) * 1000);
|
|
243
|
-
console.log(pc.dim(` TTS+SILK 完成 (duration=${durationMs}ms)`));
|
|
244
|
-
|
|
245
|
-
// CDN 上传 (mediaType=4 = 语音)
|
|
246
|
-
const cdn = await uploadToCdn("/tmp/tts_bridge.silk", from, creds.token, 4);
|
|
247
|
-
const aesKeyB64 = Buffer.from(cdn.aeskey).toString("base64");
|
|
248
|
-
console.log(pc.dim(` CDN 上传成功 (mediaType=4)`));
|
|
249
|
-
|
|
250
|
-
// 发送语音消息
|
|
251
|
-
const body = JSON.stringify({
|
|
252
|
-
msg: {
|
|
253
|
-
from_user_id: "", to_user_id: from,
|
|
254
|
-
client_id: crypto.randomUUID(),
|
|
255
|
-
message_type: 2, message_state: 2,
|
|
256
|
-
item_list: [{
|
|
257
|
-
type: 3,
|
|
258
|
-
voice_item: {
|
|
259
|
-
media: {
|
|
260
|
-
encrypt_query_param: cdn.downloadParam,
|
|
261
|
-
aes_key: aesKeyB64,
|
|
262
|
-
},
|
|
263
|
-
encode_type: 4,
|
|
264
|
-
bits_per_sample: 16,
|
|
265
|
-
sample_rate: 16000,
|
|
266
|
-
playtime: durationMs,
|
|
267
|
-
},
|
|
268
|
-
}],
|
|
269
|
-
context_token: contextToken,
|
|
270
|
-
},
|
|
271
|
-
base_info: {},
|
|
272
|
-
});
|
|
273
|
-
const res = await fetch(`${baseUrl}/ilink/bot/sendmessage`, {
|
|
274
|
-
method: "POST",
|
|
275
|
-
headers: buildHeaders(creds.token, body),
|
|
276
|
-
body,
|
|
277
|
-
});
|
|
278
|
-
console.log(pc.green(`→ [语音] status: ${res.status}`));
|
|
279
|
-
await sendMessage(creds.token, from, `🎤 语音已发送 (${durationMs}ms)`, contextToken);
|
|
280
|
-
} catch (err) {
|
|
281
|
-
console.error(pc.red(` 语音测试失败: ${err.message}`));
|
|
282
|
-
await sendMessage(creds.token, from, `⚠️ 语音测试失败: ${err.message}`, contextToken);
|
|
283
|
-
}
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
405
|
|
|
287
406
|
// 解析 @agentName 路由
|
|
288
407
|
let targetAgent = userDefaults.get(from) || defaultAgent;
|
|
@@ -328,135 +447,7 @@ export async function start(agents, defaultAgent) {
|
|
|
328
447
|
const reply = await callAgentAuto(agentUrl, agentMessages, from);
|
|
329
448
|
if (typing) typing.onIdle();
|
|
330
449
|
const agentTag = multiMode ? `[${targetAgent}] ` : "";
|
|
331
|
-
|
|
332
|
-
// 检查回复是否包含 [audio:path/url]
|
|
333
|
-
const audioMatch = reply.match(/\[audio:(.*?)\]/);
|
|
334
|
-
// 检查回复是否包含图片(markdown 格式,支持 URL 和 data URI)
|
|
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:(.*?)\]/);
|
|
340
|
-
|
|
341
|
-
if (audioMatch) {
|
|
342
|
-
const audioSrc = audioMatch[1];
|
|
343
|
-
const textPart = reply.replace(/\[audio:.*?\]/g, "").trim();
|
|
344
|
-
console.log(pc.green(`→ [${targetAgent}] [语音] ${audioSrc.slice(0, 60)}`));
|
|
345
|
-
try {
|
|
346
|
-
const { execSync } = await import("node:child_process");
|
|
347
|
-
const { statSync, writeFileSync } = await import("node:fs");
|
|
348
|
-
const { uploadToCdn } = await import("./cdn.mjs");
|
|
349
|
-
const { buildHeaders, BASE_URL: baseUrl } = await import("./weixin.mjs");
|
|
350
|
-
|
|
351
|
-
// 下载或使用本地文件
|
|
352
|
-
let audioFile = audioSrc;
|
|
353
|
-
if (audioSrc.startsWith("http://") || audioSrc.startsWith("https://")) {
|
|
354
|
-
const resp = await fetch(audioSrc);
|
|
355
|
-
if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
|
|
356
|
-
writeFileSync("/tmp/wxta_audio_in.mp3", Buffer.from(await resp.arrayBuffer()));
|
|
357
|
-
audioFile = "/tmp/wxta_audio_in.mp3";
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// 转码: audio → PCM(16kHz) → SILK
|
|
361
|
-
execSync(`ffmpeg -y -i "${audioFile}" -ar 16000 -ac 1 -f s16le /tmp/wxta_audio.pcm 2>/dev/null`);
|
|
362
|
-
execSync(`python3 -c "import pilk; pilk.encode('/tmp/wxta_audio.pcm', '/tmp/wxta_audio.silk', pcm_rate=16000, tencent=True)"`);
|
|
363
|
-
const pcmSize = statSync("/tmp/wxta_audio.pcm").size;
|
|
364
|
-
const durationMs = Math.round((pcmSize / 32000) * 1000);
|
|
365
|
-
|
|
366
|
-
// CDN 上传 + 发送语音(与"语音测试"相同格式)
|
|
367
|
-
const cdn = await uploadToCdn("/tmp/wxta_audio.silk", from, creds.token, 4);
|
|
368
|
-
const aesKeyB64 = Buffer.from(cdn.aeskey).toString("base64");
|
|
369
|
-
const crypto = await import("node:crypto");
|
|
370
|
-
|
|
371
|
-
const body = JSON.stringify({
|
|
372
|
-
msg: {
|
|
373
|
-
from_user_id: "", to_user_id: from,
|
|
374
|
-
client_id: crypto.randomUUID(),
|
|
375
|
-
message_type: 2, message_state: 2,
|
|
376
|
-
item_list: [{
|
|
377
|
-
type: 3,
|
|
378
|
-
voice_item: {
|
|
379
|
-
media: {
|
|
380
|
-
encrypt_query_param: cdn.downloadParam,
|
|
381
|
-
aes_key: aesKeyB64,
|
|
382
|
-
},
|
|
383
|
-
encode_type: 4,
|
|
384
|
-
bits_per_sample: 16,
|
|
385
|
-
sample_rate: 16000,
|
|
386
|
-
playtime: durationMs,
|
|
387
|
-
},
|
|
388
|
-
}],
|
|
389
|
-
context_token: contextToken,
|
|
390
|
-
},
|
|
391
|
-
base_info: {},
|
|
392
|
-
});
|
|
393
|
-
await fetch(`${baseUrl}/ilink/bot/sendmessage`, {
|
|
394
|
-
method: "POST",
|
|
395
|
-
headers: buildHeaders(creds.token, body),
|
|
396
|
-
body,
|
|
397
|
-
});
|
|
398
|
-
console.log(pc.green(`→ [语音] 已发送 (${durationMs}ms)`));
|
|
399
|
-
if (textPart) await sendMessage(creds.token, from, agentTag + stripMarkdown(textPart), contextToken);
|
|
400
|
-
} catch (err) {
|
|
401
|
-
console.error(pc.red(` 语音发送失败: ${err.message}`));
|
|
402
|
-
await sendMessage(creds.token, from, agentTag + reply.replace(/\[audio:.*?\]/g, "").trim() || reply, contextToken);
|
|
403
|
-
}
|
|
404
|
-
} else if (imageMatch) {
|
|
405
|
-
// Agent 回复了图片 URL → 直接发到微信
|
|
406
|
-
const imageUrl = imageMatch[1];
|
|
407
|
-
const textPart = reply.replace(/!\[.*?\]\(https?:\/\/[^\s)]+\)/g, "").trim();
|
|
408
|
-
console.log(pc.green(`→ [${targetAgent}] [图片] ${imageUrl.slice(0, 60)}`));
|
|
409
|
-
try {
|
|
410
|
-
if (textPart) await sendMessage(creds.token, from, agentTag + stripMarkdown(textPart), contextToken);
|
|
411
|
-
await sendImageByUrl(creds.token, from, contextToken, imageUrl);
|
|
412
|
-
} catch (err) {
|
|
413
|
-
console.error(pc.red(` 图片发送失败: ${err.message}`));
|
|
414
|
-
await sendMessage(creds.token, from, agentTag + reply, contextToken);
|
|
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
|
-
}
|
|
455
|
-
} else {
|
|
456
|
-
// 纯文本回复
|
|
457
|
-
console.log(pc.green(`→ [${targetAgent}] ${reply.slice(0, 80)}${reply.length > 80 ? "..." : ""}`));
|
|
458
|
-
await sendMessage(creds.token, from, agentTag + stripMarkdown(reply), contextToken);
|
|
459
|
-
}
|
|
450
|
+
await sendContent(from, reply, agentTag);
|
|
460
451
|
} catch (err) {
|
|
461
452
|
if (typing) typing.onCleanup();
|
|
462
453
|
console.error(pc.red(` ${targetAgent} 错误: ${err.message}`));
|
|
@@ -471,6 +462,7 @@ export async function start(agents, defaultAgent) {
|
|
|
471
462
|
};
|
|
472
463
|
|
|
473
464
|
process.on("SIGINT", () => {
|
|
465
|
+
httpServer.close();
|
|
474
466
|
console.log(pc.dim("\n桥已停止"));
|
|
475
467
|
process.exit(0);
|
|
476
468
|
});
|
package/cli/cdn.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export async function downloadMediaToFile(encryptQueryParam, aesKeyBase64, ext =
|
|
|
80
80
|
*/
|
|
81
81
|
export async function uploadImageWithThumb(filePath, toUserId, token) {
|
|
82
82
|
const { buildHeaders, BASE_URL } = await import("./weixin.mjs");
|
|
83
|
-
const {
|
|
83
|
+
const { execFileSync } = await import("child_process");
|
|
84
84
|
|
|
85
85
|
const plaintext = await readFile(filePath);
|
|
86
86
|
const rawsize = plaintext.length;
|
|
@@ -92,7 +92,7 @@ export async function uploadImageWithThumb(filePath, toUserId, token) {
|
|
|
92
92
|
// 生成缩略图
|
|
93
93
|
const thumbPath = `/tmp/wxta_thumb_${Date.now()}.jpg`;
|
|
94
94
|
try {
|
|
95
|
-
|
|
95
|
+
execFileSync("sips", ["--resampleWidth", "120", filePath, "--out", thumbPath], { stdio: "ignore" });
|
|
96
96
|
} catch {
|
|
97
97
|
// sips 失败时用原图当缩略图
|
|
98
98
|
await writeFile(thumbPath, plaintext);
|
|
@@ -102,7 +102,7 @@ export async function uploadImageWithThumb(filePath, toUserId, token) {
|
|
|
102
102
|
// 获取缩略图尺寸
|
|
103
103
|
let thumbWidth = 120, thumbHeight = 120;
|
|
104
104
|
try {
|
|
105
|
-
const sipsOut =
|
|
105
|
+
const sipsOut = execFileSync("sips", ["-g", "pixelWidth", "-g", "pixelHeight", thumbPath], { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
|
|
106
106
|
const wm = sipsOut.match(/pixelWidth:\s*(\d+)/);
|
|
107
107
|
const hm = sipsOut.match(/pixelHeight:\s*(\d+)/);
|
|
108
108
|
if (wm) thumbWidth = parseInt(wm[1]);
|
|
@@ -183,7 +183,7 @@ export async function uploadImageWithThumb(filePath, toUserId, token) {
|
|
|
183
183
|
*/
|
|
184
184
|
export async function uploadVideoWithThumb(filePath, toUserId, token) {
|
|
185
185
|
const { buildHeaders, BASE_URL } = await import("./weixin.mjs");
|
|
186
|
-
const {
|
|
186
|
+
const { execFileSync } = await import("child_process");
|
|
187
187
|
|
|
188
188
|
const plaintext = await readFile(filePath);
|
|
189
189
|
const rawsize = plaintext.length;
|
|
@@ -195,14 +195,14 @@ export async function uploadVideoWithThumb(filePath, toUserId, token) {
|
|
|
195
195
|
// 获取视频时长
|
|
196
196
|
let playLength = 10;
|
|
197
197
|
try {
|
|
198
|
-
const dur =
|
|
198
|
+
const dur = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { encoding: "utf-8" }).trim();
|
|
199
199
|
playLength = Math.round(parseFloat(dur));
|
|
200
200
|
} catch {}
|
|
201
201
|
|
|
202
202
|
// 生成缩略图(第一帧)
|
|
203
203
|
const thumbPath = `/tmp/wxta_video_thumb_${Date.now()}.jpg`;
|
|
204
204
|
try {
|
|
205
|
-
|
|
205
|
+
execFileSync("ffmpeg", ["-y", "-i", filePath, "-vframes", "1", "-vf", "scale=224:-1", "-q:v", "5", thumbPath], { stdio: "ignore" });
|
|
206
206
|
} catch {
|
|
207
207
|
// ffmpeg 失败时创建空白缩略图(sendmessage 仍需 thumb 注册)
|
|
208
208
|
const { writeFileSync } = await import("node:fs");
|
|
@@ -213,7 +213,7 @@ export async function uploadVideoWithThumb(filePath, toUserId, token) {
|
|
|
213
213
|
// 缩略图尺寸
|
|
214
214
|
let thumbWidth = 224, thumbHeight = 224;
|
|
215
215
|
try {
|
|
216
|
-
const sipsOut =
|
|
216
|
+
const sipsOut = execFileSync("sips", ["-g", "pixelWidth", "-g", "pixelHeight", thumbPath], { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
|
|
217
217
|
const wm = sipsOut.match(/pixelWidth:\s*(\d+)/);
|
|
218
218
|
const hm = sipsOut.match(/pixelHeight:\s*(\d+)/);
|
|
219
219
|
if (wm) thumbWidth = parseInt(wm[1]);
|
package/cli/weixin.mjs
CHANGED
|
@@ -254,34 +254,6 @@ export async function sendImageByUrl(token, to, contextToken, imageUrl) {
|
|
|
254
254
|
);
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
/**
|
|
258
|
-
* 发送语音消息(base64 音频数据)
|
|
259
|
-
*/
|
|
260
|
-
export async function sendVoiceMessage(token, to, contextToken, audioBase64, durationSec) {
|
|
261
|
-
await apiPost(
|
|
262
|
-
"ilink/bot/sendmessage",
|
|
263
|
-
{
|
|
264
|
-
msg: {
|
|
265
|
-
from_user_id: "",
|
|
266
|
-
to_user_id: to,
|
|
267
|
-
client_id: crypto.randomUUID(),
|
|
268
|
-
message_type: 2,
|
|
269
|
-
message_state: 2,
|
|
270
|
-
item_list: [{
|
|
271
|
-
type: 3, // VOICE
|
|
272
|
-
voice_item: {
|
|
273
|
-
url: `data:audio/mpeg;base64,${audioBase64}`,
|
|
274
|
-
duration: durationSec || 5,
|
|
275
|
-
},
|
|
276
|
-
}],
|
|
277
|
-
context_token: contextToken,
|
|
278
|
-
},
|
|
279
|
-
base_info: {},
|
|
280
|
-
},
|
|
281
|
-
token,
|
|
282
|
-
API_TIMEOUT_MS
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
257
|
|
|
286
258
|
|
|
287
259
|
/**
|