chatccc 0.2.45 → 0.2.46
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/receive-send-file.md +3 -11
- package/im-skills/feishu-skill/receive-send-image.md +3 -11
- package/im-skills/feishu-skill/send-file.mjs +5 -6
- package/im-skills/feishu-skill/send-image.mjs +5 -6
- package/im-skills/feishu-skill/skill.md +2 -3
- package/package.json +1 -1
- package/src/__tests__/agent-image-rpc.test.ts +17 -52
- package/src/__tests__/session.test.ts +31 -1
- package/src/agent-file-rpc.ts +108 -106
- package/src/agent-image-rpc.ts +78 -113
- package/src/index.ts +158 -109
- package/src/session-chat-binding.ts +87 -0
- package/src/session.ts +328 -263
- package/src/stream-state.ts +94 -0
- package/src/agent-grants-rpc.ts +0 -71
|
@@ -4,25 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
Videos are sent as regular files (not media), which looks cleaner in Feishu.
|
|
6
6
|
|
|
7
|
-
First, query the current send token:
|
|
8
|
-
```bash
|
|
9
|
-
curl "{{session_grants_url}}?sid={{session_id}}"
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
Then send with the returned url and token:
|
|
13
|
-
|
|
14
7
|
### Script (recommended)
|
|
15
8
|
```bash
|
|
16
|
-
node "{{send_file_script}}" --url
|
|
9
|
+
node "{{send_file_script}}" --url "{{send_file_url}}" --session-id "{{session_id}}" --path "<absolute file path>" --caption "<optional caption>"
|
|
17
10
|
```
|
|
18
11
|
|
|
19
12
|
### Direct HTTP
|
|
20
13
|
```http
|
|
21
|
-
POST
|
|
22
|
-
Authorization: Bearer <token_from_query>
|
|
14
|
+
POST {{send_file_url}}
|
|
23
15
|
Content-Type: application/json; charset=utf-8
|
|
24
16
|
|
|
25
|
-
{"path":"<absolute file path>","caption":"<optional caption>"}
|
|
17
|
+
{"session_id":"{{session_id}}","path":"<absolute file path>","caption":"<optional caption>"}
|
|
26
18
|
```
|
|
27
19
|
|
|
28
20
|
### Rules
|
|
@@ -2,25 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Send Images
|
|
4
4
|
|
|
5
|
-
First, query the current send token:
|
|
6
|
-
```bash
|
|
7
|
-
curl "{{session_grants_url}}?sid={{session_id}}"
|
|
8
|
-
```
|
|
9
|
-
|
|
10
|
-
Then send with the returned url and token:
|
|
11
|
-
|
|
12
5
|
### Script (recommended)
|
|
13
6
|
```bash
|
|
14
|
-
node "{{send_image_script}}" --url
|
|
7
|
+
node "{{send_image_script}}" --url "{{send_image_url}}" --session-id "{{session_id}}" --path "<absolute image path>" --caption "<optional caption>"
|
|
15
8
|
```
|
|
16
9
|
|
|
17
10
|
### Direct HTTP
|
|
18
11
|
```http
|
|
19
|
-
POST
|
|
20
|
-
Authorization: Bearer <token_from_query>
|
|
12
|
+
POST {{send_image_url}}
|
|
21
13
|
Content-Type: application/json; charset=utf-8
|
|
22
14
|
|
|
23
|
-
{"path":"<absolute image path>","caption":"<optional caption>"}
|
|
15
|
+
{"session_id":"{{session_id}}","path":"<absolute image path>","caption":"<optional caption>"}
|
|
24
16
|
```
|
|
25
17
|
|
|
26
18
|
### Rules
|
|
@@ -14,26 +14,25 @@ function parseArgs(argv) {
|
|
|
14
14
|
|
|
15
15
|
function usage() {
|
|
16
16
|
console.error(`Usage:
|
|
17
|
-
node ${basename(process.argv[1])} --url <url> --
|
|
17
|
+
node ${basename(process.argv[1])} --url <url> --session-id <session_id> --path <absolute file path> [--caption <text>]`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async function main() {
|
|
21
21
|
const args = parseArgs(process.argv.slice(2));
|
|
22
22
|
const url = args.url || process.env.CHATCCC_SEND_FILE_URL;
|
|
23
|
-
const
|
|
23
|
+
const sessionId = args["session-id"] || args.session_id || process.env.CHATCCC_SESSION_ID;
|
|
24
24
|
const path = args.path;
|
|
25
25
|
const caption = args.caption || "";
|
|
26
26
|
|
|
27
|
-
if (!url || !
|
|
27
|
+
if (!url || !sessionId || !path) {
|
|
28
28
|
usage();
|
|
29
29
|
process.exit(1);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const body = Buffer.from(JSON.stringify({ path, caption }), "utf8");
|
|
32
|
+
const body = Buffer.from(JSON.stringify({ session_id: sessionId, path, caption }), "utf8");
|
|
33
33
|
const response = await fetch(url, {
|
|
34
34
|
method: "POST",
|
|
35
35
|
headers: {
|
|
36
|
-
Authorization: `Bearer ${token}`,
|
|
37
36
|
"Content-Type": "application/json; charset=utf-8",
|
|
38
37
|
},
|
|
39
38
|
body,
|
|
@@ -49,4 +48,4 @@ async function main() {
|
|
|
49
48
|
main().catch((err) => {
|
|
50
49
|
console.error(err instanceof Error ? err.message : String(err));
|
|
51
50
|
process.exit(1);
|
|
52
|
-
});
|
|
51
|
+
});
|
|
@@ -14,26 +14,25 @@ function parseArgs(argv) {
|
|
|
14
14
|
|
|
15
15
|
function usage() {
|
|
16
16
|
console.error(`Usage:
|
|
17
|
-
node ${basename(process.argv[1])} --url <url> --
|
|
17
|
+
node ${basename(process.argv[1])} --url <url> --session-id <session_id> --path <absolute image path> [--caption <text>]`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async function main() {
|
|
21
21
|
const args = parseArgs(process.argv.slice(2));
|
|
22
22
|
const url = args.url || process.env.CHATCCC_SEND_IMAGE_URL;
|
|
23
|
-
const
|
|
23
|
+
const sessionId = args["session-id"] || args.session_id || process.env.CHATCCC_SESSION_ID;
|
|
24
24
|
const path = args.path;
|
|
25
25
|
const caption = args.caption || "";
|
|
26
26
|
|
|
27
|
-
if (!url || !
|
|
27
|
+
if (!url || !sessionId || !path) {
|
|
28
28
|
usage();
|
|
29
29
|
process.exit(1);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const body = Buffer.from(JSON.stringify({ path, caption }), "utf8");
|
|
32
|
+
const body = Buffer.from(JSON.stringify({ session_id: sessionId, path, caption }), "utf8");
|
|
33
33
|
const response = await fetch(url, {
|
|
34
34
|
method: "POST",
|
|
35
35
|
headers: {
|
|
36
|
-
Authorization: `Bearer ${token}`,
|
|
37
36
|
"Content-Type": "application/json; charset=utf-8",
|
|
38
37
|
},
|
|
39
38
|
body,
|
|
@@ -49,4 +48,4 @@ async function main() {
|
|
|
49
48
|
main().catch((err) => {
|
|
50
49
|
console.error(err instanceof Error ? err.message : String(err));
|
|
51
50
|
process.exit(1);
|
|
52
|
-
});
|
|
51
|
+
});
|
|
@@ -7,6 +7,5 @@ Current working directory: {{cwd}}
|
|
|
7
7
|
|
|
8
8
|
Use local endpoints instead of calling Feishu Open Platform directly.
|
|
9
9
|
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **Files & Videos** (send & download): read `{{im_skills_cache_dir}}/feishu-skill/receive-send-file.md`
|
|
10
|
+
- **Send images**: POST `{{send_image_url}}` with `{"session_id":"{{session_id}}","path":"<absolute path>","caption":"<optional>"}` — read `{{im_skills_cache_dir}}/feishu-skill/receive-send-image.md`
|
|
11
|
+
- **Send files/videos**: POST `{{send_file_url}}` with `{"session_id":"{{session_id}}","path":"<absolute path>","caption":"<optional>"}` — read `{{im_skills_cache_dir}}/feishu-skill/receive-send-file.md`
|
package/package.json
CHANGED
|
@@ -3,67 +3,32 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
AGENT_SEND_IMAGE_PATH,
|
|
5
5
|
buildAgentImageCapabilityPrompt,
|
|
6
|
-
createAgentImageGrant,
|
|
7
|
-
getAgentImageGrantFromAuthorization,
|
|
8
|
-
revokeAgentImageGrant,
|
|
9
6
|
} from "../agent-image-rpc.ts";
|
|
10
7
|
|
|
11
|
-
describe("agent image RPC
|
|
12
|
-
it("
|
|
13
|
-
const grant = createAgentImageGrant({
|
|
14
|
-
chatId: "oc_chat",
|
|
15
|
-
sessionId: "sid-1",
|
|
16
|
-
cwd: "F:/repo",
|
|
17
|
-
port: 18080,
|
|
18
|
-
nowMs: 1000,
|
|
19
|
-
ttlMs: 60_000,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
expect(grant.url).toBe(`http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`);
|
|
23
|
-
expect(grant.token.length).toBeGreaterThan(20);
|
|
24
|
-
|
|
25
|
-
const found = getAgentImageGrantFromAuthorization(
|
|
26
|
-
`Bearer ${grant.token}`,
|
|
27
|
-
30_000,
|
|
28
|
-
);
|
|
29
|
-
expect(found).toMatchObject({
|
|
30
|
-
chatId: "oc_chat",
|
|
31
|
-
sessionId: "sid-1",
|
|
32
|
-
cwd: "F:/repo",
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
revokeAgentImageGrant(grant.token);
|
|
36
|
-
expect(getAgentImageGrantFromAuthorization(`Bearer ${grant.token}`, 30_000)).toBeNull();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("rejects missing, malformed, revoked, and expired authorization", () => {
|
|
40
|
-
const grant = createAgentImageGrant({
|
|
41
|
-
chatId: "oc_chat",
|
|
42
|
-
sessionId: "sid-1",
|
|
43
|
-
cwd: "F:/repo",
|
|
44
|
-
port: 18080,
|
|
45
|
-
nowMs: 1000,
|
|
46
|
-
ttlMs: 10,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
expect(getAgentImageGrantFromAuthorization("", 1000)).toBeNull();
|
|
50
|
-
expect(getAgentImageGrantFromAuthorization(grant.token, 1000)).toBeNull();
|
|
51
|
-
expect(getAgentImageGrantFromAuthorization(`Basic ${grant.token}`, 1000)).toBeNull();
|
|
52
|
-
expect(getAgentImageGrantFromAuthorization(`Bearer ${grant.token}`, 1011)).toBeNull();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("builds prompt instructions without requiring environment variables", () => {
|
|
8
|
+
describe("agent image RPC", () => {
|
|
9
|
+
it("builds prompt instructions with session_id", () => {
|
|
56
10
|
const prompt = buildAgentImageCapabilityPrompt({
|
|
57
11
|
url: `http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`,
|
|
58
|
-
|
|
12
|
+
sessionId: "sid-test",
|
|
59
13
|
});
|
|
60
14
|
|
|
61
15
|
expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/send-image");
|
|
62
|
-
expect(prompt).toContain("
|
|
16
|
+
expect(prompt).toContain('"session_id":"sid-test"');
|
|
63
17
|
expect(prompt).toContain("Content-Type: application/json; charset=utf-8");
|
|
64
18
|
expect(prompt).toContain("UTF-8 encoded JSON bytes");
|
|
65
|
-
expect(prompt).toContain("
|
|
19
|
+
expect(prompt).toContain('"path"');
|
|
20
|
+
expect(prompt).not.toContain("Authorization: Bearer");
|
|
66
21
|
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_URL");
|
|
67
22
|
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_TOKEN");
|
|
68
23
|
});
|
|
69
|
-
|
|
24
|
+
|
|
25
|
+
it("builds prompt with cwd hint", () => {
|
|
26
|
+
const prompt = buildAgentImageCapabilityPrompt({
|
|
27
|
+
url: `http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`,
|
|
28
|
+
sessionId: "sid-1",
|
|
29
|
+
cwd: "F:/repo",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(prompt).toContain("Current working directory: F:/repo");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
2
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// mock stream-state 以支持在测试中控制累积长度
|
|
7
|
+
const mockStreamStates = new Map<string, { accumulatedContent: string; finalReply: string }>();
|
|
8
|
+
vi.mock("../stream-state.ts", () => ({
|
|
9
|
+
readStreamState: async (sid: string) => {
|
|
10
|
+
const state = mockStreamStates.get(sid);
|
|
11
|
+
if (!state) return null;
|
|
12
|
+
return { sessionId: sid, accumulatedContent: state.accumulatedContent, finalReply: state.finalReply, status: "running", chunkCount: 0, turnCount: 0, contextTokens: 0, updatedAt: Date.now(), cwd: "", tool: "claude" };
|
|
13
|
+
},
|
|
14
|
+
writeStreamState: async () => {},
|
|
15
|
+
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
16
|
+
sessionId: sid, status: "running" as const, accumulatedContent: "", finalReply: "", chunkCount: 0, turnCount, contextTokens: 0, updatedAt: Date.now(), cwd, tool,
|
|
17
|
+
}),
|
|
18
|
+
fixStaleStreamStates: async () => {},
|
|
19
|
+
}));
|
|
5
20
|
import {
|
|
6
21
|
chatSessionMap,
|
|
7
22
|
sessionInfoMap,
|
|
@@ -19,6 +34,7 @@ import {
|
|
|
19
34
|
_setAdapterForToolForTest,
|
|
20
35
|
_clearAdapterCacheForTest,
|
|
21
36
|
} from "../session.ts";
|
|
37
|
+
import { activePrompts } from "../session-chat-binding.ts";
|
|
22
38
|
import type { AccumulatorState } from "../session.ts";
|
|
23
39
|
import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
|
|
24
40
|
|
|
@@ -28,6 +44,18 @@ function mockActiveSession(chatId: string, overrides: Partial<{
|
|
|
28
44
|
finalText: string;
|
|
29
45
|
stopped: boolean;
|
|
30
46
|
}> = {}) {
|
|
47
|
+
const info = sessionInfoMap.get(chatId);
|
|
48
|
+
const sessionId = info?.sessionId ?? "test-session-id";
|
|
49
|
+
activePrompts.set(sessionId, {
|
|
50
|
+
controller: new AbortController(),
|
|
51
|
+
stopped: overrides.stopped ?? false,
|
|
52
|
+
startTime: Date.now(),
|
|
53
|
+
});
|
|
54
|
+
mockStreamStates.set(sessionId, {
|
|
55
|
+
accumulatedContent: overrides.accumulatedContent ?? "thinking...",
|
|
56
|
+
finalReply: overrides.finalText ?? "",
|
|
57
|
+
});
|
|
58
|
+
// 保留 chatSessionMap 兼容旧测试
|
|
31
59
|
chatSessionMap.set(chatId, {
|
|
32
60
|
gen: 1,
|
|
33
61
|
close: () => {},
|
|
@@ -98,6 +126,8 @@ describe("getSessionStatus", () => {
|
|
|
98
126
|
beforeEach(() => {
|
|
99
127
|
chatSessionMap.clear();
|
|
100
128
|
sessionInfoMap.clear();
|
|
129
|
+
activePrompts.clear();
|
|
130
|
+
mockStreamStates.clear();
|
|
101
131
|
});
|
|
102
132
|
|
|
103
133
|
afterEach(() => {
|
package/src/agent-file-rpc.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
2
|
import { extname, isAbsolute, resolve } from "node:path";
|
|
4
3
|
import { stat } from "node:fs/promises";
|
|
5
4
|
|
|
6
5
|
import { getTenantAccessToken, sendFileReply, sendTextReply } from "./feishu-platform.ts";
|
|
7
6
|
import { ts } from "./config.ts";
|
|
8
|
-
import { logTrace } from "./trace.ts";
|
|
9
7
|
import { readUtf8JsonBody } from "./agent-rpc-body.ts";
|
|
8
|
+
import { getAdapterForTool } from "./session.ts";
|
|
9
|
+
import { getChatsForSession } from "./session-chat-binding.ts";
|
|
10
10
|
|
|
11
11
|
export const AGENT_SEND_FILE_PATH = "/api/agent/send-file";
|
|
12
12
|
|
|
13
|
-
const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
|
|
14
13
|
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
15
14
|
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
16
15
|
const ALLOWED_FILE_EXTS = new Set([
|
|
@@ -20,136 +19,139 @@ const ALLOWED_FILE_EXTS = new Set([
|
|
|
20
19
|
".txt", ".zip", ".tar", ".gz",
|
|
21
20
|
]);
|
|
22
21
|
|
|
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
22
|
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
103
23
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
104
24
|
res.end(JSON.stringify(data));
|
|
105
25
|
}
|
|
106
26
|
|
|
107
|
-
async function resolveAndValidateFilePath(
|
|
108
|
-
if (typeof rawPath !== "string" || rawPath.trim() === "")
|
|
109
|
-
|
|
110
|
-
|
|
27
|
+
async function resolveAndValidateFilePath(cwd: string, rawPath: unknown): Promise<string> {
|
|
28
|
+
if (typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
29
|
+
throw new Error("path must be a non-empty string");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sessionRoot = resolve(cwd);
|
|
33
|
+
const filePath = isAbsolute(rawPath)
|
|
34
|
+
? resolve(rawPath)
|
|
35
|
+
: resolve(sessionRoot, rawPath);
|
|
36
|
+
|
|
111
37
|
const ext = extname(filePath).toLowerCase();
|
|
112
|
-
if (!ALLOWED_FILE_EXTS.has(ext))
|
|
38
|
+
if (!ALLOWED_FILE_EXTS.has(ext)) {
|
|
39
|
+
throw new Error(`unsupported file extension: ${ext || "(none)"}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
113
42
|
const st = await stat(filePath);
|
|
114
43
|
if (!st.isFile()) throw new Error("file path is not a file");
|
|
115
44
|
if (st.size <= 0) throw new Error("file is empty");
|
|
116
|
-
if (st.size > MAX_FILE_BYTES) throw new Error(
|
|
45
|
+
if (st.size > MAX_FILE_BYTES) throw new Error("file is larger than 100MB");
|
|
117
46
|
return filePath;
|
|
118
47
|
}
|
|
119
48
|
|
|
120
|
-
export async function handleAgentFileRequest(
|
|
49
|
+
export async function handleAgentFileRequest(
|
|
50
|
+
req: IncomingMessage,
|
|
51
|
+
res: ServerResponse,
|
|
52
|
+
): Promise<boolean> {
|
|
121
53
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
122
54
|
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
55
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
56
|
+
if (req.method !== "POST") {
|
|
57
|
+
jsonReply(res, 405, { ok: false, error: "Method not allowed" });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
128
60
|
|
|
129
|
-
let payload: { path?: unknown; caption?: unknown };
|
|
130
|
-
try {
|
|
131
|
-
|
|
132
|
-
|
|
61
|
+
let payload: { session_id?: unknown; path?: unknown; caption?: unknown };
|
|
62
|
+
try {
|
|
63
|
+
payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
70
|
+
if (!sessionId) {
|
|
71
|
+
jsonReply(res, 400, { ok: false, error: "Missing session_id" });
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let cwd: string;
|
|
76
|
+
try {
|
|
77
|
+
const { getSessionTool } = await import("./session.ts");
|
|
78
|
+
const tool = await getSessionTool(sessionId);
|
|
79
|
+
const adapter = getAdapterForTool(tool ?? "claude");
|
|
80
|
+
const info = await adapter.getSessionInfo(sessionId);
|
|
81
|
+
if (!info?.cwd) {
|
|
82
|
+
jsonReply(res, 400, { ok: false, error: "Cannot determine cwd for session" });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
cwd = info.cwd;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
jsonReply(res, 500, { ok: false, error: `Failed to get session info: ${(err as Error).message}` });
|
|
88
|
+
return true;
|
|
133
89
|
}
|
|
134
90
|
|
|
135
91
|
let filePath: string;
|
|
136
|
-
try {
|
|
137
|
-
|
|
138
|
-
|
|
92
|
+
try {
|
|
93
|
+
filePath = await resolveAndValidateFilePath(cwd, payload.path);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const chatIds = getChatsForSession(sessionId);
|
|
100
|
+
if (chatIds.length === 0) {
|
|
101
|
+
jsonReply(res, 404, { ok: false, error: "No chats bound to this session" });
|
|
102
|
+
return true;
|
|
139
103
|
}
|
|
140
104
|
|
|
141
105
|
try {
|
|
142
106
|
const token = await getTenantAccessToken();
|
|
143
107
|
const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
108
|
+
let sentCount = 0;
|
|
109
|
+
for (const cid of chatIds) {
|
|
110
|
+
try {
|
|
111
|
+
await sendFileReply(token, cid, filePath);
|
|
112
|
+
if (caption) await sendTextReply(token, cid, caption);
|
|
113
|
+
sentCount++;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(`[${ts()}] [AGENT-FILE] send to ${cid} failed: ${(err as Error).message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
console.log(`[${ts()}] [AGENT-FILE] sent file to ${sentCount}/${chatIds.length} chats, session=${sessionId} path=${filePath}`);
|
|
119
|
+
jsonReply(res, 200, { ok: true, sentTo: sentCount, total: chatIds.length });
|
|
149
120
|
} catch (err) {
|
|
150
|
-
if (tid) logTrace(tid, "FILE_REQ", { outcome: "send_failed", path: filePath!, error: (err as Error).message });
|
|
151
121
|
console.error(`[${ts()}] [AGENT-FILE] send failed: ${(err as Error).message}`);
|
|
152
122
|
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
153
123
|
}
|
|
154
124
|
return true;
|
|
155
125
|
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// 兼容旧版 buildAgentFileCapabilityPrompt(供 im-skills 使用)
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export function buildAgentFileCapabilityPrompt(input: {
|
|
132
|
+
url: string;
|
|
133
|
+
sessionId?: string;
|
|
134
|
+
cwd?: string;
|
|
135
|
+
}): string {
|
|
136
|
+
const lines = [
|
|
137
|
+
"[ChatCCC local capability: send file]",
|
|
138
|
+
"You can send a file (video, audio, document, etc.) to all chats bound to this session by calling this local endpoint.",
|
|
139
|
+
"",
|
|
140
|
+
`POST ${input.url}`,
|
|
141
|
+
"Content-Type: application/json; charset=utf-8",
|
|
142
|
+
"",
|
|
143
|
+
`Body: {"session_id":"${input.sessionId ?? "YOUR_SESSION_ID"}","path":"absolute file path","caption":"optional caption"}`,
|
|
144
|
+
"",
|
|
145
|
+
"Rules:",
|
|
146
|
+
"- Save or choose a local file first, then call the endpoint.",
|
|
147
|
+
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
148
|
+
"- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
|
|
149
|
+
"- Only call this endpoint when the user asked for a file/video or when a file is useful to the answer.",
|
|
150
|
+
"- Max file size: 100MB.",
|
|
151
|
+
"[/ChatCCC local capability: send file]",
|
|
152
|
+
];
|
|
153
|
+
if (input.cwd) {
|
|
154
|
+
lines.splice(2, 0, `Current working directory: ${input.cwd}`);
|
|
155
|
+
}
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|