chatccc 0.2.45 → 0.2.47

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.
@@ -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 <url_from_query> --token <token_from_query> --path "<absolute file path>" --caption "<optional caption>"
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 <url_from_query>
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 <url_from_query> --token <token_from_query> --path "<absolute image path>" --caption "<optional caption>"
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 <url_from_query>
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> --token <token> --path <absolute file path> [--caption <text>]`);
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 token = args.token || process.env.CHATCCC_SEND_FILE_TOKEN;
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 || !token || !path) {
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> --token <token> --path <absolute image path> [--caption <text>]`);
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 token = args.token || process.env.CHATCCC_SEND_IMAGE_TOKEN;
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 || !token || !path) {
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
- - **Get send tokens**: `GET {{session_grants_url}}?sid={{session_id}}` — returns `{ image: {url,token}, file: {url,token} }`
11
- - **Images** (send & receive): read `{{im_skills_cache_dir}}/feishu-skill/receive-send-image.md`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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 grants", () => {
12
- it("creates a per-turn grant and validates bearer authorization", () => {
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
- token: "tok_test",
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("Authorization: Bearer tok_test");
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("\"path\"");
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(() => {
@@ -258,7 +288,8 @@ describe("getAllSessionsStatus", () => {
258
288
  const result = await getAllSessionsStatus();
259
289
  expect(result).toHaveLength(2);
260
290
  expect(result[0].chatId).toBe("chat1");
261
- expect(result[0].active).toBe(true);
291
+ // running=true in registry doesn't make it active — must be in activePrompts
292
+ expect(result[0].active).toBe(false);
262
293
  expect(result[0].turnCount).toBe(2);
263
294
  expect(result[0].chatName).toBe("test-chat-1");
264
295
  expect(result[1].chatId).toBe("chat2");
@@ -284,7 +315,7 @@ describe("getAllSessionsStatus", () => {
284
315
  expect(result.some((r) => r.chatId === "chat-4")).toBe(false);
285
316
  });
286
317
 
287
- it("marks disk-running sessions as active without checking chatSessionMap", async () => {
318
+ it("shows session as inactive when not in activePrompts, regardless of registry running field", async () => {
288
319
  await recordSessionRegistry({
289
320
  chatId: "chat1",
290
321
  sessionId: "s1",
@@ -293,6 +324,21 @@ describe("getAllSessionsStatus", () => {
293
324
  updatedAt: 1000,
294
325
  });
295
326
 
327
+ const result = await getAllSessionsStatus();
328
+ // After restart, activePrompts is cleared; registry running=true should not show as active
329
+ expect(result.find(r => r.chatId === "chat1")!.active).toBe(false);
330
+ });
331
+
332
+ it("shows session as active when in activePrompts", async () => {
333
+ await recordSessionRegistry({
334
+ chatId: "chat1",
335
+ sessionId: "s1",
336
+ tool: "claude",
337
+ running: false, // registry says false, but activePrompts wins
338
+ updatedAt: 1000,
339
+ });
340
+ mockActiveSession("chat1");
341
+
296
342
  const result = await getAllSessionsStatus();
297
343
  expect(result.find(r => r.chatId === "chat1")!.active).toBe(true);
298
344
  });
@@ -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(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);
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)) throw new Error(`unsupported file extension: ${ext || "(none)"}`);
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(`file is larger than ${MAX_FILE_BYTES / 1024 / 1024}MB`);
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(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
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
- 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;
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 { 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;
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 { 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;
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
- 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 });
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
+ }