wechat-to-anything 0.6.5 → 0.6.6
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.en.md +30 -1
- package/README.md +30 -1
- package/bin/cli.mjs +15 -1
- package/cli/bridge.mjs +178 -130
- package/package.json +1 -1
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,177 @@ 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 { execSync } = 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
|
+
execSync(`ffmpeg -y -i "${audioFile}" -ar 16000 -ac 1 -f s16le /tmp/wxta_audio.pcm 2>/dev/null`);
|
|
145
|
+
execSync(`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
|
+
for await (const chunk of req) body += chunk;
|
|
244
|
+
try {
|
|
245
|
+
const { to, content } = JSON.parse(body);
|
|
246
|
+
if (!to || !content) {
|
|
247
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
248
|
+
res.end(JSON.stringify({ error: "missing 'to' or 'content'" }));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
console.log(pc.cyan(`← [API] → ${to.slice(0, 12)}...: ${content.slice(0, 60)}`));
|
|
252
|
+
await sendContent(to, content);
|
|
253
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
254
|
+
res.end("{}");
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(pc.red(` /api/send 错误: ${err.message}`));
|
|
257
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
258
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
264
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
httpServer.on("error", (err) => {
|
|
268
|
+
if (err.code === "EADDRINUSE") {
|
|
269
|
+
console.warn(pc.yellow(`⚠️ 端口 ${port} 已被占用,API 未启动(bridge 继续运行)`));
|
|
270
|
+
} else {
|
|
271
|
+
console.error(pc.red(`API 服务器错误: ${err.message}`));
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
httpServer.listen(port, () => {
|
|
275
|
+
console.log(pc.green(`📡 API 已启动: http://localhost:${port}/api/send`));
|
|
276
|
+
});
|
|
277
|
+
|
|
106
278
|
const loop = async () => {
|
|
107
279
|
while (true) {
|
|
108
280
|
try {
|
|
@@ -117,6 +289,9 @@ export async function start(agents, defaultAgent) {
|
|
|
117
289
|
const contextToken = msg.context_token || "";
|
|
118
290
|
if (!from) continue;
|
|
119
291
|
|
|
292
|
+
// 缓存 contextToken 供 /api/send 使用
|
|
293
|
+
if (contextToken) userContextTokens.set(from, contextToken);
|
|
294
|
+
|
|
120
295
|
const text = extractText(msg);
|
|
121
296
|
const media = extractMedia(msg);
|
|
122
297
|
|
|
@@ -328,135 +503,7 @@ export async function start(agents, defaultAgent) {
|
|
|
328
503
|
const reply = await callAgentAuto(agentUrl, agentMessages, from);
|
|
329
504
|
if (typing) typing.onIdle();
|
|
330
505
|
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
|
-
}
|
|
506
|
+
await sendContent(from, reply, agentTag);
|
|
460
507
|
} catch (err) {
|
|
461
508
|
if (typing) typing.onCleanup();
|
|
462
509
|
console.error(pc.red(` ${targetAgent} 错误: ${err.message}`));
|
|
@@ -471,6 +518,7 @@ export async function start(agents, defaultAgent) {
|
|
|
471
518
|
};
|
|
472
519
|
|
|
473
520
|
process.on("SIGINT", () => {
|
|
521
|
+
httpServer.close();
|
|
474
522
|
console.log(pc.dim("\n桥已停止"));
|
|
475
523
|
process.exit(0);
|
|
476
524
|
});
|