wechat-to-anything 0.6.1 → 0.6.2
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 +15 -7
- package/examples/video-test-local.mjs +42 -131
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
>
|
|
5
5
|
> ⭐ 如果这个项目对你有帮助,请给个 Star!本项目仅用于技术学习和交流,开源不易。
|
|
6
6
|
|
|
7
|
-
微信双向支持 Agent
|
|
7
|
+
微信双向支持 Agent 多种模态消息发送和接收,支持文本、图片、语音、视频、文件。
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<img src="docs/wechat-image-send.png" width="250" alt="发送图片给 Agent 识别" />
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
|
|
25
25
|
### 多种模态支持
|
|
26
26
|
|
|
27
|
-
| 方向 | 图片 | 语音 | 文件 |
|
|
28
|
-
|
|
29
|
-
| **微信 → Agent** | ✅ 自动识别 | ✅ 语音转文字 | ✅ 提取文本 |
|
|
30
|
-
| **Agent → 微信** | ✅ 自动发图 | ✅ 语音消息 | 文本回复 |
|
|
27
|
+
| 方向 | 图片 | 语音 | 视频 | 文件 |
|
|
28
|
+
|---|---|---|---|---|
|
|
29
|
+
| **微信 → Agent** | ✅ 自动识别 | ✅ 语音转文字 | ✅ 自动接收 | ✅ 提取文本 |
|
|
30
|
+
| **Agent → 微信** | ✅ 自动发图 | ✅ 语音消息 | ✅ 视频消息 | 文本回复 |
|
|
31
31
|
|
|
32
32
|
## 前置条件
|
|
33
33
|
|
|
@@ -135,13 +135,21 @@ npx wechat-to-anything \
|
|
|
135
135
|
```python
|
|
136
136
|
@app.post("/v1/chat/completions")
|
|
137
137
|
def chat(request):
|
|
138
|
-
message = request.json["messages"][-1]["content"]
|
|
139
138
|
audio_path = your_tts(message) # → /tmp/reply.mp3
|
|
140
139
|
reply = f"[audio:{audio_path}]\n这是文字版内容"
|
|
141
140
|
return {"choices": [{"message": {"role": "assistant", "content": reply}}]}
|
|
142
141
|
```
|
|
143
142
|
|
|
144
|
-
|
|
143
|
+
**视频(Agent → 微信)**:回复中包含 `[video:path 或 URL]` 自动发视频消息(含缩略图)。需要 `ffmpeg`。
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
@app.post("/v1/chat/completions")
|
|
147
|
+
def chat(request):
|
|
148
|
+
reply = "[video:/tmp/demo.mp4]\n这是视频描述"
|
|
149
|
+
return {"choices": [{"message": {"role": "assistant", "content": reply}}]}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
> 示例:[examples/image-test.mjs](examples/image-test.mjs) · [examples/voice-test.mjs](examples/voice-test.mjs) · [examples/video-test-local.mjs](examples/video-test-local.mjs)
|
|
145
153
|
|
|
146
154
|
## 凭证
|
|
147
155
|
|
|
@@ -1,146 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 视频发送测试脚本
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - thumb 和 video 共享 aes_key
|
|
9
|
-
* - 有 play_length, thumb_media 完整字段
|
|
4
|
+
* 用法: node examples/video-test-local.mjs [视频文件路径]
|
|
5
|
+
*
|
|
6
|
+
* 该脚本直接通过 CDN 上传视频文件,发送到自己的微信(用于测试)。
|
|
7
|
+
* 需要先扫码登录获取 credentials。
|
|
10
8
|
*/
|
|
11
|
-
import { readFileSync
|
|
12
|
-
import {
|
|
13
|
-
import { homedir, tmpdir } from "os";
|
|
14
|
-
import { join } from "path";
|
|
15
|
-
import { execSync } from "child_process";
|
|
16
|
-
import { createHash, randomBytes } from "crypto";
|
|
17
|
-
import crypto from "crypto";
|
|
9
|
+
import { readFileSync } from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
18
11
|
|
|
19
12
|
const creds = JSON.parse(readFileSync(homedir() + "/.wechat-to-anything/credentials.json", "utf-8"));
|
|
20
|
-
const videoPath = process.argv[2]
|
|
13
|
+
const videoPath = process.argv[2];
|
|
14
|
+
if (!videoPath) {
|
|
15
|
+
console.error("用法: node examples/video-test-local.mjs <视频文件路径>");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
21
18
|
|
|
22
|
-
const {
|
|
19
|
+
const { uploadVideoWithThumb } = await import("../cli/cdn.mjs");
|
|
23
20
|
const { getUpdates, buildHeaders, BASE_URL } = await import("../cli/weixin.mjs");
|
|
21
|
+
import crypto from "crypto";
|
|
24
22
|
|
|
25
|
-
// 获取
|
|
23
|
+
// 获取 context_token
|
|
26
24
|
const msgs = await getUpdates(creds.token);
|
|
27
25
|
const contextToken = msgs?.context_token || "";
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const rawfilemd5 = createHash("md5").update(plaintext).digest("hex");
|
|
33
|
-
const filesize = aesEcbPaddedSize(rawsize);
|
|
34
|
-
const filekey = randomBytes(16).toString("hex");
|
|
35
|
-
const aeskey = randomBytes(16);
|
|
36
|
-
|
|
37
|
-
let playLength = 10;
|
|
38
|
-
try {
|
|
39
|
-
const dur = execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${videoPath}"`, { encoding: "utf-8" }).trim();
|
|
40
|
-
playLength = Math.round(parseFloat(dur));
|
|
41
|
-
} catch {}
|
|
42
|
-
|
|
43
|
-
// 缩略图
|
|
44
|
-
const thumbPath = join(tmpdir(), `wx-thumb-${Date.now()}.jpg`);
|
|
45
|
-
execSync(`ffmpeg -y -i "${videoPath}" -vframes 1 -vf "scale=224:-1" -q:v 5 "${thumbPath}" 2>/dev/null`);
|
|
46
|
-
const thumb = await readFile(thumbPath);
|
|
47
|
-
let thumbWidth = 224, thumbHeight = 486;
|
|
48
|
-
try {
|
|
49
|
-
const s = execSync(`sips -g pixelWidth -g pixelHeight "${thumbPath}"`).toString();
|
|
50
|
-
const w = s.match(/pixelWidth:\s*(\d+)/); const h = s.match(/pixelHeight:\s*(\d+)/);
|
|
51
|
-
if (w) thumbWidth = parseInt(w[1]); if (h) thumbHeight = parseInt(h[1]);
|
|
52
|
-
} catch {}
|
|
53
|
-
|
|
54
|
-
console.log("视频:", rawsize, "→", filesize, "bytes | md5:", rawfilemd5);
|
|
55
|
-
console.log("时长:", playLength, "s | 缩略图:", thumb.length, "bytes", thumbWidth, "x", thumbHeight);
|
|
56
|
-
|
|
57
|
-
// aes_key 编码: hex string → UTF-8 bytes → base64 = 44 chars(和微信原始格式一致)
|
|
58
|
-
const aesKeyHex = aeskey.toString("hex");
|
|
59
|
-
const aesKey44 = Buffer.from(aesKeyHex).toString("base64");
|
|
60
|
-
console.log("aes_key:", aesKey44, "(", aesKey44.length, "chars)");
|
|
61
|
-
|
|
62
|
-
// getUploadUrl (bundled thumb, no_need_thumb=false)
|
|
63
|
-
const uploadBody = JSON.stringify({
|
|
64
|
-
filekey,
|
|
65
|
-
media_type: 2,
|
|
66
|
-
to_user_id: creds.userId,
|
|
67
|
-
rawsize,
|
|
68
|
-
rawfilemd5,
|
|
69
|
-
filesize,
|
|
70
|
-
thumb_rawsize: thumb.length,
|
|
71
|
-
thumb_rawfilemd5: createHash("md5").update(thumb).digest("hex"),
|
|
72
|
-
thumb_filesize: aesEcbPaddedSize(thumb.length),
|
|
73
|
-
no_need_thumb: false,
|
|
74
|
-
aeskey: aesKeyHex,
|
|
75
|
-
base_info: {},
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const uploadRes = await fetch(`${BASE_URL}/ilink/bot/getuploadurl`, {
|
|
79
|
-
method: "POST",
|
|
80
|
-
headers: buildHeaders(creds.token, uploadBody),
|
|
81
|
-
body: uploadBody,
|
|
82
|
-
});
|
|
83
|
-
const uploadJson = await uploadRes.json();
|
|
84
|
-
console.log("\ngetUploadUrl:", uploadJson.upload_param ? "✅" : "❌", "thumb:", uploadJson.thumb_upload_param ? "✅" : "❌");
|
|
27
|
+
console.log("上传视频...");
|
|
28
|
+
const cdn = await uploadVideoWithThumb(videoPath, creds.userId, creds.token);
|
|
29
|
+
console.log("✅ 上传完成 | 时长:", cdn.playLength, "s | 大小:", cdn.fileSizeCiphertext, "bytes");
|
|
85
30
|
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
const videoUrl = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadJson.upload_param)}&filekey=${encodeURIComponent(filekey)}`;
|
|
89
|
-
const videoRes = await fetch(videoUrl, {
|
|
90
|
-
method: "POST",
|
|
91
|
-
headers: { "Content-Type": "application/octet-stream" },
|
|
92
|
-
body: new Uint8Array(videoCipher),
|
|
93
|
-
});
|
|
94
|
-
const videoDP = videoRes.headers.get("x-encrypted-query-param") || videoRes.headers.get("x-encrypted-param");
|
|
95
|
-
console.log("视频 CDN:", videoRes.status, "(400=probe error, 可忽略) dp:", videoDP ? "✅" : "❌");
|
|
31
|
+
// aes_key: hex→UTF8→base64 = 44 chars(微信格式)
|
|
32
|
+
const aesKeyB64 = Buffer.from(cdn.aeskey).toString("base64");
|
|
96
33
|
|
|
97
|
-
// 上传缩略图
|
|
98
|
-
let thumbDP = null;
|
|
99
|
-
if (uploadJson.thumb_upload_param) {
|
|
100
|
-
const thumbCipher = encryptAesEcb(thumb, aeskey);
|
|
101
|
-
const thumbUrl = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadJson.thumb_upload_param)}&filekey=${encodeURIComponent(filekey)}`;
|
|
102
|
-
const thumbRes = await fetch(thumbUrl, {
|
|
103
|
-
method: "POST",
|
|
104
|
-
headers: { "Content-Type": "application/octet-stream" },
|
|
105
|
-
body: new Uint8Array(thumbCipher),
|
|
106
|
-
});
|
|
107
|
-
thumbDP = thumbRes.headers.get("x-encrypted-query-param") || thumbRes.headers.get("x-encrypted-param");
|
|
108
|
-
console.log("缩略图 CDN:", thumbRes.status, "dp:", thumbDP ? "✅" : "❌");
|
|
109
|
-
}
|
|
110
|
-
try { unlinkSync(thumbPath); } catch {}
|
|
111
|
-
|
|
112
|
-
if (!videoDP) { console.error("❌ 无 videoDP"); process.exit(1); }
|
|
113
|
-
|
|
114
|
-
// ═══ 构造消息 — 完全匹配微信原始格式 ═══
|
|
115
34
|
const videoItem = {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
encrypt_query_param: videoDP,
|
|
120
|
-
aes_key: aesKey44,
|
|
121
|
-
// 没有 encrypt_type — 微信原始格式没有
|
|
122
|
-
},
|
|
123
|
-
video_size: filesize,
|
|
124
|
-
play_length: playLength,
|
|
125
|
-
video_md5: rawfilemd5, // ← 之前一直少这个!
|
|
126
|
-
...(thumbDP ? {
|
|
127
|
-
thumb_media: {
|
|
128
|
-
encrypt_query_param: thumbDP,
|
|
129
|
-
aes_key: aesKey44, // 共享 key
|
|
130
|
-
// 没有 encrypt_type
|
|
131
|
-
},
|
|
132
|
-
thumb_size: aesEcbPaddedSize(thumb.length),
|
|
133
|
-
thumb_height: thumbHeight,
|
|
134
|
-
thumb_width: thumbWidth,
|
|
135
|
-
} : {}),
|
|
35
|
+
media: {
|
|
36
|
+
encrypt_query_param: cdn.downloadParam,
|
|
37
|
+
aes_key: aesKeyB64,
|
|
136
38
|
},
|
|
39
|
+
video_size: cdn.fileSizeCiphertext,
|
|
40
|
+
play_length: cdn.playLength,
|
|
41
|
+
video_md5: cdn.videoMd5,
|
|
137
42
|
};
|
|
138
43
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
44
|
+
if (cdn.thumbDownloadParam) {
|
|
45
|
+
videoItem.thumb_media = {
|
|
46
|
+
encrypt_query_param: cdn.thumbDownloadParam,
|
|
47
|
+
aes_key: aesKeyB64,
|
|
48
|
+
};
|
|
49
|
+
videoItem.thumb_size = cdn.thumbSizeCiphertext;
|
|
50
|
+
videoItem.thumb_width = cdn.thumbWidth;
|
|
51
|
+
videoItem.thumb_height = cdn.thumbHeight;
|
|
52
|
+
}
|
|
144
53
|
|
|
145
54
|
const body = JSON.stringify({
|
|
146
55
|
msg: {
|
|
@@ -149,8 +58,8 @@ const body = JSON.stringify({
|
|
|
149
58
|
client_id: crypto.randomUUID(),
|
|
150
59
|
message_type: 2,
|
|
151
60
|
message_state: 2,
|
|
152
|
-
item_list: [videoItem],
|
|
153
|
-
context_token:
|
|
61
|
+
item_list: [{ type: 5, video_item: videoItem }],
|
|
62
|
+
context_token: contextToken,
|
|
154
63
|
},
|
|
155
64
|
base_info: {},
|
|
156
65
|
});
|
|
@@ -161,6 +70,8 @@ const resp = await fetch(`${BASE_URL}/ilink/bot/sendmessage`, {
|
|
|
161
70
|
body,
|
|
162
71
|
});
|
|
163
72
|
const text = await resp.text();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
else
|
|
73
|
+
if (text === "{}" || text === "") {
|
|
74
|
+
console.log("✅ 发送成功!请检查微信");
|
|
75
|
+
} else {
|
|
76
|
+
console.log("❌ 发送失败:", text);
|
|
77
|
+
}
|