chatccc 0.2.64 → 0.2.66
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 +38 -38
- package/im-skills/wechat-file-skill/send-file.mjs +83 -83
- package/im-skills/wechat-file-skill/skill.md +10 -10
- package/im-skills/wechat-image-skill/receive-send-image.md +5 -5
- package/im-skills/wechat-image-skill/send-image.mjs +2 -6
- package/im-skills/wechat-image-skill/skill.md +4 -4
- package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
- package/im-skills/wechat-video-skill/send-video.mjs +79 -79
- package/im-skills/wechat-video-skill/skill.md +10 -10
- package/package.json +1 -1
- package/src/__tests__/im-skills.test.ts +1 -56
- package/src/__tests__/orchestrator.test.ts +1 -1
- package/src/__tests__/wechat-platform.test.ts +0 -65
- package/src/orchestrator.ts +3 -4
- package/src/session.ts +1 -1
- package/src/wechat-platform.ts +68 -151
package/config.sample.json
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
# Receiving & Sending Files (WeChat)
|
|
2
|
-
|
|
3
|
-
## Receive Files
|
|
4
|
-
|
|
5
|
-
Files 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
|
-
```
|
|
9
|
-
[文件] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>_<filename>
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
You can read the file at that path to understand what the user sent.
|
|
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, "filename.pdf", "caption");
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Rules
|
|
33
|
-
|
|
34
|
-
- Save or choose a local file first.
|
|
35
|
-
- Use an absolute local path.
|
|
36
|
-
- Supported formats: .pdf, .doc, .docx, .xls, .xlsx, .csv, .ppt, .pptx, .txt, .zip, .tar, .gz, .rar, .7z, .mp3, .wav, .ogg, .aac, .m4a.
|
|
37
|
-
- Max file size: 100MB.
|
|
38
|
-
- Only send a file when the user asked for one or when it materially helps the answer.
|
|
1
|
+
# Receiving & Sending Files (WeChat)
|
|
2
|
+
|
|
3
|
+
## Receive Files
|
|
4
|
+
|
|
5
|
+
Files 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
|
+
```
|
|
9
|
+
[文件] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>_<filename>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You can read the file at that path to understand what the user sent.
|
|
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, "filename.pdf", "caption");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Rules
|
|
33
|
+
|
|
34
|
+
- Save or choose a local file first.
|
|
35
|
+
- Use an absolute local path.
|
|
36
|
+
- Supported formats: .pdf, .doc, .docx, .xls, .xlsx, .csv, .ppt, .pptx, .txt, .zip, .tar, .gz, .rar, .7z, .mp3, .wav, .ogg, .aac, .m4a.
|
|
37
|
+
- Max file size: 100MB.
|
|
38
|
+
- Only send a file when the user asked for one or when it materially helps the answer.
|
|
39
39
|
- **Claw 限制**: 文件发送也计入微信 claw 连发计数,连续 10 条未回复会截断。
|
|
@@ -1,84 +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_FILE_BYTES = 100 * 1024 * 1024;
|
|
10
|
-
const ALLOWED_EXTS = new Set([
|
|
11
|
-
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".ppt", ".pptx",
|
|
12
|
-
".txt", ".zip", ".tar", ".gz", ".rar", ".7z",
|
|
13
|
-
".mp3", ".wav", ".ogg", ".aac", ".m4a",
|
|
14
|
-
]);
|
|
15
|
-
|
|
16
|
-
function parseArgs(argv) {
|
|
17
|
-
const result = {};
|
|
18
|
-
for (let i = 0; i < argv.length; i++) {
|
|
19
|
-
const key = argv[i];
|
|
20
|
-
if (!key.startsWith("--")) continue;
|
|
21
|
-
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
22
|
-
result[key.slice(2)] = value;
|
|
23
|
-
}
|
|
24
|
-
return result;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function usage() {
|
|
28
|
-
console.error(`Usage:
|
|
29
|
-
node ${basename(process.argv[1])} --path <absolute file path> [--caption <text>]`);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function main() {
|
|
33
|
-
const args = parseArgs(process.argv.slice(2));
|
|
34
|
-
const filePath = args.path;
|
|
35
|
-
const caption = args.caption || "";
|
|
36
|
-
|
|
37
|
-
if (!filePath) {
|
|
38
|
-
usage();
|
|
39
|
-
process.exit(1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const ext = extname(filePath).toLowerCase();
|
|
43
|
-
if (!ALLOWED_EXTS.has(ext)) {
|
|
44
|
-
console.error(`Unsupported file extension: ${ext || "(none)"}`);
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const st = statSync(filePath);
|
|
49
|
-
if (!st.isFile()) {
|
|
50
|
-
console.error("File path is not a file");
|
|
51
|
-
process.exit(1);
|
|
52
|
-
}
|
|
53
|
-
if (st.size > MAX_FILE_BYTES) {
|
|
54
|
-
console.error("File exceeds 100MB 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 fileData = readFileSync(filePath);
|
|
71
|
-
const fileName = basename(filePath);
|
|
72
|
-
|
|
73
|
-
const wire = new OpenIlinkWire(snap.token, {
|
|
74
|
-
base_url: snap.baseUrl,
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, fileData, 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);
|
|
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_FILE_BYTES = 100 * 1024 * 1024;
|
|
10
|
+
const ALLOWED_EXTS = new Set([
|
|
11
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".ppt", ".pptx",
|
|
12
|
+
".txt", ".zip", ".tar", ".gz", ".rar", ".7z",
|
|
13
|
+
".mp3", ".wav", ".ogg", ".aac", ".m4a",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const result = {};
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const key = argv[i];
|
|
20
|
+
if (!key.startsWith("--")) continue;
|
|
21
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
22
|
+
result[key.slice(2)] = value;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function usage() {
|
|
28
|
+
console.error(`Usage:
|
|
29
|
+
node ${basename(process.argv[1])} --path <absolute file path> [--caption <text>]`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const args = parseArgs(process.argv.slice(2));
|
|
34
|
+
const filePath = args.path;
|
|
35
|
+
const caption = args.caption || "";
|
|
36
|
+
|
|
37
|
+
if (!filePath) {
|
|
38
|
+
usage();
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ext = extname(filePath).toLowerCase();
|
|
43
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
44
|
+
console.error(`Unsupported file extension: ${ext || "(none)"}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const st = statSync(filePath);
|
|
49
|
+
if (!st.isFile()) {
|
|
50
|
+
console.error("File path is not a file");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
54
|
+
console.error("File exceeds 100MB 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 fileData = readFileSync(filePath);
|
|
71
|
+
const fileName = basename(filePath);
|
|
72
|
+
|
|
73
|
+
const wire = new OpenIlinkWire(snap.token, {
|
|
74
|
+
base_url: snap.baseUrl,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, fileData, 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
84
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: wechat-file-skill
|
|
3
|
-
description: WeChat iLink local skills 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 scripts below instead.
|
|
9
|
-
|
|
10
|
-
- **Receive files**: Files 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>` — read `{{im_skills_cache_dir}}/wechat-file-skill/receive-send-file.md`
|
|
1
|
+
---
|
|
2
|
+
name: wechat-file-skill
|
|
3
|
+
description: WeChat iLink local skills 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 scripts below instead.
|
|
9
|
+
|
|
10
|
+
- **Receive files**: Files 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>` — read `{{im_skills_cache_dir}}/wechat-file-skill/receive-send-file.md`
|
|
11
11
|
- **Send files**: Use the send-file helper script — read `{{im_skills_cache_dir}}/wechat-file-skill/receive-send-file.md`
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Receive Images
|
|
4
4
|
|
|
5
|
-
Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/` with the `wx_` filename prefix.
|
|
5
|
+
Images sent to the bot are **automatically downloaded** to `~/.chatccc/images/downloads/` with the `wx_` filename prefix.
|
|
6
6
|
|
|
7
7
|
The message text you receive will include the downloaded path in the format:
|
|
8
|
-
```
|
|
9
|
-
[图片] C:\Users
|
|
8
|
+
```
|
|
9
|
+
[图片] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>.png
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
You can read the image file at that path to understand what the user sent.
|
|
@@ -34,6 +34,6 @@ await wire.sendMediaFile(chatId, contextToken, imageBuffer, "filename.png", "cap
|
|
|
34
34
|
- Save or choose a local image file first.
|
|
35
35
|
- Use an absolute local path.
|
|
36
36
|
- Supported formats: .png, .jpg, .jpeg, .webp, .gif, .bmp.
|
|
37
|
-
- Max image size: 10MB.
|
|
37
|
+
- Max image size: 10MB (SDK guideline; larger files upload slower).
|
|
38
38
|
- Only send an image when the user asked for one or when it materially helps the answer.
|
|
39
|
-
-
|
|
39
|
+
- **Claw 限制**: 图片发送也计入微信 claw 连发计数,连续 10 条未回复会截断。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
-
import { basename, extname,
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
|
|
6
6
|
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
@@ -34,10 +34,6 @@ async function main() {
|
|
|
34
34
|
usage();
|
|
35
35
|
process.exit(1);
|
|
36
36
|
}
|
|
37
|
-
if (!isAbsolute(imagePath)) {
|
|
38
|
-
console.error("Image path must be absolute.");
|
|
39
|
-
process.exit(1);
|
|
40
|
-
}
|
|
41
37
|
|
|
42
38
|
const ext = extname(imagePath).toLowerCase();
|
|
43
39
|
if (!ALLOWED_EXTS.has(ext)) {
|
|
@@ -81,4 +77,4 @@ async function main() {
|
|
|
81
77
|
main().catch((err) => {
|
|
82
78
|
console.error(err instanceof Error ? err.message : String(err));
|
|
83
79
|
process.exit(1);
|
|
84
|
-
});
|
|
80
|
+
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wechat-image-skill
|
|
3
|
-
description: WeChat iLink local
|
|
3
|
+
description: WeChat iLink local skills for sending and receiving images.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Current working directory: {{cwd}}
|
|
7
7
|
|
|
8
|
-
WeChat images are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper
|
|
8
|
+
WeChat images are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper scripts below instead.
|
|
9
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`
|
|
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>` — read `{{im_skills_cache_dir}}/wechat-image-skill/receive-send-image.md`
|
|
11
|
+
- **Send images**: Use the send-image helper script — read `{{im_skills_cache_dir}}/wechat-image-skill/receive-send-image.md`
|
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
# Receiving & Sending Videos (WeChat)
|
|
2
|
-
|
|
3
|
-
## Receive Videos
|
|
4
|
-
|
|
5
|
-
Videos 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
|
-
```
|
|
9
|
-
[视频] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>.mp4
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
You can read the video file at that path to understand what the user sent.
|
|
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, "video.mp4", "caption");
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Rules
|
|
33
|
-
|
|
34
|
-
- Save or choose a local video file first.
|
|
35
|
-
- Use an absolute local path.
|
|
36
|
-
- Supported formats: .mp4, .mov, .avi, .mkv, .webm, .flv.
|
|
37
|
-
- Max video size: 100MB.
|
|
38
|
-
- Only send a video when the user asked for one or when it materially helps the answer.
|
|
1
|
+
# Receiving & Sending Videos (WeChat)
|
|
2
|
+
|
|
3
|
+
## Receive Videos
|
|
4
|
+
|
|
5
|
+
Videos 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
|
+
```
|
|
9
|
+
[视频] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>.mp4
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You can read the video file at that path to understand what the user sent.
|
|
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, "video.mp4", "caption");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Rules
|
|
33
|
+
|
|
34
|
+
- Save or choose a local video file first.
|
|
35
|
+
- Use an absolute local path.
|
|
36
|
+
- Supported formats: .mp4, .mov, .avi, .mkv, .webm, .flv.
|
|
37
|
+
- Max video size: 100MB.
|
|
38
|
+
- Only send a video when the user asked for one or when it materially helps the answer.
|
|
39
39
|
- **Claw 限制**: 视频发送也计入微信 claw 连发计数,连续 10 条未回复会截断。
|
|
@@ -1,80 +1,80 @@
|
|
|
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_VIDEO_BYTES = 100 * 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
|
-
|
|
38
|
-
const ext = extname(videoPath).toLowerCase();
|
|
39
|
-
if (!ALLOWED_EXTS.has(ext)) {
|
|
40
|
-
console.error(`Unsupported video extension: ${ext || "(none)"}`);
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const st = statSync(videoPath);
|
|
45
|
-
if (!st.isFile()) {
|
|
46
|
-
console.error("Video path is not a file");
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
49
|
-
if (st.size > MAX_VIDEO_BYTES) {
|
|
50
|
-
console.error("Video file exceeds 100MB limit");
|
|
51
|
-
process.exit(1);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
55
|
-
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
56
|
-
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
61
|
-
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
62
|
-
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const videoData = readFileSync(videoPath);
|
|
67
|
-
const fileName = basename(videoPath);
|
|
68
|
-
|
|
69
|
-
const wire = new OpenIlinkWire(snap.token, {
|
|
70
|
-
base_url: snap.baseUrl,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, videoData, fileName, caption);
|
|
74
|
-
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
main().catch((err) => {
|
|
78
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
79
|
-
process.exit(1);
|
|
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_VIDEO_BYTES = 100 * 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
|
+
|
|
38
|
+
const ext = extname(videoPath).toLowerCase();
|
|
39
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
40
|
+
console.error(`Unsupported video extension: ${ext || "(none)"}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const st = statSync(videoPath);
|
|
45
|
+
if (!st.isFile()) {
|
|
46
|
+
console.error("Video path is not a file");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
if (st.size > MAX_VIDEO_BYTES) {
|
|
50
|
+
console.error("Video file exceeds 100MB limit");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
55
|
+
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
56
|
+
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
61
|
+
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
62
|
+
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const videoData = readFileSync(videoPath);
|
|
67
|
+
const fileName = basename(videoPath);
|
|
68
|
+
|
|
69
|
+
const wire = new OpenIlinkWire(snap.token, {
|
|
70
|
+
base_url: snap.baseUrl,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, videoData, fileName, caption);
|
|
74
|
+
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
79
|
+
process.exit(1);
|
|
80
80
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: wechat-video-skill
|
|
3
|
-
description: WeChat iLink local skills 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 scripts below instead.
|
|
9
|
-
|
|
10
|
-
- **Receive videos**: Videos 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>` — read `{{im_skills_cache_dir}}/wechat-video-skill/receive-send-video.md`
|
|
1
|
+
---
|
|
2
|
+
name: wechat-video-skill
|
|
3
|
+
description: WeChat iLink local skills 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 scripts below instead.
|
|
9
|
+
|
|
10
|
+
- **Receive videos**: Videos 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>` — read `{{im_skills_cache_dir}}/wechat-video-skill/receive-send-video.md`
|
|
11
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
|
@@ -97,59 +97,4 @@ describe("IM skills prompt rendering", () => {
|
|
|
97
97
|
expect(prompt).toContain("[ChatCCC IM skill: wechat-skill]");
|
|
98
98
|
expect(exported.length).toBeGreaterThanOrEqual(2);
|
|
99
99
|
});
|
|
100
|
-
|
|
101
|
-
it("renders separate WeChat image, file, and video capability docs", async () => {
|
|
102
|
-
tempRoot = await mkdtemp(join(tmpdir(), "chatccc-im-skills-cache-"));
|
|
103
|
-
const prompt = await buildImSkillsPrompt({
|
|
104
|
-
variables: {
|
|
105
|
-
cwd: "C:/work",
|
|
106
|
-
im_skills_cache_dir: tempRoot,
|
|
107
|
-
wechat_send_image_script: "C:/work/im-skills/wechat-image-skill/send-image.mjs",
|
|
108
|
-
wechat_send_file_script: "C:/work/im-skills/wechat-file-skill/send-file.mjs",
|
|
109
|
-
wechat_send_video_script: "C:/work/im-skills/wechat-video-skill/send-video.mjs",
|
|
110
|
-
},
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const exported = await exportSkillSubDocs(
|
|
114
|
-
{
|
|
115
|
-
variables: {
|
|
116
|
-
cwd: "C:/work",
|
|
117
|
-
im_skills_cache_dir: tempRoot,
|
|
118
|
-
wechat_send_image_script: "C:/work/im-skills/wechat-image-skill/send-image.mjs",
|
|
119
|
-
wechat_send_file_script: "C:/work/im-skills/wechat-file-skill/send-file.mjs",
|
|
120
|
-
wechat_send_video_script: "C:/work/im-skills/wechat-video-skill/send-video.mjs",
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
tempRoot,
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
expect(prompt).toContain("[ChatCCC IM skill: wechat-image-skill]");
|
|
127
|
-
expect(prompt).toContain("[ChatCCC IM skill: wechat-file-skill]");
|
|
128
|
-
expect(prompt).toContain("[ChatCCC IM skill: wechat-video-skill]");
|
|
129
|
-
expect(prompt).not.toContain("[ChatCCC IM skill: wechat-skill]");
|
|
130
|
-
expect(prompt).toContain("Receive images");
|
|
131
|
-
expect(prompt).toContain("Send images");
|
|
132
|
-
expect(prompt).toContain("Receive files");
|
|
133
|
-
expect(prompt).toContain("Send files");
|
|
134
|
-
expect(prompt).toContain("Receive videos");
|
|
135
|
-
expect(prompt).toContain("Send videos");
|
|
136
|
-
expect(prompt).toContain("receive-send-image.md");
|
|
137
|
-
expect(prompt).toContain("receive-send-file.md");
|
|
138
|
-
expect(prompt).toContain("receive-send-video.md");
|
|
139
|
-
expect(prompt).not.toContain("{{wechat_send_image_script}}");
|
|
140
|
-
expect(prompt).not.toContain("{{wechat_send_file_script}}");
|
|
141
|
-
expect(prompt).not.toContain("{{wechat_send_video_script}}");
|
|
142
|
-
expect(exported.some((file) => file.endsWith(join("wechat-image-skill", "receive-send-image.md")))).toBe(true);
|
|
143
|
-
expect(exported.some((file) => file.endsWith(join("wechat-file-skill", "receive-send-file.md")))).toBe(true);
|
|
144
|
-
expect(exported.some((file) => file.endsWith(join("wechat-video-skill", "receive-send-video.md")))).toBe(true);
|
|
145
|
-
await expect(readFile(join(tempRoot, "wechat-image-skill", "receive-send-image.md"), "utf8")).resolves.toContain(
|
|
146
|
-
"send-image.mjs",
|
|
147
|
-
);
|
|
148
|
-
await expect(readFile(join(tempRoot, "wechat-file-skill", "receive-send-file.md"), "utf8")).resolves.toContain(
|
|
149
|
-
"send-file.mjs",
|
|
150
|
-
);
|
|
151
|
-
await expect(readFile(join(tempRoot, "wechat-video-skill", "receive-send-video.md"), "utf8")).resolves.toContain(
|
|
152
|
-
"send-video.mjs",
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
});
|
|
100
|
+
});
|
|
@@ -1,11 +1,7 @@
|
|
|
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";
|
|
5
2
|
|
|
6
3
|
import { buildHelpCard } from "../cards.ts";
|
|
7
4
|
import {
|
|
8
|
-
_downloadWechatMediaAttachmentsForTest,
|
|
9
5
|
_resetWechatClawStateForTest,
|
|
10
6
|
_setWxMinSendIntervalMsForTest,
|
|
11
7
|
createWechatAdapter,
|
|
@@ -113,64 +109,3 @@ describe("createWechatAdapter", () => {
|
|
|
113
109
|
);
|
|
114
110
|
});
|
|
115
111
|
});
|
|
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
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
setDefaultCwd,
|
|
22
22
|
getRecentDirs,
|
|
23
23
|
addRecentDir,
|
|
24
|
-
resolveDefaultAgentTool,
|
|
25
24
|
sessionPrefixForTool,
|
|
26
25
|
toolDisplayName,
|
|
27
26
|
ts,
|
|
@@ -241,7 +240,7 @@ export async function handleCommand(
|
|
|
241
240
|
|
|
242
241
|
if (textLower === "/new" || textLower.startsWith("/new ")) {
|
|
243
242
|
const toolArg = text.slice(5).trim().toLowerCase();
|
|
244
|
-
const tool = toolArg ||
|
|
243
|
+
const tool = toolArg || "claude";
|
|
245
244
|
logTrace(tid, "BRANCH", { cmd: "/new", tool });
|
|
246
245
|
const validTools = ["claude", "cursor", "codex"];
|
|
247
246
|
if (!validTools.includes(tool)) {
|
|
@@ -889,7 +888,7 @@ export async function handleCommand(
|
|
|
889
888
|
|
|
890
889
|
const targetToolLabel = toolDisplayName(target.tool);
|
|
891
890
|
const busyNote = isSessionRunning(target.sessionId)
|
|
892
|
-
? "\n\n⚠️
|
|
891
|
+
? "\n\n⚠️ 该会话当前正在生成中,请等待完成后再发送消息。"
|
|
893
892
|
: "";
|
|
894
893
|
await platform.sendCard(
|
|
895
894
|
chatId,
|
|
@@ -991,7 +990,7 @@ export async function handleCommand(
|
|
|
991
990
|
await platform.sendCard(
|
|
992
991
|
chatId,
|
|
993
992
|
"生成中",
|
|
994
|
-
"
|
|
993
|
+
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
995
994
|
"yellow",
|
|
996
995
|
);
|
|
997
996
|
return;
|
package/src/session.ts
CHANGED
|
@@ -686,7 +686,7 @@ export async function runAgentSession(
|
|
|
686
686
|
const isWechatBusy = platform.kind === "wechat";
|
|
687
687
|
const busyMsg = isWechatBusy
|
|
688
688
|
? "当前正在生成回复中,请等待完成后再发送消息。如需中断生成,请发送 /stop 指令。"
|
|
689
|
-
: "
|
|
689
|
+
: "该会话正在生成回复中,请等待完成后再发送消息。";
|
|
690
690
|
await platform.sendText(_chatId, busyMsg).catch(() => {});
|
|
691
691
|
return;
|
|
692
692
|
}
|
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 {
|
|
13
|
+
import { 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 { FileItem, ImageItem, VideoItem } from "@openilink/openilink-sdk-node";
|
|
22
|
+
import type { CDNMedia, 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,31 +502,8 @@ 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");
|
|
507
505
|
|
|
508
|
-
|
|
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 {
|
|
506
|
+
function extFromMimeOrName(mime?: string | null, fileName?: string | null): string {
|
|
530
507
|
if (mime) {
|
|
531
508
|
const map: Record<string, string> = {
|
|
532
509
|
"image/png": ".png",
|
|
@@ -535,17 +512,6 @@ function extFromMimeOrName(
|
|
|
535
512
|
"image/gif": ".gif",
|
|
536
513
|
"image/bmp": ".bmp",
|
|
537
514
|
"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",
|
|
549
515
|
};
|
|
550
516
|
const key = mime.split(";")[0].trim().toLowerCase();
|
|
551
517
|
if (map[key]) return map[key];
|
|
@@ -554,187 +520,138 @@ function extFromMimeOrName(
|
|
|
554
520
|
const ext = extname(fileName).toLowerCase();
|
|
555
521
|
if (ext) return ext;
|
|
556
522
|
}
|
|
557
|
-
return
|
|
523
|
+
return ".png";
|
|
558
524
|
}
|
|
559
525
|
|
|
560
|
-
async function downloadWechatImage(
|
|
561
|
-
|
|
562
|
-
msgId?: number,
|
|
563
|
-
options: WechatMediaDownloadOptions = {},
|
|
564
|
-
): Promise<string> {
|
|
565
|
-
const wire = options.wire ?? ilinkWire;
|
|
526
|
+
async function downloadWechatImage(imageItem: ImageItem, msgId?: number): Promise<string> {
|
|
527
|
+
const wire = ilinkWire;
|
|
566
528
|
if (!wire) throw new Error("iLink wire not available");
|
|
567
529
|
if (!imageItem.media) throw new Error("image item has no media");
|
|
568
530
|
|
|
569
531
|
const data = await wire.downloadMedia(imageItem.media);
|
|
570
532
|
const mime = (imageItem as Record<string, unknown>).mime_type as string | undefined;
|
|
571
|
-
const
|
|
572
|
-
const ext = extFromMimeOrName(mime, fileName, ".png");
|
|
533
|
+
const ext = extFromMimeOrName(mime);
|
|
573
534
|
const key = imageItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const localPath = join(downloadDir, `wx_${key}${ext}`);
|
|
535
|
+
await mkdirSync(WECHAT_IMAGE_DOWNLOAD_DIR, { recursive: true });
|
|
536
|
+
const localPath = join(WECHAT_IMAGE_DOWNLOAD_DIR, `wx_${key}${ext}`);
|
|
577
537
|
writeFileSync(localPath, data);
|
|
578
538
|
platformLog(`图片已下载: ${localPath}`);
|
|
579
539
|
return localPath;
|
|
580
540
|
}
|
|
581
541
|
|
|
582
|
-
function
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
async function downloadWechatFile(
|
|
587
|
-
fileItem: FileItem,
|
|
588
|
-
msgId?: number,
|
|
589
|
-
options: WechatMediaDownloadOptions = {},
|
|
590
|
-
): Promise<string> {
|
|
591
|
-
const wire = options.wire ?? ilinkWire;
|
|
542
|
+
async function downloadWechatFile(fileItem: FileItem, msgId?: number): Promise<string> {
|
|
543
|
+
const wire = ilinkWire;
|
|
592
544
|
if (!wire) throw new Error("iLink wire not available");
|
|
593
545
|
if (!fileItem.media) throw new Error("file item has no media");
|
|
594
546
|
|
|
595
547
|
const data = await wire.downloadMedia(fileItem.media);
|
|
596
|
-
const
|
|
597
|
-
const ext =
|
|
598
|
-
const key =
|
|
599
|
-
|
|
600
|
-
|
|
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);
|
|
548
|
+
const fileName = fileItem.file_name || "file";
|
|
549
|
+
const ext = extname(fileName).toLowerCase() || extFromMimeOrName(undefined);
|
|
550
|
+
const key = fileItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
|
|
551
|
+
await mkdirSync(WECHAT_IMAGE_DOWNLOAD_DIR, { recursive: true });
|
|
552
|
+
const localPath = join(WECHAT_IMAGE_DOWNLOAD_DIR, `wx_${key}_${fileName}`);
|
|
608
553
|
writeFileSync(localPath, data);
|
|
609
|
-
|
|
554
|
+
platformLog(`文件已下载: ${localPath}`);
|
|
610
555
|
return localPath;
|
|
611
556
|
}
|
|
612
557
|
|
|
613
|
-
async function downloadWechatVideo(
|
|
614
|
-
|
|
615
|
-
msgId?: number,
|
|
616
|
-
options: WechatMediaDownloadOptions = {},
|
|
617
|
-
): Promise<string> {
|
|
618
|
-
const wire = options.wire ?? ilinkWire;
|
|
558
|
+
async function downloadWechatVideo(videoItem: VideoItem, msgId?: number): Promise<string> {
|
|
559
|
+
const wire = ilinkWire;
|
|
619
560
|
if (!wire) throw new Error("iLink wire not available");
|
|
620
561
|
if (!videoItem.media) throw new Error("video item has no media");
|
|
621
562
|
|
|
622
563
|
const data = await wire.downloadMedia(videoItem.media);
|
|
623
|
-
const
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
const
|
|
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}`);
|
|
564
|
+
const ext = extFromMimeOrName(undefined);
|
|
565
|
+
const key = videoItem.media.aes_key?.slice(0, 16) ?? (msgId?.toString() ?? Date.now().toString());
|
|
566
|
+
await mkdirSync(WECHAT_IMAGE_DOWNLOAD_DIR, { recursive: true });
|
|
567
|
+
const localPath = join(WECHAT_IMAGE_DOWNLOAD_DIR, `wx_${key}${ext}`);
|
|
633
568
|
writeFileSync(localPath, data);
|
|
634
|
-
|
|
569
|
+
platformLog(`视频已下载: ${localPath}`);
|
|
635
570
|
return localPath;
|
|
636
571
|
}
|
|
637
572
|
|
|
638
|
-
async function
|
|
573
|
+
async function handleWechatMessage(
|
|
639
574
|
message: WeixinMessage,
|
|
640
|
-
|
|
641
|
-
): Promise<
|
|
575
|
+
handler: MessageHandler,
|
|
576
|
+
): Promise<void> {
|
|
577
|
+
const chatId = String(message.from_user_id ?? "");
|
|
578
|
+
if (!chatId) {
|
|
579
|
+
platformLog("跳过无 from_user_id 的消息");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// 保存 lastChatId 供下次启动时发送通知
|
|
584
|
+
const current = readSnapshot();
|
|
585
|
+
if (current.lastChatId !== chatId) {
|
|
586
|
+
updateSnapshot({ lastChatId: chatId });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 保存 context_token 到 snapshot(供重启后启动通知使用)和内存
|
|
590
|
+
if (message.context_token) {
|
|
591
|
+
contextTokenMap.set(chatId, message.context_token);
|
|
592
|
+
updateSnapshot({ contextToken: message.context_token, lastChatId: chatId });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const text = extractText(message).trim();
|
|
596
|
+
const msgTimestamp = message.create_time_ms ?? Date.now();
|
|
597
|
+
|
|
598
|
+
// 检测并下载图片/文件/视频
|
|
642
599
|
const imagePaths: string[] = [];
|
|
643
600
|
const filePaths: string[] = [];
|
|
644
601
|
const videoPaths: string[] = [];
|
|
645
|
-
const messageLines: string[] = [];
|
|
646
602
|
const items = message.item_list;
|
|
647
603
|
if (items) {
|
|
648
604
|
for (const item of items) {
|
|
649
605
|
if (item.image_item?.media) {
|
|
650
606
|
try {
|
|
651
|
-
const localPath = await downloadWechatImage(item.image_item, message.message_id
|
|
607
|
+
const localPath = await downloadWechatImage(item.image_item, message.message_id);
|
|
652
608
|
imagePaths.push(localPath);
|
|
653
|
-
messageLines.push(`[图片] ${localPath}`);
|
|
654
609
|
} catch (err) {
|
|
655
|
-
|
|
610
|
+
platformLog(`图片下载失败: ${(err as Error).message}`);
|
|
656
611
|
}
|
|
657
612
|
}
|
|
658
|
-
|
|
659
613
|
if (item.file_item?.media) {
|
|
660
614
|
try {
|
|
661
|
-
const localPath = await downloadWechatFile(item.file_item, message.message_id
|
|
615
|
+
const localPath = await downloadWechatFile(item.file_item, message.message_id);
|
|
662
616
|
filePaths.push(localPath);
|
|
663
|
-
messageLines.push(`[文件] ${localPath}`);
|
|
664
617
|
} catch (err) {
|
|
665
|
-
|
|
618
|
+
platformLog(`文件下载失败: ${(err as Error).message}`);
|
|
666
619
|
}
|
|
667
620
|
}
|
|
668
|
-
|
|
669
621
|
if (item.video_item?.media) {
|
|
670
622
|
try {
|
|
671
|
-
const localPath = await downloadWechatVideo(item.video_item, message.message_id
|
|
623
|
+
const localPath = await downloadWechatVideo(item.video_item, message.message_id);
|
|
672
624
|
videoPaths.push(localPath);
|
|
673
|
-
messageLines.push(`[视频] ${localPath}`);
|
|
674
625
|
} catch (err) {
|
|
675
|
-
|
|
626
|
+
platformLog(`视频下载失败: ${(err as Error).message}`);
|
|
676
627
|
}
|
|
677
628
|
}
|
|
678
629
|
}
|
|
679
630
|
}
|
|
680
631
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
export async function _downloadWechatMediaAttachmentsForTest(
|
|
690
|
-
message: WeixinMessage,
|
|
691
|
-
options: WechatMediaDownloadOptions,
|
|
692
|
-
): Promise<WechatDownloadedMediaAttachments> {
|
|
693
|
-
return downloadWechatMediaAttachments(message, options);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
async function handleWechatMessage(
|
|
697
|
-
message: WeixinMessage,
|
|
698
|
-
handler: MessageHandler,
|
|
699
|
-
): Promise<void> {
|
|
700
|
-
const chatId = String(message.from_user_id ?? "");
|
|
701
|
-
if (!chatId) {
|
|
702
|
-
platformLog("跳过无 from_user_id 的消息");
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// 保存 lastChatId 供下次启动时发送通知
|
|
707
|
-
const current = readSnapshot();
|
|
708
|
-
if (current.lastChatId !== chatId) {
|
|
709
|
-
updateSnapshot({ lastChatId: chatId });
|
|
632
|
+
// 构建消息文本:文本内容 + 图片/文件/视频路径
|
|
633
|
+
let fullText = text;
|
|
634
|
+
if (imagePaths.length > 0) {
|
|
635
|
+
const imageLines = imagePaths.map((p) => `[图片] ${p}`).join("\n");
|
|
636
|
+
fullText = fullText ? `${fullText}\n${imageLines}` : imageLines;
|
|
710
637
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
contextTokenMap.set(chatId, message.context_token);
|
|
715
|
-
updateSnapshot({ contextToken: message.context_token, lastChatId: chatId });
|
|
638
|
+
if (filePaths.length > 0) {
|
|
639
|
+
const fileLines = filePaths.map((p) => `[文件] ${p}`).join("\n");
|
|
640
|
+
fullText = fullText ? `${fullText}\n${fileLines}` : fileLines;
|
|
716
641
|
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const mediaAttachments = await downloadWechatMediaAttachments(message);
|
|
722
|
-
|
|
723
|
-
// 构建消息文本:文本内容 + 媒体路径
|
|
724
|
-
let fullText = text;
|
|
725
|
-
if (mediaAttachments.messageLines.length > 0) {
|
|
726
|
-
const mediaLines = mediaAttachments.messageLines.join("\n");
|
|
727
|
-
fullText = fullText ? `${fullText}\n${mediaLines}` : mediaLines;
|
|
642
|
+
if (videoPaths.length > 0) {
|
|
643
|
+
const videoLines = videoPaths.map((p) => `[视频] ${p}`).join("\n");
|
|
644
|
+
fullText = fullText ? `${fullText}\n${videoLines}` : videoLines;
|
|
728
645
|
}
|
|
729
646
|
|
|
730
|
-
//
|
|
647
|
+
// 纯图片且无文字时跳过(避免空消息触发会话)
|
|
731
648
|
if (!fullText.trim()) {
|
|
732
649
|
platformLog(`跳过纯媒体消息(无文本): chatId=${chatId}`);
|
|
733
650
|
return;
|
|
734
651
|
}
|
|
735
652
|
|
|
736
653
|
platformLog(
|
|
737
|
-
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${
|
|
654
|
+
`收到消息: chatId=${chatId} text="${text.slice(0, 80)}" images=${imagePaths.length} files=${filePaths.length} videos=${videoPaths.length}`,
|
|
738
655
|
);
|
|
739
656
|
appendChatLog(chatId, chatId, fullText);
|
|
740
657
|
|