chatccc 0.2.62 → 0.2.63
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/config.sample.json +1 -1
- package/im-skills/wechat-file-skill/receive-send-file.md +42 -0
- package/im-skills/wechat-file-skill/send-file.mjs +101 -0
- package/im-skills/wechat-file-skill/skill.md +11 -0
- package/im-skills/{wechat-skill → wechat-image-skill}/receive-send-image.md +39 -39
- package/im-skills/{wechat-skill → wechat-image-skill}/send-image.mjs +84 -80
- package/im-skills/{wechat-skill → wechat-image-skill}/skill.md +11 -11
- package/im-skills/wechat-video-skill/receive-send-video.md +41 -0
- package/im-skills/wechat-video-skill/send-video.mjs +84 -0
- package/im-skills/wechat-video-skill/skill.md +11 -0
- package/package.json +1 -1
- package/src/__tests__/im-skills.test.ts +57 -2
- package/src/__tests__/orchestrator.test.ts +1 -1
- package/src/__tests__/wechat-platform.test.ts +65 -0
- package/src/orchestrator.ts +2 -2
- package/src/session.ts +7 -3
- package/src/wechat-platform.ts +170 -30
package/config.sample.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Receiving & Sending Files (WeChat)
|
|
2
|
+
|
|
3
|
+
## Receive Files
|
|
4
|
+
|
|
5
|
+
Files sent to the bot are automatically downloaded to `~/.chatccc/files/downloads/` with the `wx_` filename prefix.
|
|
6
|
+
|
|
7
|
+
The message text you receive will include the downloaded path in the format:
|
|
8
|
+
```text
|
|
9
|
+
[文件] C:\Users\<user>\.chatccc\files\downloads\wx_<key>_<filename>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You can read the file at that path to inspect, parse, transform, or forward it.
|
|
13
|
+
|
|
14
|
+
## Send Files
|
|
15
|
+
|
|
16
|
+
### Script (recommended)
|
|
17
|
+
```bash
|
|
18
|
+
node "{{wechat_send_file_script}}" --path "<absolute file path>" --caption "<optional caption>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The script reads the current WeChat session's auth token, chat ID, and context token from `~/.chatccc/state/ilink-auth.json`, then sends the file to the most recent chat.
|
|
22
|
+
|
|
23
|
+
### Direct usage in test scripts
|
|
24
|
+
|
|
25
|
+
The underlying SDK call is:
|
|
26
|
+
```ts
|
|
27
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
28
|
+
const wire = new OpenIlinkWire(token, { base_url: baseUrl });
|
|
29
|
+
await wire.sendMediaFile(chatId, contextToken, fileBuffer, "report.pdf", "caption");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For non-image and non-video MIME types, the SDK routes the upload as a file attachment.
|
|
33
|
+
|
|
34
|
+
### Rules
|
|
35
|
+
|
|
36
|
+
- Save or choose a local file first.
|
|
37
|
+
- Use an absolute local path.
|
|
38
|
+
- Supported formats: .txt, .pdf, .doc, .docx, .xls, .xlsx, .csv, .ppt, .pptx, .zip, .tar, .gz.
|
|
39
|
+
- Max file size: 30MB.
|
|
40
|
+
- Use the dedicated image or video skill for images and videos.
|
|
41
|
+
- Only send a file when the user asked for one or when it materially helps the answer.
|
|
42
|
+
- File sending counts as an outgoing WeChat message. Avoid repeated unsolicited sends.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
7
|
+
|
|
8
|
+
const ILINK_AUTH_PATH = join(homedir(), ".chatccc", "state", "ilink-auth.json");
|
|
9
|
+
const MAX_FILE_BYTES = 30 * 1024 * 1024;
|
|
10
|
+
const ALLOWED_EXTS = new Set([
|
|
11
|
+
".txt",
|
|
12
|
+
".pdf",
|
|
13
|
+
".doc",
|
|
14
|
+
".docx",
|
|
15
|
+
".xls",
|
|
16
|
+
".xlsx",
|
|
17
|
+
".csv",
|
|
18
|
+
".ppt",
|
|
19
|
+
".pptx",
|
|
20
|
+
".zip",
|
|
21
|
+
".tar",
|
|
22
|
+
".gz",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const result = {};
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const key = argv[i];
|
|
29
|
+
if (!key.startsWith("--")) continue;
|
|
30
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
31
|
+
result[key.slice(2)] = value;
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function usage() {
|
|
37
|
+
console.error(`Usage:
|
|
38
|
+
node ${basename(process.argv[1])} --path <absolute file path> [--caption <text>]`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
const args = parseArgs(process.argv.slice(2));
|
|
43
|
+
const filePath = args.path;
|
|
44
|
+
const caption = args.caption || "";
|
|
45
|
+
|
|
46
|
+
if (!filePath) {
|
|
47
|
+
usage();
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
if (!isAbsolute(filePath)) {
|
|
51
|
+
console.error("File path must be absolute.");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ext = extname(filePath).toLowerCase();
|
|
56
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
57
|
+
console.error(`Unsupported file extension: ${ext || "(none)"}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const st = statSync(filePath);
|
|
62
|
+
if (!st.isFile()) {
|
|
63
|
+
console.error("File path is not a file");
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
if (st.size <= 0) {
|
|
67
|
+
console.error("File is empty");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
71
|
+
console.error("File exceeds 30MB limit");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
76
|
+
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
77
|
+
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
82
|
+
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
83
|
+
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fileData = readFileSync(filePath);
|
|
88
|
+
const fileName = basename(filePath);
|
|
89
|
+
|
|
90
|
+
const wire = new OpenIlinkWire(snap.token, {
|
|
91
|
+
base_url: snap.baseUrl,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, fileData, fileName, caption);
|
|
95
|
+
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main().catch((err) => {
|
|
99
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wechat-file-skill
|
|
3
|
+
description: WeChat iLink local skill for sending and receiving files.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Current working directory: {{cwd}}
|
|
7
|
+
|
|
8
|
+
WeChat files are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper script below instead.
|
|
9
|
+
|
|
10
|
+
- **Receive files**: Files sent to the bot are automatically downloaded to `~/.chatccc/files/downloads/` with the filename prefix `wx_`. The message text will include the local path as `[文件] <absolute path>`.
|
|
11
|
+
- **Send files**: Use the send-file helper script — read `{{im_skills_cache_dir}}/wechat-file-skill/receive-send-file.md`
|
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
# Receiving & Sending Images (WeChat)
|
|
2
|
-
|
|
3
|
-
## Receive Images
|
|
4
|
-
|
|
5
|
-
Images sent to the bot are
|
|
6
|
-
|
|
7
|
-
The message text you receive will include the downloaded path in the format:
|
|
8
|
-
```
|
|
9
|
-
[图片] C:\Users
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
You can read the image file at that path to understand what the user sent.
|
|
13
|
-
|
|
14
|
-
## Send Images
|
|
15
|
-
|
|
16
|
-
### Script (recommended)
|
|
17
|
-
```bash
|
|
18
|
-
node "{{wechat_send_image_script}}" --path "<absolute image path>" --caption "<optional caption>"
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
The script reads the current WeChat session's auth token, chat ID, and context token from `~/.chatccc/state/ilink-auth.json`, then sends the image to the most recent chat.
|
|
22
|
-
|
|
23
|
-
### Direct usage in test scripts
|
|
24
|
-
|
|
25
|
-
The underlying SDK call is:
|
|
26
|
-
```ts
|
|
27
|
-
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
28
|
-
const wire = new OpenIlinkWire(token, { base_url: baseUrl });
|
|
29
|
-
await wire.sendMediaFile(chatId, contextToken, imageBuffer, "filename.png", "caption");
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Rules
|
|
33
|
-
|
|
34
|
-
- Save or choose a local image file first.
|
|
35
|
-
- Use an absolute local path.
|
|
36
|
-
- Supported formats: .png, .jpg, .jpeg, .webp, .gif, .bmp.
|
|
37
|
-
- Max image size: 10MB
|
|
38
|
-
- Only send an image when the user asked for one or when it materially helps the answer.
|
|
39
|
-
-
|
|
1
|
+
# Receiving & Sending Images (WeChat)
|
|
2
|
+
|
|
3
|
+
## Receive Images
|
|
4
|
+
|
|
5
|
+
Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/` with the `wx_` filename prefix.
|
|
6
|
+
|
|
7
|
+
The message text you receive will include the downloaded path in the format:
|
|
8
|
+
```text
|
|
9
|
+
[图片] C:\Users\<user>\.chatccc\images\downloads\wx_<key>.png
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You can read the image file at that path to understand what the user sent.
|
|
13
|
+
|
|
14
|
+
## Send Images
|
|
15
|
+
|
|
16
|
+
### Script (recommended)
|
|
17
|
+
```bash
|
|
18
|
+
node "{{wechat_send_image_script}}" --path "<absolute image path>" --caption "<optional caption>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The script reads the current WeChat session's auth token, chat ID, and context token from `~/.chatccc/state/ilink-auth.json`, then sends the image to the most recent chat.
|
|
22
|
+
|
|
23
|
+
### Direct usage in test scripts
|
|
24
|
+
|
|
25
|
+
The underlying SDK call is:
|
|
26
|
+
```ts
|
|
27
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
28
|
+
const wire = new OpenIlinkWire(token, { base_url: baseUrl });
|
|
29
|
+
await wire.sendMediaFile(chatId, contextToken, imageBuffer, "filename.png", "caption");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Rules
|
|
33
|
+
|
|
34
|
+
- Save or choose a local image file first.
|
|
35
|
+
- Use an absolute local path.
|
|
36
|
+
- Supported formats: .png, .jpg, .jpeg, .webp, .gif, .bmp.
|
|
37
|
+
- Max image size: 10MB.
|
|
38
|
+
- Only send an image when the user asked for one or when it materially helps the answer.
|
|
39
|
+
- Image sending counts as an outgoing WeChat message. Avoid repeated unsolicited sends.
|
|
@@ -1,80 +1,84 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
-
import { basename, extname, join } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
|
|
6
|
-
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
7
|
-
|
|
8
|
-
const ILINK_AUTH_PATH = join(homedir(), ".chatccc", "state", "ilink-auth.json");
|
|
9
|
-
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
|
-
const ALLOWED_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
11
|
-
|
|
12
|
-
function parseArgs(argv) {
|
|
13
|
-
const result = {};
|
|
14
|
-
for (let i = 0; i < argv.length; i++) {
|
|
15
|
-
const key = argv[i];
|
|
16
|
-
if (!key.startsWith("--")) continue;
|
|
17
|
-
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
18
|
-
result[key.slice(2)] = value;
|
|
19
|
-
}
|
|
20
|
-
return result;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function usage() {
|
|
24
|
-
console.error(`Usage:
|
|
25
|
-
node ${basename(process.argv[1])} --path <absolute image path> [--caption <text>]`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function main() {
|
|
29
|
-
const args = parseArgs(process.argv.slice(2));
|
|
30
|
-
const imagePath = args.path;
|
|
31
|
-
const caption = args.caption || "";
|
|
32
|
-
|
|
33
|
-
if (!imagePath) {
|
|
34
|
-
usage();
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (st.
|
|
50
|
-
console.error("Image
|
|
51
|
-
process.exit(1);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
console.
|
|
79
|
-
|
|
80
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
7
|
+
|
|
8
|
+
const ILINK_AUTH_PATH = join(homedir(), ".chatccc", "state", "ilink-auth.json");
|
|
9
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const ALLOWED_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const result = {};
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const key = argv[i];
|
|
16
|
+
if (!key.startsWith("--")) continue;
|
|
17
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
18
|
+
result[key.slice(2)] = value;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
console.error(`Usage:
|
|
25
|
+
node ${basename(process.argv[1])} --path <absolute image path> [--caption <text>]`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const args = parseArgs(process.argv.slice(2));
|
|
30
|
+
const imagePath = args.path;
|
|
31
|
+
const caption = args.caption || "";
|
|
32
|
+
|
|
33
|
+
if (!imagePath) {
|
|
34
|
+
usage();
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (!isAbsolute(imagePath)) {
|
|
38
|
+
console.error("Image path must be absolute.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ext = extname(imagePath).toLowerCase();
|
|
43
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
44
|
+
console.error(`Unsupported image extension: ${ext || "(none)"}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const st = statSync(imagePath);
|
|
49
|
+
if (!st.isFile()) {
|
|
50
|
+
console.error("Image path is not a file");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (st.size > MAX_IMAGE_BYTES) {
|
|
54
|
+
console.error("Image file exceeds 10MB limit");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
59
|
+
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
60
|
+
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
65
|
+
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
66
|
+
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const imgData = readFileSync(imagePath);
|
|
71
|
+
const fileName = basename(imagePath);
|
|
72
|
+
|
|
73
|
+
const wire = new OpenIlinkWire(snap.token, {
|
|
74
|
+
base_url: snap.baseUrl,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, imgData, fileName, caption);
|
|
78
|
+
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().catch((err) => {
|
|
82
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: wechat-skill
|
|
3
|
-
description: WeChat iLink local
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Current working directory: {{cwd}}
|
|
7
|
-
|
|
8
|
-
WeChat images are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper
|
|
9
|
-
|
|
10
|
-
- **Receive images**: Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/` with the filename prefix `wx_`. The message text will include the local path as `[图片] <absolute path>`.
|
|
11
|
-
- **Send images**: Use the send-image helper script — read `{{im_skills_cache_dir}}/wechat-skill/receive-send-image.md`
|
|
1
|
+
---
|
|
2
|
+
name: wechat-image-skill
|
|
3
|
+
description: WeChat iLink local skill for sending and receiving images.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Current working directory: {{cwd}}
|
|
7
|
+
|
|
8
|
+
WeChat images are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper script below instead.
|
|
9
|
+
|
|
10
|
+
- **Receive images**: Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/` with the filename prefix `wx_`. The message text will include the local path as `[图片] <absolute path>`.
|
|
11
|
+
- **Send images**: Use the send-image helper script — read `{{im_skills_cache_dir}}/wechat-image-skill/receive-send-image.md`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Receiving & Sending Videos (WeChat)
|
|
2
|
+
|
|
3
|
+
## Receive Videos
|
|
4
|
+
|
|
5
|
+
Videos sent to the bot are automatically downloaded to `~/.chatccc/videos/downloads/` with the `wx_` filename prefix.
|
|
6
|
+
|
|
7
|
+
The message text you receive will include the downloaded path in the format:
|
|
8
|
+
```text
|
|
9
|
+
[视频] C:\Users\<user>\.chatccc\videos\downloads\wx_<key>.mp4
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You can read the video file at that path to inspect, transcode, trim, or forward it.
|
|
13
|
+
|
|
14
|
+
## Send Videos
|
|
15
|
+
|
|
16
|
+
### Script (recommended)
|
|
17
|
+
```bash
|
|
18
|
+
node "{{wechat_send_video_script}}" --path "<absolute video path>" --caption "<optional caption>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The script reads the current WeChat session's auth token, chat ID, and context token from `~/.chatccc/state/ilink-auth.json`, then sends the video to the most recent chat.
|
|
22
|
+
|
|
23
|
+
### Direct usage in test scripts
|
|
24
|
+
|
|
25
|
+
The underlying SDK call is:
|
|
26
|
+
```ts
|
|
27
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
28
|
+
const wire = new OpenIlinkWire(token, { base_url: baseUrl });
|
|
29
|
+
await wire.sendMediaFile(chatId, contextToken, videoBuffer, "filename.mp4", "caption");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The SDK detects video MIME types from the file name and routes `.mp4`, `.mov`, `.avi`, `.mkv`, `.webm`, and `.flv` as videos.
|
|
33
|
+
|
|
34
|
+
### Rules
|
|
35
|
+
|
|
36
|
+
- Save or choose a local video file first.
|
|
37
|
+
- Use an absolute local path.
|
|
38
|
+
- Supported formats: .mp4, .mov, .avi, .mkv, .webm, .flv.
|
|
39
|
+
- Max video size: 30MB.
|
|
40
|
+
- Only send a video when the user asked for one or when it materially helps the answer.
|
|
41
|
+
- Video sending counts as an outgoing WeChat message. Avoid repeated unsolicited sends.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
7
|
+
|
|
8
|
+
const ILINK_AUTH_PATH = join(homedir(), ".chatccc", "state", "ilink-auth.json");
|
|
9
|
+
const MAX_VIDEO_BYTES = 30 * 1024 * 1024;
|
|
10
|
+
const ALLOWED_EXTS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv"]);
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const result = {};
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const key = argv[i];
|
|
16
|
+
if (!key.startsWith("--")) continue;
|
|
17
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
18
|
+
result[key.slice(2)] = value;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
console.error(`Usage:
|
|
25
|
+
node ${basename(process.argv[1])} --path <absolute video path> [--caption <text>]`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const args = parseArgs(process.argv.slice(2));
|
|
30
|
+
const videoPath = args.path;
|
|
31
|
+
const caption = args.caption || "";
|
|
32
|
+
|
|
33
|
+
if (!videoPath) {
|
|
34
|
+
usage();
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
if (!isAbsolute(videoPath)) {
|
|
38
|
+
console.error("Video path must be absolute.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ext = extname(videoPath).toLowerCase();
|
|
43
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
44
|
+
console.error(`Unsupported video extension: ${ext || "(none)"}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const st = statSync(videoPath);
|
|
49
|
+
if (!st.isFile()) {
|
|
50
|
+
console.error("Video path is not a file");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (st.size > MAX_VIDEO_BYTES) {
|
|
54
|
+
console.error("Video file exceeds 30MB limit");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
59
|
+
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
60
|
+
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
65
|
+
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
66
|
+
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const videoData = readFileSync(videoPath);
|
|
71
|
+
const fileName = basename(videoPath);
|
|
72
|
+
|
|
73
|
+
const wire = new OpenIlinkWire(snap.token, {
|
|
74
|
+
base_url: snap.baseUrl,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, videoData, fileName, caption);
|
|
78
|
+
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().catch((err) => {
|
|
82
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wechat-video-skill
|
|
3
|
+
description: WeChat iLink local skill for sending and receiving videos.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Current working directory: {{cwd}}
|
|
7
|
+
|
|
8
|
+
WeChat videos are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper script below instead.
|
|
9
|
+
|
|
10
|
+
- **Receive videos**: Videos sent to the bot are automatically downloaded to `~/.chatccc/videos/downloads/` with the filename prefix `wx_`. The message text will include the local path as `[视频] <absolute path>`.
|
|
11
|
+
- **Send videos**: Use the send-video helper script — read `{{im_skills_cache_dir}}/wechat-video-skill/receive-send-video.md`
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
5
|
|
|
6
|
-
import { buildImSkillsPrompt } from "../im-skills.ts";
|
|
6
|
+
import { buildImSkillsPrompt, exportSkillSubDocs } from "../im-skills.ts";
|
|
7
7
|
|
|
8
8
|
let tempRoot: string | null = null;
|
|
9
9
|
|
|
@@ -43,4 +43,59 @@ describe("IM skills prompt rendering", () => {
|
|
|
43
43
|
expect(prompt).toContain("cwd=C:/work");
|
|
44
44
|
expect(prompt).not.toContain("{{");
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
it("renders separate WeChat image, file, and video capability docs", async () => {
|
|
48
|
+
tempRoot = await mkdtemp(join(tmpdir(), "chatccc-im-skills-cache-"));
|
|
49
|
+
const prompt = await buildImSkillsPrompt({
|
|
50
|
+
variables: {
|
|
51
|
+
cwd: "C:/work",
|
|
52
|
+
im_skills_cache_dir: tempRoot,
|
|
53
|
+
wechat_send_image_script: "C:/work/im-skills/wechat-image-skill/send-image.mjs",
|
|
54
|
+
wechat_send_file_script: "C:/work/im-skills/wechat-file-skill/send-file.mjs",
|
|
55
|
+
wechat_send_video_script: "C:/work/im-skills/wechat-video-skill/send-video.mjs",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const exported = await exportSkillSubDocs(
|
|
60
|
+
{
|
|
61
|
+
variables: {
|
|
62
|
+
cwd: "C:/work",
|
|
63
|
+
im_skills_cache_dir: tempRoot,
|
|
64
|
+
wechat_send_image_script: "C:/work/im-skills/wechat-image-skill/send-image.mjs",
|
|
65
|
+
wechat_send_file_script: "C:/work/im-skills/wechat-file-skill/send-file.mjs",
|
|
66
|
+
wechat_send_video_script: "C:/work/im-skills/wechat-video-skill/send-video.mjs",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
tempRoot,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(prompt).toContain("[ChatCCC IM skill: wechat-image-skill]");
|
|
73
|
+
expect(prompt).toContain("[ChatCCC IM skill: wechat-file-skill]");
|
|
74
|
+
expect(prompt).toContain("[ChatCCC IM skill: wechat-video-skill]");
|
|
75
|
+
expect(prompt).not.toContain("[ChatCCC IM skill: wechat-skill]");
|
|
76
|
+
expect(prompt).toContain("Receive images");
|
|
77
|
+
expect(prompt).toContain("Send images");
|
|
78
|
+
expect(prompt).toContain("Receive files");
|
|
79
|
+
expect(prompt).toContain("Send files");
|
|
80
|
+
expect(prompt).toContain("Receive videos");
|
|
81
|
+
expect(prompt).toContain("Send videos");
|
|
82
|
+
expect(prompt).toContain("receive-send-image.md");
|
|
83
|
+
expect(prompt).toContain("receive-send-file.md");
|
|
84
|
+
expect(prompt).toContain("receive-send-video.md");
|
|
85
|
+
expect(prompt).not.toContain("{{wechat_send_image_script}}");
|
|
86
|
+
expect(prompt).not.toContain("{{wechat_send_file_script}}");
|
|
87
|
+
expect(prompt).not.toContain("{{wechat_send_video_script}}");
|
|
88
|
+
expect(exported.some((file) => file.endsWith(join("wechat-image-skill", "receive-send-image.md")))).toBe(true);
|
|
89
|
+
expect(exported.some((file) => file.endsWith(join("wechat-file-skill", "receive-send-file.md")))).toBe(true);
|
|
90
|
+
expect(exported.some((file) => file.endsWith(join("wechat-video-skill", "receive-send-video.md")))).toBe(true);
|
|
91
|
+
await expect(readFile(join(tempRoot, "wechat-image-skill", "receive-send-image.md"), "utf8")).resolves.toContain(
|
|
92
|
+
"send-image.mjs",
|
|
93
|
+
);
|
|
94
|
+
await expect(readFile(join(tempRoot, "wechat-file-skill", "receive-send-file.md"), "utf8")).resolves.toContain(
|
|
95
|
+
"send-file.mjs",
|
|
96
|
+
);
|
|
97
|
+
await expect(readFile(join(tempRoot, "wechat-video-skill", "receive-send-video.md"), "utf8")).resolves.toContain(
|
|
98
|
+
"send-video.mjs",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
46
101
|
});
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
2
5
|
|
|
3
6
|
import { buildHelpCard } from "../cards.ts";
|
|
4
7
|
import {
|
|
8
|
+
_downloadWechatMediaAttachmentsForTest,
|
|
5
9
|
_resetWechatClawStateForTest,
|
|
6
10
|
_setWxMinSendIntervalMsForTest,
|
|
7
11
|
createWechatAdapter,
|
|
@@ -109,3 +113,64 @@ describe("createWechatAdapter", () => {
|
|
|
109
113
|
);
|
|
110
114
|
});
|
|
111
115
|
});
|
|
116
|
+
|
|
117
|
+
describe("WeChat media receive helpers", () => {
|
|
118
|
+
it("downloads image, file, and video items and returns message attachment paths", async () => {
|
|
119
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "chatccc-wx-media-"));
|
|
120
|
+
const imageDir = join(tempRoot, "images");
|
|
121
|
+
const fileDir = join(tempRoot, "files");
|
|
122
|
+
const videoDir = join(tempRoot, "videos");
|
|
123
|
+
try {
|
|
124
|
+
const imageMedia = { aes_key: "image-key-1234567890", encrypt_query_param: "image" };
|
|
125
|
+
const fileMedia = { aes_key: "file-key-1234567890", encrypt_query_param: "file" };
|
|
126
|
+
const videoMedia = { aes_key: "video-key-1234567890", encrypt_query_param: "video" };
|
|
127
|
+
const wire = {
|
|
128
|
+
downloadMedia: vi.fn(async (media: unknown) => {
|
|
129
|
+
if (media === imageMedia) return Buffer.from("image-data");
|
|
130
|
+
if (media === fileMedia) return Buffer.from("file-data");
|
|
131
|
+
if (media === videoMedia) return Buffer.from("video-data");
|
|
132
|
+
throw new Error("unexpected media");
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const result = await _downloadWechatMediaAttachmentsForTest(
|
|
137
|
+
{
|
|
138
|
+
message_id: 123,
|
|
139
|
+
item_list: [
|
|
140
|
+
{ image_item: { media: imageMedia } },
|
|
141
|
+
{
|
|
142
|
+
file_item: {
|
|
143
|
+
media: fileMedia,
|
|
144
|
+
file_name: "report.txt",
|
|
145
|
+
md5: "1234567890abcdef1234567890abcdef",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
video_item: {
|
|
150
|
+
media: videoMedia,
|
|
151
|
+
video_md5: "abcdef1234567890abcdef",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{ wire, imageDir, fileDir, videoDir },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(result.imagePaths).toHaveLength(1);
|
|
160
|
+
expect(result.filePaths).toHaveLength(1);
|
|
161
|
+
expect(result.videoPaths).toHaveLength(1);
|
|
162
|
+
expect(result.messageLines[0]).toMatch(/^\[图片\] /);
|
|
163
|
+
expect(result.messageLines[1]).toMatch(/^\[文件\] /);
|
|
164
|
+
expect(result.messageLines[2]).toMatch(/^\[视频\] /);
|
|
165
|
+
expect(result.imagePaths[0]).toContain(join("images", "wx_image-key-123456.png"));
|
|
166
|
+
expect(result.filePaths[0]).toContain(join("files", "wx_1234567890abcdef_report.txt"));
|
|
167
|
+
expect(result.videoPaths[0]).toContain(join("videos", "wx_abcdef1234567890.mp4"));
|
|
168
|
+
await expect(readFile(result.imagePaths[0], "utf8")).resolves.toBe("image-data");
|
|
169
|
+
await expect(readFile(result.filePaths[0], "utf8")).resolves.toBe("file-data");
|
|
170
|
+
await expect(readFile(result.videoPaths[0], "utf8")).resolves.toBe("video-data");
|
|
171
|
+
expect(wire.downloadMedia).toHaveBeenCalledTimes(3);
|
|
172
|
+
} finally {
|
|
173
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -889,7 +889,7 @@ export async function handleCommand(
|
|
|
889
889
|
|
|
890
890
|
const targetToolLabel = toolDisplayName(target.tool);
|
|
891
891
|
const busyNote = isSessionRunning(target.sessionId)
|
|
892
|
-
? "\n\n⚠️
|
|
892
|
+
? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。如需中断生成,请发送 /stop 指令。"
|
|
893
893
|
: "";
|
|
894
894
|
await platform.sendCard(
|
|
895
895
|
chatId,
|
|
@@ -991,7 +991,7 @@ export async function handleCommand(
|
|
|
991
991
|
await platform.sendCard(
|
|
992
992
|
chatId,
|
|
993
993
|
"生成中",
|
|
994
|
-
"
|
|
994
|
+
"该会话正在生成回复中,请等待完成后再发送新消息。如需中断生成,请发送 /stop 指令。",
|
|
995
995
|
"yellow",
|
|
996
996
|
);
|
|
997
997
|
return;
|
package/src/session.ts
CHANGED
|
@@ -675,7 +675,7 @@ export async function runAgentSession(
|
|
|
675
675
|
const isWechatBusy = platform.kind === "wechat";
|
|
676
676
|
const busyMsg = isWechatBusy
|
|
677
677
|
? "当前正在生成回复中,请等待完成后再发送消息。如需中断生成,请发送 /stop 指令。"
|
|
678
|
-
: "
|
|
678
|
+
: "该会话正在生成回复中,请等待完成后再发送消息。如需中断生成,请发送 /stop 指令。";
|
|
679
679
|
await platform.sendText(_chatId, busyMsg).catch(() => {});
|
|
680
680
|
return;
|
|
681
681
|
}
|
|
@@ -705,7 +705,9 @@ export async function runAgentSession(
|
|
|
705
705
|
|
|
706
706
|
// 构建 IM skills prompt(sessionId 方式,无 token)
|
|
707
707
|
const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
|
|
708
|
-
const
|
|
708
|
+
const wechatImageSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-image-skill");
|
|
709
|
+
const wechatFileSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-file-skill");
|
|
710
|
+
const wechatVideoSkillDir = join(PROJECT_ROOT, "im-skills", "wechat-video-skill");
|
|
709
711
|
const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
|
|
710
712
|
const skillVariables = {
|
|
711
713
|
cwd,
|
|
@@ -716,7 +718,9 @@ export async function runAgentSession(
|
|
|
716
718
|
send_image_script: join(feishuSkillDir, "send-image.mjs"),
|
|
717
719
|
send_file_script: join(feishuSkillDir, "send-file.mjs"),
|
|
718
720
|
download_video_script: join(feishuSkillDir, "download-video.mjs"),
|
|
719
|
-
wechat_send_image_script: join(
|
|
721
|
+
wechat_send_image_script: join(wechatImageSkillDir, "send-image.mjs"),
|
|
722
|
+
wechat_send_file_script: join(wechatFileSkillDir, "send-file.mjs"),
|
|
723
|
+
wechat_send_video_script: join(wechatVideoSkillDir, "send-video.mjs"),
|
|
720
724
|
};
|
|
721
725
|
var imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
|
|
722
726
|
await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
|
package/src/wechat-platform.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import { dirname, extname, join } from "node:path";
|
|
13
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
|
|
16
16
|
import {
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type GetUpdatesResponse,
|
|
20
20
|
type WeixinMessage,
|
|
21
21
|
} from "@openilink/openilink-sdk-node";
|
|
22
|
-
import type {
|
|
22
|
+
import type { FileItem, ImageItem, VideoItem } from "@openilink/openilink-sdk-node";
|
|
23
23
|
|
|
24
24
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
25
25
|
import { setupFileLogging } from "./shared.ts";
|
|
@@ -502,8 +502,31 @@ export async function startWechatPlatform(
|
|
|
502
502
|
}
|
|
503
503
|
|
|
504
504
|
const WECHAT_IMAGE_DOWNLOAD_DIR = join(homedir(), ".chatccc", "images", "downloads");
|
|
505
|
+
const WECHAT_FILE_DOWNLOAD_DIR = join(homedir(), ".chatccc", "files", "downloads");
|
|
506
|
+
const WECHAT_VIDEO_DOWNLOAD_DIR = join(homedir(), ".chatccc", "videos", "downloads");
|
|
505
507
|
|
|
506
|
-
|
|
508
|
+
type WechatMediaDownloader = Pick<OpenIlinkWire, "downloadMedia">;
|
|
509
|
+
|
|
510
|
+
interface WechatMediaDownloadOptions {
|
|
511
|
+
wire?: WechatMediaDownloader | null;
|
|
512
|
+
imageDir?: string;
|
|
513
|
+
fileDir?: string;
|
|
514
|
+
videoDir?: string;
|
|
515
|
+
log?: (msg: string) => void;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
interface WechatDownloadedMediaAttachments {
|
|
519
|
+
imagePaths: string[];
|
|
520
|
+
filePaths: string[];
|
|
521
|
+
videoPaths: string[];
|
|
522
|
+
messageLines: string[];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function extFromMimeOrName(
|
|
526
|
+
mime?: string | null,
|
|
527
|
+
fileName?: string | null,
|
|
528
|
+
fallback = ".bin",
|
|
529
|
+
): string {
|
|
507
530
|
if (mime) {
|
|
508
531
|
const map: Record<string, string> = {
|
|
509
532
|
"image/png": ".png",
|
|
@@ -512,6 +535,17 @@ function extFromMimeOrName(mime?: string | null, fileName?: string | null): stri
|
|
|
512
535
|
"image/gif": ".gif",
|
|
513
536
|
"image/bmp": ".bmp",
|
|
514
537
|
"image/svg+xml": ".svg",
|
|
538
|
+
"video/mp4": ".mp4",
|
|
539
|
+
"video/quicktime": ".mov",
|
|
540
|
+
"video/x-msvideo": ".avi",
|
|
541
|
+
"video/x-matroska": ".mkv",
|
|
542
|
+
"video/webm": ".webm",
|
|
543
|
+
"video/x-flv": ".flv",
|
|
544
|
+
"text/plain": ".txt",
|
|
545
|
+
"text/csv": ".csv",
|
|
546
|
+
"application/pdf": ".pdf",
|
|
547
|
+
"application/zip": ".zip",
|
|
548
|
+
"application/gzip": ".gz",
|
|
515
549
|
};
|
|
516
550
|
const key = mime.split(";")[0].trim().toLowerCase();
|
|
517
551
|
if (map[key]) return map[key];
|
|
@@ -520,25 +554,145 @@ function extFromMimeOrName(mime?: string | null, fileName?: string | null): stri
|
|
|
520
554
|
const ext = extname(fileName).toLowerCase();
|
|
521
555
|
if (ext) return ext;
|
|
522
556
|
}
|
|
523
|
-
return
|
|
557
|
+
return fallback;
|
|
524
558
|
}
|
|
525
559
|
|
|
526
|
-
async function downloadWechatImage(
|
|
527
|
-
|
|
560
|
+
async function downloadWechatImage(
|
|
561
|
+
imageItem: ImageItem,
|
|
562
|
+
msgId?: number,
|
|
563
|
+
options: WechatMediaDownloadOptions = {},
|
|
564
|
+
): Promise<string> {
|
|
565
|
+
const wire = options.wire ?? ilinkWire;
|
|
528
566
|
if (!wire) throw new Error("iLink wire not available");
|
|
529
567
|
if (!imageItem.media) throw new Error("image item has no media");
|
|
530
568
|
|
|
531
569
|
const data = await wire.downloadMedia(imageItem.media);
|
|
532
570
|
const mime = (imageItem as Record<string, unknown>).mime_type as string | undefined;
|
|
533
|
-
const
|
|
571
|
+
const fileName = (imageItem as Record<string, unknown>).file_name as string | undefined;
|
|
572
|
+
const ext = extFromMimeOrName(mime, fileName, ".png");
|
|
534
573
|
const key = imageItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
|
|
535
|
-
|
|
536
|
-
|
|
574
|
+
const downloadDir = options.imageDir ?? WECHAT_IMAGE_DOWNLOAD_DIR;
|
|
575
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
576
|
+
const localPath = join(downloadDir, `wx_${key}${ext}`);
|
|
537
577
|
writeFileSync(localPath, data);
|
|
538
578
|
platformLog(`图片已下载: ${localPath}`);
|
|
539
579
|
return localPath;
|
|
540
580
|
}
|
|
541
581
|
|
|
582
|
+
function safeLocalFileName(fileName: string): string {
|
|
583
|
+
return basename(fileName).replace(/[<>:"/\\|?*\x00-\x1F]/g, "_");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function downloadWechatFile(
|
|
587
|
+
fileItem: FileItem,
|
|
588
|
+
msgId?: number,
|
|
589
|
+
options: WechatMediaDownloadOptions = {},
|
|
590
|
+
): Promise<string> {
|
|
591
|
+
const wire = options.wire ?? ilinkWire;
|
|
592
|
+
if (!wire) throw new Error("iLink wire not available");
|
|
593
|
+
if (!fileItem.media) throw new Error("file item has no media");
|
|
594
|
+
|
|
595
|
+
const data = await wire.downloadMedia(fileItem.media);
|
|
596
|
+
const mime = (fileItem as Record<string, unknown>).mime_type as string | undefined;
|
|
597
|
+
const ext = extFromMimeOrName(mime, fileItem.file_name, ".bin");
|
|
598
|
+
const key =
|
|
599
|
+
fileItem.md5?.slice(0, 16) ??
|
|
600
|
+
fileItem.media.aes_key?.slice(0, 16) ??
|
|
601
|
+
(msgId?.toString() ?? Date.now().toString());
|
|
602
|
+
const localName = fileItem.file_name
|
|
603
|
+
? `wx_${key}_${safeLocalFileName(fileItem.file_name)}`
|
|
604
|
+
: `wx_${key}${ext}`;
|
|
605
|
+
const downloadDir = options.fileDir ?? WECHAT_FILE_DOWNLOAD_DIR;
|
|
606
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
607
|
+
const localPath = join(downloadDir, localName);
|
|
608
|
+
writeFileSync(localPath, data);
|
|
609
|
+
(options.log ?? platformLog)(`文件已下载: ${localPath}`);
|
|
610
|
+
return localPath;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function downloadWechatVideo(
|
|
614
|
+
videoItem: VideoItem,
|
|
615
|
+
msgId?: number,
|
|
616
|
+
options: WechatMediaDownloadOptions = {},
|
|
617
|
+
): Promise<string> {
|
|
618
|
+
const wire = options.wire ?? ilinkWire;
|
|
619
|
+
if (!wire) throw new Error("iLink wire not available");
|
|
620
|
+
if (!videoItem.media) throw new Error("video item has no media");
|
|
621
|
+
|
|
622
|
+
const data = await wire.downloadMedia(videoItem.media);
|
|
623
|
+
const mime = (videoItem as Record<string, unknown>).mime_type as string | undefined;
|
|
624
|
+
const fileName = (videoItem as Record<string, unknown>).file_name as string | undefined;
|
|
625
|
+
const ext = extFromMimeOrName(mime, fileName, ".mp4");
|
|
626
|
+
const key =
|
|
627
|
+
videoItem.video_md5?.slice(0, 16) ??
|
|
628
|
+
videoItem.media.aes_key?.slice(0, 16) ??
|
|
629
|
+
(msgId?.toString() ?? Date.now().toString());
|
|
630
|
+
const downloadDir = options.videoDir ?? WECHAT_VIDEO_DOWNLOAD_DIR;
|
|
631
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
632
|
+
const localPath = join(downloadDir, `wx_${key}${ext}`);
|
|
633
|
+
writeFileSync(localPath, data);
|
|
634
|
+
(options.log ?? platformLog)(`视频已下载: ${localPath}`);
|
|
635
|
+
return localPath;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function downloadWechatMediaAttachments(
|
|
639
|
+
message: WeixinMessage,
|
|
640
|
+
options: WechatMediaDownloadOptions = {},
|
|
641
|
+
): Promise<WechatDownloadedMediaAttachments> {
|
|
642
|
+
const imagePaths: string[] = [];
|
|
643
|
+
const filePaths: string[] = [];
|
|
644
|
+
const videoPaths: string[] = [];
|
|
645
|
+
const messageLines: string[] = [];
|
|
646
|
+
const items = message.item_list;
|
|
647
|
+
if (items) {
|
|
648
|
+
for (const item of items) {
|
|
649
|
+
if (item.image_item?.media) {
|
|
650
|
+
try {
|
|
651
|
+
const localPath = await downloadWechatImage(item.image_item, message.message_id, options);
|
|
652
|
+
imagePaths.push(localPath);
|
|
653
|
+
messageLines.push(`[图片] ${localPath}`);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
(options.log ?? platformLog)(`图片下载失败: ${(err as Error).message}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (item.file_item?.media) {
|
|
660
|
+
try {
|
|
661
|
+
const localPath = await downloadWechatFile(item.file_item, message.message_id, options);
|
|
662
|
+
filePaths.push(localPath);
|
|
663
|
+
messageLines.push(`[文件] ${localPath}`);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
(options.log ?? platformLog)(`文件下载失败: ${(err as Error).message}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (item.video_item?.media) {
|
|
670
|
+
try {
|
|
671
|
+
const localPath = await downloadWechatVideo(item.video_item, message.message_id, options);
|
|
672
|
+
videoPaths.push(localPath);
|
|
673
|
+
messageLines.push(`[视频] ${localPath}`);
|
|
674
|
+
} catch (err) {
|
|
675
|
+
(options.log ?? platformLog)(`视频下载失败: ${(err as Error).message}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
imagePaths,
|
|
683
|
+
filePaths,
|
|
684
|
+
videoPaths,
|
|
685
|
+
messageLines,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export async function _downloadWechatMediaAttachmentsForTest(
|
|
690
|
+
message: WeixinMessage,
|
|
691
|
+
options: WechatMediaDownloadOptions,
|
|
692
|
+
): Promise<WechatDownloadedMediaAttachments> {
|
|
693
|
+
return downloadWechatMediaAttachments(message, options);
|
|
694
|
+
}
|
|
695
|
+
|
|
542
696
|
async function handleWechatMessage(
|
|
543
697
|
message: WeixinMessage,
|
|
544
698
|
handler: MessageHandler,
|
|
@@ -564,37 +718,23 @@ async function handleWechatMessage(
|
|
|
564
718
|
const text = extractText(message).trim();
|
|
565
719
|
const msgTimestamp = message.create_time_ms ?? Date.now();
|
|
566
720
|
|
|
567
|
-
|
|
568
|
-
const imagePaths: string[] = [];
|
|
569
|
-
const items = message.item_list;
|
|
570
|
-
if (items) {
|
|
571
|
-
for (const item of items) {
|
|
572
|
-
if (item.image_item?.media) {
|
|
573
|
-
try {
|
|
574
|
-
const localPath = await downloadWechatImage(item.image_item, message.message_id);
|
|
575
|
-
imagePaths.push(localPath);
|
|
576
|
-
} catch (err) {
|
|
577
|
-
platformLog(`图片下载失败: ${(err as Error).message}`);
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
}
|
|
721
|
+
const mediaAttachments = await downloadWechatMediaAttachments(message);
|
|
582
722
|
|
|
583
|
-
// 构建消息文本:文本内容 +
|
|
723
|
+
// 构建消息文本:文本内容 + 媒体路径
|
|
584
724
|
let fullText = text;
|
|
585
|
-
if (
|
|
586
|
-
const
|
|
587
|
-
fullText = fullText ? `${fullText}\n${
|
|
725
|
+
if (mediaAttachments.messageLines.length > 0) {
|
|
726
|
+
const mediaLines = mediaAttachments.messageLines.join("\n");
|
|
727
|
+
fullText = fullText ? `${fullText}\n${mediaLines}` : mediaLines;
|
|
588
728
|
}
|
|
589
729
|
|
|
590
|
-
//
|
|
730
|
+
// 纯媒体且无可下载内容时跳过(避免空消息触发会话)
|
|
591
731
|
if (!fullText.trim()) {
|
|
592
732
|
platformLog(`跳过纯媒体消息(无文本): chatId=${chatId}`);
|
|
593
733
|
return;
|
|
594
734
|
}
|
|
595
735
|
|
|
596
736
|
platformLog(
|
|
597
|
-
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${imagePaths.length}`,
|
|
737
|
+
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${mediaAttachments.imagePaths.length} files=${mediaAttachments.filePaths.length} videos=${mediaAttachments.videoPaths.length}`,
|
|
598
738
|
);
|
|
599
739
|
appendChatLog(chatId, chatId, fullText);
|
|
600
740
|
|