chatccc 0.2.26 → 0.2.27
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/im-skills/feishu-skill/download-video.mjs +162 -0
- package/im-skills/feishu-skill/send-file.md +45 -0
- package/im-skills/feishu-skill/send-file.mjs +52 -0
- package/im-skills/feishu-skill/send-image.md +31 -0
- package/im-skills/feishu-skill/send-image.mjs +52 -0
- package/im-skills/feishu-skill/skill.md +11 -0
- package/package.json +2 -1
- package/src/__tests__/agent-image-rpc.test.ts +2 -0
- package/src/__tests__/agent-rpc-body.test.ts +41 -0
- package/src/__tests__/im-skills.test.ts +50 -0
- package/src/agent-file-rpc.ts +155 -0
- package/src/agent-image-rpc.ts +4 -20
- package/src/agent-rpc-body.ts +48 -0
- package/src/feishu-api.ts +122 -0
- package/src/im-skills.ts +109 -0
- package/src/index.ts +13 -2
- package/src/session.ts +33 -8
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const DOWNLOAD_DIR = join(homedir(), ".chatccc", "videos", "downloads");
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const result = {};
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const key = argv[i];
|
|
14
|
+
if (!key.startsWith("--")) continue;
|
|
15
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
16
|
+
result[key.slice(2)] = value;
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function walkUpForConfig(startDir) {
|
|
22
|
+
const result = [];
|
|
23
|
+
let dir = resolve(startDir);
|
|
24
|
+
for (let i = 0; i < 6; i++) {
|
|
25
|
+
result.push(join(dir, "config.json"));
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
if (parent === dir) break;
|
|
28
|
+
dir = parent;
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function findConfig() {
|
|
34
|
+
const paths = [
|
|
35
|
+
join(homedir(), ".chatccc", "config.json"),
|
|
36
|
+
...walkUpForConfig(SCRIPT_DIR),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const path of paths) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(path, "utf-8");
|
|
42
|
+
const cfg = JSON.parse(raw);
|
|
43
|
+
const appId = cfg.feishu?.appId || "";
|
|
44
|
+
const appSecret = cfg.feishu?.appSecret || "";
|
|
45
|
+
if (appId && appSecret) {
|
|
46
|
+
console.error(`Using config: ${path}`);
|
|
47
|
+
return { appId, appSecret };
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Try the next candidate.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Could not find Feishu config. Tried: ${paths.slice(0, 3).join(", ")}...`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function getTenantAccessToken(appId, appSecret) {
|
|
57
|
+
const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
60
|
+
body: Buffer.from(JSON.stringify({ app_id: appId, app_secret: appSecret }), "utf8"),
|
|
61
|
+
});
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
if (data.code !== 0) {
|
|
64
|
+
throw new Error(`Failed to get tenant_access_token: [${data.code}] ${data.msg}`);
|
|
65
|
+
}
|
|
66
|
+
return data.tenant_access_token;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function findMessageId(token, chatId, fileKey) {
|
|
70
|
+
let pageToken = "";
|
|
71
|
+
for (let page = 0; page < 10; page++) {
|
|
72
|
+
const url = new URL("https://open.feishu.cn/open-apis/im/v1/messages");
|
|
73
|
+
url.searchParams.set("receive_id_type", "chat_id");
|
|
74
|
+
url.searchParams.set("receive_id", chatId);
|
|
75
|
+
url.searchParams.set("page_size", "50");
|
|
76
|
+
if (pageToken) url.searchParams.set("page_token", pageToken);
|
|
77
|
+
|
|
78
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
79
|
+
const data = await response.json();
|
|
80
|
+
if (data.code !== 0) {
|
|
81
|
+
throw new Error(`Failed to list messages: [${data.code}] ${data.msg}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const item of data.data?.items ?? []) {
|
|
85
|
+
try {
|
|
86
|
+
if (JSON.parse(item.body?.content ?? "{}").file_key === fileKey) {
|
|
87
|
+
return item.message_id;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Ignore non-file messages.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!data.data?.has_more) break;
|
|
95
|
+
pageToken = data.data?.page_token || "";
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function safeFileName(name) {
|
|
101
|
+
return (name || "download.bin").replace(/[\\/:*?"<>|]/g, "_");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function downloadResource(token, messageId, fileKey, fileName) {
|
|
105
|
+
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=file`;
|
|
106
|
+
console.error(`Downloading: ${url}`);
|
|
107
|
+
|
|
108
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const text = await response.text().catch(() => "");
|
|
111
|
+
throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await mkdir(DOWNLOAD_DIR, { recursive: true });
|
|
115
|
+
const localPath = resolve(DOWNLOAD_DIR, safeFileName(fileName));
|
|
116
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
117
|
+
await writeFile(localPath, buffer);
|
|
118
|
+
return localPath;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function usage() {
|
|
122
|
+
console.error(`Usage:
|
|
123
|
+
node download-video.mjs --message-id <message_id> --file-key <file_key> [--name <file_name>]
|
|
124
|
+
node download-video.mjs --chat-id <chat_id> --file-key <file_key> [--name <file_name>]`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function main() {
|
|
128
|
+
const args = parseArgs(process.argv.slice(2));
|
|
129
|
+
if (!args["file-key"]) {
|
|
130
|
+
usage();
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { appId, appSecret } = await findConfig();
|
|
135
|
+
const token = await getTenantAccessToken(appId, appSecret);
|
|
136
|
+
|
|
137
|
+
let messageId = args["message-id"] || "";
|
|
138
|
+
if (!messageId && args["chat-id"]) {
|
|
139
|
+
messageId = await findMessageId(token, args["chat-id"], args["file-key"]);
|
|
140
|
+
if (!messageId) {
|
|
141
|
+
throw new Error(`No message found for file_key=${args["file-key"]}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!messageId) {
|
|
146
|
+
usage();
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const localPath = await downloadResource(
|
|
151
|
+
token,
|
|
152
|
+
messageId,
|
|
153
|
+
args["file-key"],
|
|
154
|
+
args.name || "download.bin",
|
|
155
|
+
);
|
|
156
|
+
console.log(localPath);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch((err) => {
|
|
160
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Sending & Downloading Files/Videos
|
|
2
|
+
|
|
3
|
+
## Send Files or Videos
|
|
4
|
+
|
|
5
|
+
Videos are sent as regular files (not media), which looks cleaner in Feishu.
|
|
6
|
+
|
|
7
|
+
### Script (recommended)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
node "{{send_file_script}}" --url "{{send_file_url}}" --token "{{send_file_token}}" --path "<absolute file path>" --caption "<optional caption>"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Direct HTTP
|
|
14
|
+
|
|
15
|
+
```http
|
|
16
|
+
POST {{send_file_url}}
|
|
17
|
+
Authorization: Bearer {{send_file_token}}
|
|
18
|
+
Content-Type: application/json; charset=utf-8
|
|
19
|
+
|
|
20
|
+
{"path":"<absolute file path>","caption":"<optional caption>"}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Rules
|
|
24
|
+
|
|
25
|
+
- Save or choose a local file first.
|
|
26
|
+
- Use an absolute local path.
|
|
27
|
+
- Max file size: 100MB.
|
|
28
|
+
- Supported formats: .mp4 .mov .avi .mkv .webm .flv .mp3 .wav .ogg .aac .m4a .pdf .doc .docx .xls .xlsx .csv .ppt .pptx .txt .zip .tar .gz.
|
|
29
|
+
- Only send a file/video when the user asked for one or when it materially helps the answer.
|
|
30
|
+
|
|
31
|
+
## Download Files or Videos
|
|
32
|
+
|
|
33
|
+
When the user sends a file or video to the bot, the message contains `message_id` and `file_key`. Download it with:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
node "{{download_video_script}}" --message-id <message_id> --file-key <file_key> --name <file_name>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If only `chat_id` and `file_key` are available:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
node "{{download_video_script}}" --chat-id <chat_id> --file-key <file_key> --name <file_name>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Downloads are saved under `~/.chatccc/videos/downloads/`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
function parseArgs(argv) {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (let i = 0; i < argv.length; i++) {
|
|
7
|
+
const key = argv[i];
|
|
8
|
+
if (!key.startsWith("--")) continue;
|
|
9
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
10
|
+
result[key.slice(2)] = value;
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function usage() {
|
|
16
|
+
console.error(`Usage:
|
|
17
|
+
node ${basename(process.argv[1])} --url <url> --token <token> --path <absolute file path> [--caption <text>]`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
const args = parseArgs(process.argv.slice(2));
|
|
22
|
+
const url = args.url || process.env.CHATCCC_SEND_FILE_URL;
|
|
23
|
+
const token = args.token || process.env.CHATCCC_SEND_FILE_TOKEN;
|
|
24
|
+
const path = args.path;
|
|
25
|
+
const caption = args.caption || "";
|
|
26
|
+
|
|
27
|
+
if (!url || !token || !path) {
|
|
28
|
+
usage();
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const body = Buffer.from(JSON.stringify({ path, caption }), "utf8");
|
|
33
|
+
const response = await fetch(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
38
|
+
},
|
|
39
|
+
body,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const text = await response.text();
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main().catch((err) => {
|
|
50
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Sending & Receiving Images
|
|
2
|
+
|
|
3
|
+
## Send Images
|
|
4
|
+
|
|
5
|
+
### Script (recommended)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
node "{{send_image_script}}" --url "{{send_image_url}}" --token "{{send_image_token}}" --path "<absolute image path>" --caption "<optional caption>"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### Direct HTTP
|
|
12
|
+
|
|
13
|
+
```http
|
|
14
|
+
POST {{send_image_url}}
|
|
15
|
+
Authorization: Bearer {{send_image_token}}
|
|
16
|
+
Content-Type: application/json; charset=utf-8
|
|
17
|
+
|
|
18
|
+
{"path":"<absolute image path>","caption":"<optional caption>"}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Rules
|
|
22
|
+
|
|
23
|
+
- Save or choose a local image file first.
|
|
24
|
+
- Use an absolute local path.
|
|
25
|
+
- Supported formats: .png, .jpg, .jpeg, .webp, .gif, .bmp.
|
|
26
|
+
- Max image size: 10MB.
|
|
27
|
+
- Only send an image when the user asked for one or when it materially helps the answer.
|
|
28
|
+
|
|
29
|
+
## Receive Images
|
|
30
|
+
|
|
31
|
+
Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/`. The message contains an `image_key` that maps to the cached file.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
function parseArgs(argv) {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (let i = 0; i < argv.length; i++) {
|
|
7
|
+
const key = argv[i];
|
|
8
|
+
if (!key.startsWith("--")) continue;
|
|
9
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
10
|
+
result[key.slice(2)] = value;
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function usage() {
|
|
16
|
+
console.error(`Usage:
|
|
17
|
+
node ${basename(process.argv[1])} --url <url> --token <token> --path <absolute image path> [--caption <text>]`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
const args = parseArgs(process.argv.slice(2));
|
|
22
|
+
const url = args.url || process.env.CHATCCC_SEND_IMAGE_URL;
|
|
23
|
+
const token = args.token || process.env.CHATCCC_SEND_IMAGE_TOKEN;
|
|
24
|
+
const path = args.path;
|
|
25
|
+
const caption = args.caption || "";
|
|
26
|
+
|
|
27
|
+
if (!url || !token || !path) {
|
|
28
|
+
usage();
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const body = Buffer.from(JSON.stringify({ path, caption }), "utf8");
|
|
33
|
+
const response = await fetch(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
38
|
+
},
|
|
39
|
+
body,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const text = await response.text();
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main().catch((err) => {
|
|
50
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feishu-skill
|
|
3
|
+
description: Feishu IM local skills for sending and receiving images, files, and videos.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Current working directory: {{cwd}}
|
|
7
|
+
|
|
8
|
+
Use local endpoints instead of calling Feishu Open Platform directly.
|
|
9
|
+
|
|
10
|
+
- **Images** (send & receive): read `{{im_skills_cache_dir}}/feishu-skill/send-image.md`
|
|
11
|
+
- **Files & Videos** (send & download): read `{{im_skills_cache_dir}}/feishu-skill/send-file.md`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.27",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"src/",
|
|
13
13
|
"bin/",
|
|
14
|
+
"im-skills/",
|
|
14
15
|
"images/img_readme_*.jpg",
|
|
15
16
|
"images/img_readme_*.png",
|
|
16
17
|
"images/avatars/status_*.png",
|
|
@@ -60,6 +60,8 @@ describe("agent image RPC grants", () => {
|
|
|
60
60
|
|
|
61
61
|
expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/send-image");
|
|
62
62
|
expect(prompt).toContain("Authorization: Bearer tok_test");
|
|
63
|
+
expect(prompt).toContain("Content-Type: application/json; charset=utf-8");
|
|
64
|
+
expect(prompt).toContain("UTF-8 encoded JSON bytes");
|
|
63
65
|
expect(prompt).toContain("\"path\"");
|
|
64
66
|
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_URL");
|
|
65
67
|
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_TOKEN");
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { getRequestCharset, readUtf8JsonBody } from "../agent-rpc-body.ts";
|
|
7
|
+
|
|
8
|
+
function requestFrom(body: Buffer, contentType?: string): IncomingMessage {
|
|
9
|
+
const req = Readable.from([body]) as IncomingMessage;
|
|
10
|
+
req.headers = {};
|
|
11
|
+
if (contentType) req.headers["content-type"] = contentType;
|
|
12
|
+
return req;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("agent RPC body parsing", () => {
|
|
16
|
+
it("defaults JSON requests to UTF-8 and preserves Unicode captions", async () => {
|
|
17
|
+
const caption = "\u4e2d\u6587 caption";
|
|
18
|
+
const body = Buffer.from(JSON.stringify({ caption }), "utf8");
|
|
19
|
+
const req = requestFrom(body, "application/json");
|
|
20
|
+
|
|
21
|
+
await expect(readUtf8JsonBody(req, 1024)).resolves.toEqual({ caption });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("reads charset from content-type", () => {
|
|
25
|
+
const req = requestFrom(Buffer.from("{}"), 'application/json; charset="utf-8"');
|
|
26
|
+
|
|
27
|
+
expect(getRequestCharset(req)).toBe("utf-8");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects non UTF-8 charsets", async () => {
|
|
31
|
+
const req = requestFrom(Buffer.from("{}"), "application/json; charset=gbk");
|
|
32
|
+
|
|
33
|
+
await expect(readUtf8JsonBody(req, 1024)).rejects.toThrow("Unsupported charset");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects invalid UTF-8 bytes", async () => {
|
|
37
|
+
const req = requestFrom(Buffer.from([0xff, 0xfe]), "application/json; charset=utf-8");
|
|
38
|
+
|
|
39
|
+
await expect(readUtf8JsonBody(req, 1024)).rejects.toThrow("valid UTF-8");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { buildImSkillsPrompt } from "../im-skills.ts";
|
|
7
|
+
|
|
8
|
+
let tempRoot: string | null = null;
|
|
9
|
+
|
|
10
|
+
describe("IM skills prompt rendering", () => {
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
if (tempRoot) await rm(tempRoot, { recursive: true, force: true });
|
|
13
|
+
tempRoot = null;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("loads skill.md files and renders runtime variables", async () => {
|
|
17
|
+
tempRoot = await mkdtemp(join(tmpdir(), "chatccc-im-skills-"));
|
|
18
|
+
const skillDir = join(tempRoot, "feishu-skill");
|
|
19
|
+
await mkdir(skillDir);
|
|
20
|
+
await writeFile(
|
|
21
|
+
join(skillDir, "skill.md"),
|
|
22
|
+
[
|
|
23
|
+
"---",
|
|
24
|
+
"name: feishu-skill",
|
|
25
|
+
"---",
|
|
26
|
+
"POST {{send_image_url}}",
|
|
27
|
+
"Authorization: Bearer {{send_image_token}}",
|
|
28
|
+
"Content-Type: application/json; charset=utf-8",
|
|
29
|
+
"cwd={{cwd}}",
|
|
30
|
+
].join("\n"),
|
|
31
|
+
"utf-8",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const prompt = await buildImSkillsPrompt({
|
|
35
|
+
skillsDir: tempRoot,
|
|
36
|
+
variables: {
|
|
37
|
+
cwd: "C:/work",
|
|
38
|
+
send_image_url: "http://127.0.0.1:18080/api/agent/send-image",
|
|
39
|
+
send_image_token: "tok_test",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(prompt).toContain("[ChatCCC IM skill: feishu-skill]");
|
|
44
|
+
expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/send-image");
|
|
45
|
+
expect(prompt).toContain("Authorization: Bearer tok_test");
|
|
46
|
+
expect(prompt).toContain("Content-Type: application/json; charset=utf-8");
|
|
47
|
+
expect(prompt).toContain("cwd=C:/work");
|
|
48
|
+
expect(prompt).not.toContain("{{");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
import { getTenantAccessToken, sendFileReply, sendTextReply } from "./feishu-api.ts";
|
|
7
|
+
import { ts } from "./config.ts";
|
|
8
|
+
import { logTrace } from "./trace.ts";
|
|
9
|
+
import { readUtf8JsonBody } from "./agent-rpc-body.ts";
|
|
10
|
+
|
|
11
|
+
export const AGENT_SEND_FILE_PATH = "/api/agent/send-file";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
|
|
14
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
15
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
16
|
+
const ALLOWED_FILE_EXTS = new Set([
|
|
17
|
+
".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv",
|
|
18
|
+
".mp3", ".wav", ".ogg", ".aac", ".m4a",
|
|
19
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".ppt", ".pptx",
|
|
20
|
+
".txt", ".zip", ".tar", ".gz",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export interface AgentFileGrant {
|
|
24
|
+
token: string;
|
|
25
|
+
url: string;
|
|
26
|
+
chatId: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
traceId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fileGrants = new Map<string, AgentFileGrant>();
|
|
34
|
+
|
|
35
|
+
function createToken(): string {
|
|
36
|
+
return randomBytes(24).toString("base64url");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createAgentFileGrant(input: {
|
|
40
|
+
chatId: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
cwd: string;
|
|
43
|
+
port: number;
|
|
44
|
+
traceId?: string;
|
|
45
|
+
nowMs?: number;
|
|
46
|
+
ttlMs?: number;
|
|
47
|
+
}): AgentFileGrant {
|
|
48
|
+
const now = input.nowMs ?? Date.now();
|
|
49
|
+
const token = createToken();
|
|
50
|
+
const grant: AgentFileGrant = {
|
|
51
|
+
token,
|
|
52
|
+
url: `http://127.0.0.1:${input.port}${AGENT_SEND_FILE_PATH}`,
|
|
53
|
+
chatId: input.chatId,
|
|
54
|
+
sessionId: input.sessionId,
|
|
55
|
+
cwd: input.cwd,
|
|
56
|
+
expiresAt: now + (input.ttlMs ?? DEFAULT_GRANT_TTL_MS),
|
|
57
|
+
traceId: input.traceId ?? "",
|
|
58
|
+
};
|
|
59
|
+
fileGrants.set(token, grant);
|
|
60
|
+
if (grant.traceId) logTrace(grant.traceId, "FILE_GRANT_CREATED", { sessionId: grant.sessionId });
|
|
61
|
+
return grant;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function revokeAgentFileGrant(token: string): void {
|
|
65
|
+
const grant = fileGrants.get(token);
|
|
66
|
+
if (grant?.traceId) logTrace(grant.traceId, "FILE_GRANT_REVOKED", {});
|
|
67
|
+
fileGrants.delete(token);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getAgentFileGrantFromAuthorization(authorization: string | undefined, nowMs = Date.now()): AgentFileGrant | null {
|
|
71
|
+
const match = (authorization ?? "").match(/^Bearer\s+(.+)$/i);
|
|
72
|
+
if (!match) return null;
|
|
73
|
+
const grant = fileGrants.get(match[1]);
|
|
74
|
+
if (!grant) return null;
|
|
75
|
+
if (grant.expiresAt <= nowMs) { fileGrants.delete(match[1]); return null; }
|
|
76
|
+
return grant;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildAgentFileCapabilityPrompt(input: { url: string; token: string; cwd?: string }): string {
|
|
80
|
+
const lines = [
|
|
81
|
+
"[ChatCCC local capability: send file]",
|
|
82
|
+
"You can send a file (video, audio, document, etc.) to the current Feishu chat in real time by calling this local endpoint.",
|
|
83
|
+
"",
|
|
84
|
+
`POST ${input.url}`,
|
|
85
|
+
`Authorization: Bearer ${input.token}`,
|
|
86
|
+
"Content-Type: application/json; charset=utf-8",
|
|
87
|
+
"",
|
|
88
|
+
'Body: {"path":"absolute file path","caption":"optional caption"}',
|
|
89
|
+
"",
|
|
90
|
+
"Rules:",
|
|
91
|
+
"- Save or choose a local file first, then call the endpoint.",
|
|
92
|
+
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
93
|
+
"- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
|
|
94
|
+
"- Only call this endpoint when the user asked for a file/video or when a file is useful to the answer.",
|
|
95
|
+
"- Max file size: 100MB.",
|
|
96
|
+
"[/ChatCCC local capability: send file]",
|
|
97
|
+
];
|
|
98
|
+
if (input.cwd) lines.splice(2, 0, `Current working directory: ${input.cwd}`);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
103
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
104
|
+
res.end(JSON.stringify(data));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolveAndValidateFilePath(grant: AgentFileGrant, rawPath: unknown): Promise<string> {
|
|
108
|
+
if (typeof rawPath !== "string" || rawPath.trim() === "") throw new Error("path must be a non-empty string");
|
|
109
|
+
const sessionRoot = resolve(grant.cwd);
|
|
110
|
+
const filePath = isAbsolute(rawPath) ? resolve(rawPath) : resolve(sessionRoot, rawPath);
|
|
111
|
+
const ext = extname(filePath).toLowerCase();
|
|
112
|
+
if (!ALLOWED_FILE_EXTS.has(ext)) throw new Error(`unsupported file extension: ${ext || "(none)"}`);
|
|
113
|
+
const st = await stat(filePath);
|
|
114
|
+
if (!st.isFile()) throw new Error("file path is not a file");
|
|
115
|
+
if (st.size <= 0) throw new Error("file is empty");
|
|
116
|
+
if (st.size > MAX_FILE_BYTES) throw new Error(`file is larger than ${MAX_FILE_BYTES / 1024 / 1024}MB`);
|
|
117
|
+
return filePath;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function handleAgentFileRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
121
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
122
|
+
if (url.pathname !== AGENT_SEND_FILE_PATH) return false;
|
|
123
|
+
if (req.method !== "POST") { jsonReply(res, 405, { ok: false, error: "Method not allowed" }); return true; }
|
|
124
|
+
|
|
125
|
+
const grant = getAgentFileGrantFromAuthorization(req.headers.authorization);
|
|
126
|
+
if (!grant) { jsonReply(res, 401, { ok: false, error: "Invalid or expired file-send token" }); return true; }
|
|
127
|
+
const tid = grant.traceId;
|
|
128
|
+
|
|
129
|
+
let payload: { path?: unknown; caption?: unknown };
|
|
130
|
+
try { payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES); } catch (err) {
|
|
131
|
+
if (tid) logTrace(tid, "FILE_REQ", { outcome: "invalid_json", error: (err as Error).message });
|
|
132
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" }); return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let filePath: string;
|
|
136
|
+
try { filePath = await resolveAndValidateFilePath(grant, payload.path); } catch (err) {
|
|
137
|
+
if (tid) logTrace(tid, "FILE_REQ", { outcome: "invalid_path", path: String(payload.path), error: (err as Error).message });
|
|
138
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message }); return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const token = await getTenantAccessToken();
|
|
143
|
+
const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
|
|
144
|
+
await sendFileReply(token, grant.chatId, filePath);
|
|
145
|
+
if (caption) await sendTextReply(token, grant.chatId, caption);
|
|
146
|
+
if (tid) logTrace(tid, "FILE_REQ", { outcome: "sent", path: filePath, hasCaption: !!caption });
|
|
147
|
+
console.log(`[${ts()}] [AGENT-FILE] sent file chat=${grant.chatId} session=${grant.sessionId} path=${filePath}`);
|
|
148
|
+
jsonReply(res, 200, { ok: true });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
if (tid) logTrace(tid, "FILE_REQ", { outcome: "send_failed", path: filePath!, error: (err as Error).message });
|
|
151
|
+
console.error(`[${ts()}] [AGENT-FILE] send failed: ${(err as Error).message}`);
|
|
152
|
+
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
package/src/agent-image-rpc.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { stat } from "node:fs/promises";
|
|
|
6
6
|
import { getTenantAccessToken, sendImageReply, sendTextReply } from "./feishu-api.ts";
|
|
7
7
|
import { ts } from "./config.ts";
|
|
8
8
|
import { logTrace } from "./trace.ts";
|
|
9
|
+
import { readUtf8JsonBody } from "./agent-rpc-body.ts";
|
|
9
10
|
|
|
10
11
|
export const AGENT_SEND_IMAGE_PATH = "/api/agent/send-image";
|
|
11
12
|
|
|
@@ -89,13 +90,14 @@ export function buildAgentImageCapabilityPrompt(input: {
|
|
|
89
90
|
"",
|
|
90
91
|
`POST ${input.url}`,
|
|
91
92
|
`Authorization: Bearer ${input.token}`,
|
|
92
|
-
"Content-Type: application/json",
|
|
93
|
+
"Content-Type: application/json; charset=utf-8",
|
|
93
94
|
"",
|
|
94
95
|
'Body: {"path":"absolute image file path","caption":"optional caption"}',
|
|
95
96
|
"",
|
|
96
97
|
"Rules:",
|
|
97
98
|
"- Save or choose a local image file first, then call the endpoint.",
|
|
98
99
|
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
100
|
+
"- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
|
|
99
101
|
"- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
|
|
100
102
|
"[/ChatCCC local capability: send image]",
|
|
101
103
|
];
|
|
@@ -110,24 +112,6 @@ function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
|
110
112
|
res.end(JSON.stringify(data));
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
function readLimitedBody(req: IncomingMessage): Promise<string> {
|
|
114
|
-
return new Promise((resolve, reject) => {
|
|
115
|
-
let body = "";
|
|
116
|
-
let size = 0;
|
|
117
|
-
req.on("data", (chunk: Buffer) => {
|
|
118
|
-
size += chunk.length;
|
|
119
|
-
if (size > MAX_REQUEST_BYTES) {
|
|
120
|
-
reject(new Error("Request body too large"));
|
|
121
|
-
req.destroy();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
body += chunk.toString("utf8");
|
|
125
|
-
});
|
|
126
|
-
req.on("end", () => resolve(body));
|
|
127
|
-
req.on("error", reject);
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
115
|
async function resolveAndValidateImagePath(grant: AgentImageGrant, rawPath: unknown): Promise<string> {
|
|
132
116
|
if (typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
133
117
|
throw new Error("path must be a non-empty string");
|
|
@@ -171,7 +155,7 @@ export async function handleAgentImageRequest(
|
|
|
171
155
|
|
|
172
156
|
let payload: { path?: unknown; caption?: unknown };
|
|
173
157
|
try {
|
|
174
|
-
payload =
|
|
158
|
+
payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
|
|
175
159
|
} catch (err) {
|
|
176
160
|
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_json", error: (err as Error).message });
|
|
177
161
|
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import { TextDecoder } from "node:util";
|
|
3
|
+
|
|
4
|
+
function normalizeHeaderValue(value: string | string[] | undefined): string {
|
|
5
|
+
if (Array.isArray(value)) return value.join("; ");
|
|
6
|
+
return value ?? "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getRequestCharset(req: IncomingMessage): string {
|
|
10
|
+
const contentType = normalizeHeaderValue(req.headers["content-type"]);
|
|
11
|
+
const match = contentType.match(/(?:^|;)\s*charset\s*=\s*("?)([^";\s]+)\1/i);
|
|
12
|
+
return (match?.[2] ?? "utf-8").toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function readLimitedBodyBuffer(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
|
16
|
+
const chunks: Buffer[] = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
req.on("data", (chunk: Buffer) => {
|
|
21
|
+
size += chunk.length;
|
|
22
|
+
if (size > maxBytes) {
|
|
23
|
+
reject(new Error("Request body too large"));
|
|
24
|
+
req.destroy();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
chunks.push(chunk);
|
|
28
|
+
});
|
|
29
|
+
req.on("end", () => resolve(Buffer.concat(chunks, size)));
|
|
30
|
+
req.on("error", reject);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readUtf8JsonBody<T>(req: IncomingMessage, maxBytes: number): Promise<T> {
|
|
35
|
+
const charset = getRequestCharset(req);
|
|
36
|
+
if (charset !== "utf-8" && charset !== "utf8") {
|
|
37
|
+
throw new Error(`Unsupported charset: ${charset}. Use UTF-8 JSON.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const body = await readLimitedBodyBuffer(req, maxBytes);
|
|
41
|
+
let text: string;
|
|
42
|
+
try {
|
|
43
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error("Request body must be valid UTF-8 JSON");
|
|
46
|
+
}
|
|
47
|
+
return JSON.parse(text) as T;
|
|
48
|
+
}
|
package/src/feishu-api.ts
CHANGED
|
@@ -621,6 +621,128 @@ export async function sendImageReply(
|
|
|
621
621
|
}
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
function feishuFileType(ext: string): string {
|
|
625
|
+
const map: Record<string, string> = {
|
|
626
|
+
".mp4": "mp4",
|
|
627
|
+
".mp3": "opus", ".wav": "opus", ".ogg": "opus", ".aac": "opus", ".m4a": "opus",
|
|
628
|
+
".pdf": "pdf", ".doc": "doc", ".docx": "doc",
|
|
629
|
+
".xls": "xls", ".xlsx": "xls", ".csv": "xls",
|
|
630
|
+
".ppt": "ppt", ".pptx": "ppt",
|
|
631
|
+
};
|
|
632
|
+
return map[ext] ?? "stream";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const VIDEO_EXTENSIONS = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv"];
|
|
636
|
+
|
|
637
|
+
function parseMediaDurationMs(buffer: Buffer): number {
|
|
638
|
+
try {
|
|
639
|
+
let offset = 0;
|
|
640
|
+
while (offset + 8 <= buffer.length) {
|
|
641
|
+
const size = buffer.readUInt32BE(offset);
|
|
642
|
+
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
|
643
|
+
if (size < 8 || offset + size > buffer.length) break;
|
|
644
|
+
if (type === "moov") {
|
|
645
|
+
let mo = offset + 8;
|
|
646
|
+
const moEnd = offset + size;
|
|
647
|
+
while (mo + 8 <= moEnd) {
|
|
648
|
+
const s = buffer.readUInt32BE(mo);
|
|
649
|
+
const t = buffer.toString("ascii", mo + 4, mo + 8);
|
|
650
|
+
if (s < 8 || mo + s > moEnd) break;
|
|
651
|
+
if (t === "mvhd") {
|
|
652
|
+
const ds = mo + 8;
|
|
653
|
+
const ver = buffer[ds];
|
|
654
|
+
let timescale: number, duration: number;
|
|
655
|
+
if (ver === 1) {
|
|
656
|
+
timescale = buffer.readUInt32BE(ds + 20);
|
|
657
|
+
duration = Number(buffer.readBigUInt64BE(ds + 24));
|
|
658
|
+
} else {
|
|
659
|
+
timescale = buffer.readUInt32BE(ds + 12);
|
|
660
|
+
duration = buffer.readUInt32BE(ds + 16);
|
|
661
|
+
}
|
|
662
|
+
if (timescale <= 0) return 0;
|
|
663
|
+
return Math.round((duration / timescale) * 1000);
|
|
664
|
+
}
|
|
665
|
+
mo += s;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
offset += size;
|
|
669
|
+
}
|
|
670
|
+
} catch {}
|
|
671
|
+
return 0;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function uploadMessageFile(token: string, filePath: string): Promise<string> {
|
|
675
|
+
const ext = extname(filePath).toLowerCase();
|
|
676
|
+
const isVideo = VIDEO_EXTENSIONS.includes(ext);
|
|
677
|
+
const buffer = await readFile(filePath);
|
|
678
|
+
const fileName = filePath.split(/[\\/]/).pop() || "file";
|
|
679
|
+
const fileType = isVideo ? "stream" : feishuFileType(ext);
|
|
680
|
+
const blob = new Blob([new Uint8Array(buffer)], { type: isVideo ? "video/mp4" : "application/octet-stream" });
|
|
681
|
+
const form = new FormData();
|
|
682
|
+
form.append("file_type", fileType);
|
|
683
|
+
form.append("file_name", fileName);
|
|
684
|
+
// 音频文件传 duration(opus 需要)
|
|
685
|
+
if (fileType === "opus") {
|
|
686
|
+
const durationMs = parseMediaDurationMs(buffer);
|
|
687
|
+
if (durationMs > 0) form.append("duration", String(durationMs));
|
|
688
|
+
}
|
|
689
|
+
form.append("file", blob, fileName);
|
|
690
|
+
|
|
691
|
+
const resp = await fetch(`${BASE_URL}/im/v1/files`, {
|
|
692
|
+
method: "POST",
|
|
693
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
694
|
+
body: form,
|
|
695
|
+
});
|
|
696
|
+
const text = await resp.text();
|
|
697
|
+
let data: { code: number; msg?: string; data?: { file_key?: string } };
|
|
698
|
+
try { data = JSON.parse(text); } catch {
|
|
699
|
+
throw new Error(`uploadMessageFile non-JSON response: ${text.slice(0, 200)}`);
|
|
700
|
+
}
|
|
701
|
+
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
702
|
+
const fileKey = data.data?.file_key;
|
|
703
|
+
if (!fileKey) throw new Error("uploadMessageFile response missing file_key");
|
|
704
|
+
return fileKey;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export async function sendFileReply(
|
|
708
|
+
token: string,
|
|
709
|
+
chatId: string,
|
|
710
|
+
filePath: string,
|
|
711
|
+
): Promise<boolean> {
|
|
712
|
+
try {
|
|
713
|
+
const fileKey = await uploadMessageFile(token, filePath);
|
|
714
|
+
const fileName = filePath.split(/[\\/]/).pop() || "file";
|
|
715
|
+
const ext = extname(filePath).toLowerCase();
|
|
716
|
+
// 视频/音频用 msg_type: "media",文档用 "file"
|
|
717
|
+
const isMedia = [".mp3", ".wav", ".ogg", ".aac", ".m4a"].includes(ext);
|
|
718
|
+
const msgType = isMedia ? "media" : "file";
|
|
719
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
720
|
+
method: "POST",
|
|
721
|
+
headers: {
|
|
722
|
+
Authorization: `Bearer ${token}`,
|
|
723
|
+
"Content-Type": "application/json",
|
|
724
|
+
},
|
|
725
|
+
body: JSON.stringify({
|
|
726
|
+
receive_id: chatId,
|
|
727
|
+
msg_type: msgType,
|
|
728
|
+
content: JSON.stringify({ file_key: fileKey }),
|
|
729
|
+
}),
|
|
730
|
+
});
|
|
731
|
+
const data = (await resp.json().catch(() => ({}))) as { code?: number; msg?: string; data?: { message_id?: string } };
|
|
732
|
+
if (data.code !== 0) {
|
|
733
|
+
console.error(`[${ts()}] [SEND] ${msgType} FAIL: chatId=${chatId} path=${filePath} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
734
|
+
throw new Error(`[${data.code}] ${data.msg ?? "send file failed"}`);
|
|
735
|
+
}
|
|
736
|
+
console.log(`[${ts()}] [SEND] ${msgType} OK: chatId=${chatId} name=${fileName} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
737
|
+
return true;
|
|
738
|
+
} catch (err) {
|
|
739
|
+
if (!(err instanceof Error && err.message.startsWith("["))) {
|
|
740
|
+
console.error(`[${ts()}] [SEND] file FAIL: chatId=${chatId} path=${filePath} ${(err as Error).message}`);
|
|
741
|
+
}
|
|
742
|
+
throw err;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
624
746
|
export async function addReaction(
|
|
625
747
|
token: string,
|
|
626
748
|
messageId: string,
|
package/src/im-skills.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { PROJECT_ROOT } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_IM_SKILLS_DIR = join(PROJECT_ROOT, "im-skills");
|
|
7
|
+
|
|
8
|
+
export interface ImSkillVariableMap {
|
|
9
|
+
[key: string]: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BuildImSkillsPromptInput {
|
|
13
|
+
skillsDir?: string;
|
|
14
|
+
variables: ImSkillVariableMap;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ImSkillDoc {
|
|
18
|
+
name: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderTemplate(template: string, variables: ImSkillVariableMap): string {
|
|
23
|
+
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key: string) => {
|
|
24
|
+
return variables[key] ?? "";
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function loadSkillDocs(skillsDir: string): Promise<ImSkillDoc[]> {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const docs = await Promise.all(
|
|
37
|
+
entries
|
|
38
|
+
.filter((entry) => entry.isDirectory())
|
|
39
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
40
|
+
.map(async (entry): Promise<ImSkillDoc | null> => {
|
|
41
|
+
const skillPath = join(skillsDir, entry.name, "skill.md");
|
|
42
|
+
try {
|
|
43
|
+
const content = await readFile(skillPath, "utf-8");
|
|
44
|
+
return { name: entry.name, content };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
return docs.filter((doc): doc is ImSkillDoc => doc !== null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function buildImSkillsPrompt(input: BuildImSkillsPromptInput): Promise<string> {
|
|
54
|
+
const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
|
|
55
|
+
const docs = await loadSkillDocs(skillsDir);
|
|
56
|
+
return docs
|
|
57
|
+
.map((doc) => {
|
|
58
|
+
const rendered = renderTemplate(doc.content, input.variables).trim();
|
|
59
|
+
return [
|
|
60
|
+
`[ChatCCC IM skill: ${doc.name}]`,
|
|
61
|
+
rendered,
|
|
62
|
+
`[/ChatCCC IM skill: ${doc.name}]`,
|
|
63
|
+
].join("\n");
|
|
64
|
+
})
|
|
65
|
+
.join("\n\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 渲染技能目录下的子文档(skill.md 除外)并写入 outputDir。
|
|
70
|
+
* 返回写入的文件路径列表。通常在会话初始化时调用,写入的文档供 Agent 按需读取。
|
|
71
|
+
*/
|
|
72
|
+
export async function exportSkillSubDocs(
|
|
73
|
+
input: BuildImSkillsPromptInput,
|
|
74
|
+
outputDir: string,
|
|
75
|
+
): Promise<string[]> {
|
|
76
|
+
const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
|
|
77
|
+
const files: string[] = [];
|
|
78
|
+
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isDirectory()) continue;
|
|
88
|
+
const skillPath = join(skillsDir, entry.name);
|
|
89
|
+
|
|
90
|
+
let subFiles;
|
|
91
|
+
try {
|
|
92
|
+
subFiles = await readdir(skillPath);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const sf of subFiles) {
|
|
98
|
+
if (sf === "skill.md" || !sf.endsWith(".md")) continue;
|
|
99
|
+
const content = await readFile(join(skillPath, sf), "utf-8");
|
|
100
|
+
const rendered = renderTemplate(content, input.variables);
|
|
101
|
+
const outPath = join(outputDir, entry.name, sf);
|
|
102
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
103
|
+
await writeFile(outPath, rendered, "utf-8");
|
|
104
|
+
files.push(outPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return files;
|
|
109
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
|
|
84
84
|
import { updateCardKitCard } from "./cardkit.ts";
|
|
85
85
|
import { handleAgentImageRequest } from "./agent-image-rpc.ts";
|
|
86
|
+
import { handleAgentFileRequest } from "./agent-file-rpc.ts";
|
|
86
87
|
import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
|
|
87
88
|
import {
|
|
88
89
|
MAX_PROCESSED,
|
|
@@ -160,7 +161,15 @@ async function formatMessageContent(message: { message_id?: string; message_type
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
if (message.message_type === "media") {
|
|
165
|
+
const fileKey = content.file_key as string | undefined;
|
|
166
|
+
const fileName = (content.file_name as string) || "video.mp4";
|
|
167
|
+
const messageId = message.message_id;
|
|
168
|
+
if (!fileKey || !messageId) return contentStr;
|
|
169
|
+
return `[视频] message_id=${messageId} file_key=${fileKey} file_name=${fileName}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 其他类型(file, audio, sticker)直接给原始 JSON
|
|
164
173
|
return contentStr;
|
|
165
174
|
}
|
|
166
175
|
|
|
@@ -1048,7 +1057,9 @@ async function main(): Promise<void> {
|
|
|
1048
1057
|
appIdMask: maskAppId(APP_ID),
|
|
1049
1058
|
});
|
|
1050
1059
|
});
|
|
1051
|
-
setExtraApiHandler(
|
|
1060
|
+
setExtraApiHandler(async (req, res) => {
|
|
1061
|
+
return (await handleAgentImageRequest(req, res)) || (await handleAgentFileRequest(req, res));
|
|
1062
|
+
});
|
|
1052
1063
|
|
|
1053
1064
|
console.log(`[启动 2/7] 环境与凭证检查`);
|
|
1054
1065
|
reportEnvironmentVariableReadout();
|
package/src/session.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname } from "node:path";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
CLAUDE_API_KEY,
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
CLAUDE_EFFORT,
|
|
8
8
|
CLAUDE_MODEL,
|
|
9
9
|
CHATCCC_PORT,
|
|
10
|
+
PROJECT_ROOT,
|
|
10
11
|
SESSIONS_FILE,
|
|
12
|
+
USER_DATA_DIR,
|
|
11
13
|
addRecentDir,
|
|
12
14
|
anthropicConfigDisplay,
|
|
13
15
|
config,
|
|
@@ -31,10 +33,14 @@ import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
|
31
33
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
32
34
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
33
35
|
import {
|
|
34
|
-
buildAgentImageCapabilityPrompt,
|
|
35
36
|
createAgentImageGrant,
|
|
36
37
|
revokeAgentImageGrant,
|
|
37
38
|
} from "./agent-image-rpc.ts";
|
|
39
|
+
import {
|
|
40
|
+
createAgentFileGrant,
|
|
41
|
+
revokeAgentFileGrant,
|
|
42
|
+
} from "./agent-file-rpc.ts";
|
|
43
|
+
import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
|
|
38
44
|
|
|
39
45
|
// ---------------------------------------------------------------------------
|
|
40
46
|
// Shared state (imported by index.ts)
|
|
@@ -324,13 +330,31 @@ export async function resumeAndPrompt(
|
|
|
324
330
|
port: CHATCCC_PORT,
|
|
325
331
|
traceId: tid || undefined,
|
|
326
332
|
});
|
|
333
|
+
const fileGrant = createAgentFileGrant({
|
|
334
|
+
chatId,
|
|
335
|
+
sessionId,
|
|
336
|
+
cwd,
|
|
337
|
+
port: CHATCCC_PORT,
|
|
338
|
+
traceId: tid || undefined,
|
|
339
|
+
});
|
|
340
|
+
const feishuSkillDir = join(PROJECT_ROOT, "im-skills", "feishu-skill");
|
|
341
|
+
const imSkillsCacheDir = join(USER_DATA_DIR, "im-skills");
|
|
342
|
+
const skillVariables = {
|
|
343
|
+
cwd,
|
|
344
|
+
im_skills_cache_dir: imSkillsCacheDir,
|
|
345
|
+
send_image_url: imageGrant.url,
|
|
346
|
+
send_image_token: imageGrant.token,
|
|
347
|
+
send_file_url: fileGrant.url,
|
|
348
|
+
send_file_token: fileGrant.token,
|
|
349
|
+
send_image_script: join(feishuSkillDir, "send-image.mjs"),
|
|
350
|
+
send_file_script: join(feishuSkillDir, "send-file.mjs"),
|
|
351
|
+
download_video_script: join(feishuSkillDir, "download-video.mjs"),
|
|
352
|
+
};
|
|
353
|
+
const imSkillsPrompt = await buildImSkillsPrompt({ variables: skillVariables });
|
|
354
|
+
// 渲染子文档到缓存目录,供 Agent 按需读取
|
|
355
|
+
await exportSkillSubDocs({ variables: skillVariables }, imSkillsCacheDir);
|
|
327
356
|
const userTextWithCapabilities = [
|
|
328
|
-
|
|
329
|
-
url: imageGrant.url,
|
|
330
|
-
token: imageGrant.token,
|
|
331
|
-
cwd,
|
|
332
|
-
}),
|
|
333
|
-
"",
|
|
357
|
+
...(imSkillsPrompt ? [imSkillsPrompt, ""] : []),
|
|
334
358
|
"[User message]",
|
|
335
359
|
userText,
|
|
336
360
|
"[/User message]",
|
|
@@ -491,6 +515,7 @@ export async function resumeAndPrompt(
|
|
|
491
515
|
} finally {
|
|
492
516
|
if (sendInterval) clearInterval(sendInterval);
|
|
493
517
|
revokeAgentImageGrant(imageGrant.token);
|
|
518
|
+
revokeAgentFileGrant(fileGrant.token);
|
|
494
519
|
}
|
|
495
520
|
|
|
496
521
|
const cEntry = chatSessionMap.get(chatId);
|