chatccc 0.2.52 → 0.2.53

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.
Files changed (39) hide show
  1. package/README.md +95 -256
  2. package/bin/chatccc.mjs +23 -23
  3. package/demo/ilink_echo_probe.ts +222 -222
  4. package/im-skills/feishu-skill/download-video.mjs +162 -162
  5. package/im-skills/feishu-skill/receive-send-file.md +66 -66
  6. package/im-skills/feishu-skill/receive-send-image.md +27 -27
  7. package/im-skills/feishu-skill/send-file.mjs +50 -50
  8. package/im-skills/feishu-skill/send-image.mjs +50 -50
  9. package/im-skills/feishu-skill/skill.md +10 -10
  10. package/package.json +59 -59
  11. package/src/__tests__/agent-image-rpc.test.ts +33 -33
  12. package/src/__tests__/agent-rpc-body.test.ts +41 -41
  13. package/src/__tests__/card-plain-text.test.ts +42 -42
  14. package/src/__tests__/codex-adapter.test.ts +304 -304
  15. package/src/__tests__/config-reload.test.ts +26 -26
  16. package/src/__tests__/config-sample.test.ts +19 -19
  17. package/src/__tests__/crash-logging.test.ts +62 -62
  18. package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
  19. package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
  20. package/src/__tests__/im-skills.test.ts +46 -46
  21. package/src/__tests__/session.test.ts +57 -2
  22. package/src/__tests__/sim-agent.test.ts +173 -173
  23. package/src/__tests__/sim-platform.test.ts +76 -76
  24. package/src/__tests__/sim-store.test.ts +213 -213
  25. package/src/__tests__/stream-state.test.ts +134 -134
  26. package/src/__tests__/wechat-platform.test.ts +57 -32
  27. package/src/adapters/codex-adapter.ts +294 -294
  28. package/src/adapters/codex-session-meta-store.ts +130 -130
  29. package/src/agent-file-rpc.ts +156 -156
  30. package/src/agent-image-rpc.ts +152 -152
  31. package/src/agent-rpc-body.ts +48 -48
  32. package/src/card-plain-text.ts +108 -108
  33. package/src/im-skills.ts +109 -109
  34. package/src/platform-adapter.ts +60 -60
  35. package/src/session-chat-binding.ts +6 -1
  36. package/src/session.ts +40 -35
  37. package/src/sim-agent.ts +167 -167
  38. package/src/trace.ts +50 -50
  39. package/src/wechat-platform.ts +1 -1
@@ -1,131 +1,131 @@
1
- // =============================================================================
2
- // codex-session-meta-store.ts — Codex 会话 sessionId → meta 持久化映射
3
- // =============================================================================
4
- // Codex CLI 没有 SDK 可查询会话元数据,ChatCCC 内部维护 sessionId → { cwd, threadId } 映射。
5
- // threadId 在首次 prompt 时才生成(Codex 不支持"空会话"创建),存储用于后续 resume。
6
- // =============================================================================
7
-
8
- import { mkdir, readFile, writeFile } from "node:fs/promises";
9
- import { dirname, join } from "node:path";
10
-
11
- import { USER_DATA_DIR } from "../config.ts";
12
-
13
- export const CODEX_SESSION_META_FILE = join(
14
- USER_DATA_DIR,
15
- "state",
16
- "codex-session-meta.json",
17
- );
18
-
19
- export interface CodexSessionMeta {
20
- cwd: string;
21
- threadId?: string;
22
- }
23
-
24
- export interface CodexSessionMetaStore {
25
- get(sessionId: string): Promise<CodexSessionMeta | undefined>;
26
- set(sessionId: string, partial: Partial<CodexSessionMeta>): Promise<void>;
27
- /**
28
- * 只更新 threadId(高频操作:prompt 首次调用时写入)。
29
- * 与 set 的区别:不重新读写完整文件,内联到已有的 load→merge→save 流程。
30
- */
31
- setThreadId(sessionId: string, threadId: string): Promise<void>;
32
- }
33
-
34
- interface RawEntry {
35
- cwd?: string;
36
- threadId?: string;
37
- }
38
-
39
- function isNonEmptyString(v: unknown): v is string {
40
- return typeof v === "string" && v.length > 0;
41
- }
42
-
43
- function parseEntry(raw: unknown): RawEntry | null {
44
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
45
- const obj = raw as Record<string, unknown>;
46
- const out: RawEntry = {};
47
- if (isNonEmptyString(obj.cwd)) out.cwd = obj.cwd;
48
- if (isNonEmptyString(obj.threadId)) out.threadId = obj.threadId;
49
- return out;
50
- }
51
- return null;
52
- }
53
-
54
- export function createCodexSessionMetaStore(
55
- filePath: string = CODEX_SESSION_META_FILE,
56
- ): CodexSessionMetaStore {
57
- let cache: Record<string, RawEntry> | null = null;
58
-
59
- async function load(): Promise<Record<string, RawEntry>> {
60
- if (cache) return cache;
61
- try {
62
- const raw = await readFile(filePath, "utf-8");
63
- const parsed = JSON.parse(raw);
64
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
65
- const out: Record<string, RawEntry> = {};
66
- for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
67
- const entry = parseEntry(v);
68
- if (entry) out[k] = entry;
69
- }
70
- cache = out;
71
- return out;
72
- }
73
- } catch {
74
- // 文件不存在或损坏
75
- }
76
- cache = {};
77
- return cache;
78
- }
79
-
80
- async function save(map: Record<string, RawEntry>): Promise<void> {
81
- try {
82
- await mkdir(dirname(filePath), { recursive: true });
83
- await writeFile(filePath, JSON.stringify(map, null, 2), "utf-8");
84
- } catch (err) {
85
- console.error(
86
- `[codex-session-meta] failed to persist ${filePath}: ${(err as Error).message}`,
87
- );
88
- }
89
- }
90
-
91
- return {
92
- async get(sessionId: string): Promise<CodexSessionMeta | undefined> {
93
- const map = await load();
94
- const entry = map[sessionId];
95
- if (!entry || !isNonEmptyString(entry.cwd)) return undefined;
96
- return entry.threadId
97
- ? { cwd: entry.cwd, threadId: entry.threadId }
98
- : { cwd: entry.cwd };
99
- },
100
-
101
- async set(
102
- sessionId: string,
103
- partial: Partial<CodexSessionMeta>,
104
- ): Promise<void> {
105
- const map = await load();
106
- const existing = map[sessionId] ?? {};
107
- const merged: RawEntry = { ...existing };
108
- if (isNonEmptyString(partial.cwd)) merged.cwd = partial.cwd;
109
- if (isNonEmptyString(partial.threadId)) merged.threadId = partial.threadId;
110
-
111
- if (existing.cwd === merged.cwd && existing.threadId === merged.threadId) return;
112
-
113
- map[sessionId] = merged;
114
- await save(map);
115
- },
116
-
117
- async setThreadId(
118
- sessionId: string,
119
- threadId: string,
120
- ): Promise<void> {
121
- const map = await load();
122
- const existing = map[sessionId] ?? {};
123
- if (existing.threadId === threadId) return;
124
- const merged: RawEntry = { ...existing, threadId };
125
- map[sessionId] = merged;
126
- await save(map);
127
- },
128
- };
129
- }
130
-
1
+ // =============================================================================
2
+ // codex-session-meta-store.ts — Codex 会话 sessionId → meta 持久化映射
3
+ // =============================================================================
4
+ // Codex CLI 没有 SDK 可查询会话元数据,ChatCCC 内部维护 sessionId → { cwd, threadId } 映射。
5
+ // threadId 在首次 prompt 时才生成(Codex 不支持"空会话"创建),存储用于后续 resume。
6
+ // =============================================================================
7
+
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { dirname, join } from "node:path";
10
+
11
+ import { USER_DATA_DIR } from "../config.ts";
12
+
13
+ export const CODEX_SESSION_META_FILE = join(
14
+ USER_DATA_DIR,
15
+ "state",
16
+ "codex-session-meta.json",
17
+ );
18
+
19
+ export interface CodexSessionMeta {
20
+ cwd: string;
21
+ threadId?: string;
22
+ }
23
+
24
+ export interface CodexSessionMetaStore {
25
+ get(sessionId: string): Promise<CodexSessionMeta | undefined>;
26
+ set(sessionId: string, partial: Partial<CodexSessionMeta>): Promise<void>;
27
+ /**
28
+ * 只更新 threadId(高频操作:prompt 首次调用时写入)。
29
+ * 与 set 的区别:不重新读写完整文件,内联到已有的 load→merge→save 流程。
30
+ */
31
+ setThreadId(sessionId: string, threadId: string): Promise<void>;
32
+ }
33
+
34
+ interface RawEntry {
35
+ cwd?: string;
36
+ threadId?: string;
37
+ }
38
+
39
+ function isNonEmptyString(v: unknown): v is string {
40
+ return typeof v === "string" && v.length > 0;
41
+ }
42
+
43
+ function parseEntry(raw: unknown): RawEntry | null {
44
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
45
+ const obj = raw as Record<string, unknown>;
46
+ const out: RawEntry = {};
47
+ if (isNonEmptyString(obj.cwd)) out.cwd = obj.cwd;
48
+ if (isNonEmptyString(obj.threadId)) out.threadId = obj.threadId;
49
+ return out;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function createCodexSessionMetaStore(
55
+ filePath: string = CODEX_SESSION_META_FILE,
56
+ ): CodexSessionMetaStore {
57
+ let cache: Record<string, RawEntry> | null = null;
58
+
59
+ async function load(): Promise<Record<string, RawEntry>> {
60
+ if (cache) return cache;
61
+ try {
62
+ const raw = await readFile(filePath, "utf-8");
63
+ const parsed = JSON.parse(raw);
64
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
65
+ const out: Record<string, RawEntry> = {};
66
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
67
+ const entry = parseEntry(v);
68
+ if (entry) out[k] = entry;
69
+ }
70
+ cache = out;
71
+ return out;
72
+ }
73
+ } catch {
74
+ // 文件不存在或损坏
75
+ }
76
+ cache = {};
77
+ return cache;
78
+ }
79
+
80
+ async function save(map: Record<string, RawEntry>): Promise<void> {
81
+ try {
82
+ await mkdir(dirname(filePath), { recursive: true });
83
+ await writeFile(filePath, JSON.stringify(map, null, 2), "utf-8");
84
+ } catch (err) {
85
+ console.error(
86
+ `[codex-session-meta] failed to persist ${filePath}: ${(err as Error).message}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ return {
92
+ async get(sessionId: string): Promise<CodexSessionMeta | undefined> {
93
+ const map = await load();
94
+ const entry = map[sessionId];
95
+ if (!entry || !isNonEmptyString(entry.cwd)) return undefined;
96
+ return entry.threadId
97
+ ? { cwd: entry.cwd, threadId: entry.threadId }
98
+ : { cwd: entry.cwd };
99
+ },
100
+
101
+ async set(
102
+ sessionId: string,
103
+ partial: Partial<CodexSessionMeta>,
104
+ ): Promise<void> {
105
+ const map = await load();
106
+ const existing = map[sessionId] ?? {};
107
+ const merged: RawEntry = { ...existing };
108
+ if (isNonEmptyString(partial.cwd)) merged.cwd = partial.cwd;
109
+ if (isNonEmptyString(partial.threadId)) merged.threadId = partial.threadId;
110
+
111
+ if (existing.cwd === merged.cwd && existing.threadId === merged.threadId) return;
112
+
113
+ map[sessionId] = merged;
114
+ await save(map);
115
+ },
116
+
117
+ async setThreadId(
118
+ sessionId: string,
119
+ threadId: string,
120
+ ): Promise<void> {
121
+ const map = await load();
122
+ const existing = map[sessionId] ?? {};
123
+ if (existing.threadId === threadId) return;
124
+ const merged: RawEntry = { ...existing, threadId };
125
+ map[sessionId] = merged;
126
+ await save(map);
127
+ },
128
+ };
129
+ }
130
+
131
131
  export const defaultCodexSessionMetaStore = createCodexSessionMetaStore();
@@ -1,157 +1,157 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { extname, isAbsolute, resolve } from "node:path";
3
- import { stat } from "node:fs/promises";
4
-
5
- import { getTenantAccessToken, sendFileReply, sendTextReply } from "./feishu-platform.ts";
6
- import { ts } from "./config.ts";
7
- import { readUtf8JsonBody } from "./agent-rpc-body.ts";
8
- import { getAdapterForTool } from "./session.ts";
9
- import { getChatsForSession } from "./session-chat-binding.ts";
10
-
11
- export const AGENT_SEND_FILE_PATH = "/api/agent/send-file";
12
-
13
- const MAX_REQUEST_BYTES = 64 * 1024;
14
- const MAX_FILE_BYTES = 100 * 1024 * 1024;
15
- const ALLOWED_FILE_EXTS = new Set([
16
- ".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv",
17
- ".mp3", ".wav", ".ogg", ".aac", ".m4a",
18
- ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".ppt", ".pptx",
19
- ".txt", ".zip", ".tar", ".gz",
20
- ]);
21
-
22
- function jsonReply(res: ServerResponse, status: number, data: unknown): void {
23
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
24
- res.end(JSON.stringify(data));
25
- }
26
-
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
-
37
- const ext = extname(filePath).toLowerCase();
38
- if (!ALLOWED_FILE_EXTS.has(ext)) {
39
- throw new Error(`unsupported file extension: ${ext || "(none)"}`);
40
- }
41
-
42
- const st = await stat(filePath);
43
- if (!st.isFile()) throw new Error("file path is not a file");
44
- if (st.size <= 0) throw new Error("file is empty");
45
- if (st.size > MAX_FILE_BYTES) throw new Error("file is larger than 100MB");
46
- return filePath;
47
- }
48
-
49
- export async function handleAgentFileRequest(
50
- req: IncomingMessage,
51
- res: ServerResponse,
52
- ): Promise<boolean> {
53
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
54
- if (url.pathname !== AGENT_SEND_FILE_PATH) return false;
55
-
56
- if (req.method !== "POST") {
57
- jsonReply(res, 405, { ok: false, error: "Method not allowed" });
58
- return true;
59
- }
60
-
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;
89
- }
90
-
91
- let filePath: string;
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;
103
- }
104
-
105
- try {
106
- const token = await getTenantAccessToken();
107
- const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
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 });
120
- } catch (err) {
121
- console.error(`[${ts()}] [AGENT-FILE] send failed: ${(err as Error).message}`);
122
- jsonReply(res, 500, { ok: false, error: (err as Error).message });
123
- }
124
- return true;
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");
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { extname, isAbsolute, resolve } from "node:path";
3
+ import { stat } from "node:fs/promises";
4
+
5
+ import { getTenantAccessToken, sendFileReply, sendTextReply } from "./feishu-platform.ts";
6
+ import { ts } from "./config.ts";
7
+ import { readUtf8JsonBody } from "./agent-rpc-body.ts";
8
+ import { getAdapterForTool } from "./session.ts";
9
+ import { getChatsForSession } from "./session-chat-binding.ts";
10
+
11
+ export const AGENT_SEND_FILE_PATH = "/api/agent/send-file";
12
+
13
+ const MAX_REQUEST_BYTES = 64 * 1024;
14
+ const MAX_FILE_BYTES = 100 * 1024 * 1024;
15
+ const ALLOWED_FILE_EXTS = new Set([
16
+ ".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv",
17
+ ".mp3", ".wav", ".ogg", ".aac", ".m4a",
18
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".ppt", ".pptx",
19
+ ".txt", ".zip", ".tar", ".gz",
20
+ ]);
21
+
22
+ function jsonReply(res: ServerResponse, status: number, data: unknown): void {
23
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
24
+ res.end(JSON.stringify(data));
25
+ }
26
+
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
+
37
+ const ext = extname(filePath).toLowerCase();
38
+ if (!ALLOWED_FILE_EXTS.has(ext)) {
39
+ throw new Error(`unsupported file extension: ${ext || "(none)"}`);
40
+ }
41
+
42
+ const st = await stat(filePath);
43
+ if (!st.isFile()) throw new Error("file path is not a file");
44
+ if (st.size <= 0) throw new Error("file is empty");
45
+ if (st.size > MAX_FILE_BYTES) throw new Error("file is larger than 100MB");
46
+ return filePath;
47
+ }
48
+
49
+ export async function handleAgentFileRequest(
50
+ req: IncomingMessage,
51
+ res: ServerResponse,
52
+ ): Promise<boolean> {
53
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
54
+ if (url.pathname !== AGENT_SEND_FILE_PATH) return false;
55
+
56
+ if (req.method !== "POST") {
57
+ jsonReply(res, 405, { ok: false, error: "Method not allowed" });
58
+ return true;
59
+ }
60
+
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;
89
+ }
90
+
91
+ let filePath: string;
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;
103
+ }
104
+
105
+ try {
106
+ const token = await getTenantAccessToken();
107
+ const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
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 });
120
+ } catch (err) {
121
+ console.error(`[${ts()}] [AGENT-FILE] send failed: ${(err as Error).message}`);
122
+ jsonReply(res, 500, { ok: false, error: (err as Error).message });
123
+ }
124
+ return true;
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
157
  }