chatccc 0.2.25 → 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.
@@ -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.25",
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
+ }
@@ -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 = JSON.parse(await readLimitedBody(req));
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,
@@ -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
- // 其他类型(file, audio, media, sticker)直接给原始 JSON
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(handleAgentImageRequest);
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
- buildAgentImageCapabilityPrompt({
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);