chatccc 0.2.52 → 0.2.54

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 (42) 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__/privacy.test.ts +143 -0
  22. package/src/__tests__/session.test.ts +57 -2
  23. package/src/__tests__/sim-agent.test.ts +173 -173
  24. package/src/__tests__/sim-platform.test.ts +76 -76
  25. package/src/__tests__/sim-store.test.ts +213 -213
  26. package/src/__tests__/stream-state.test.ts +134 -134
  27. package/src/__tests__/wechat-platform.test.ts +57 -32
  28. package/src/adapters/codex-adapter.ts +294 -294
  29. package/src/adapters/codex-session-meta-store.ts +130 -130
  30. package/src/agent-file-rpc.ts +156 -156
  31. package/src/agent-image-rpc.ts +152 -152
  32. package/src/agent-rpc-body.ts +48 -48
  33. package/src/card-plain-text.ts +108 -108
  34. package/src/feishu-api.ts +7 -3
  35. package/src/im-skills.ts +109 -109
  36. package/src/platform-adapter.ts +60 -60
  37. package/src/privacy.ts +68 -0
  38. package/src/session-chat-binding.ts +6 -1
  39. package/src/session.ts +40 -35
  40. package/src/sim-agent.ts +167 -167
  41. package/src/trace.ts +50 -50
  42. package/src/wechat-platform.ts +4 -1
@@ -1,153 +1,153 @@
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, sendImageReply, 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_IMAGE_PATH = "/api/agent/send-image";
12
-
13
- const MAX_REQUEST_BYTES = 64 * 1024;
14
- const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
15
- const ALLOWED_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
16
-
17
- function jsonReply(res: ServerResponse, status: number, data: unknown): void {
18
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
19
- res.end(JSON.stringify(data));
20
- }
21
-
22
- async function resolveAndValidateImagePath(cwd: string, rawPath: unknown): Promise<string> {
23
- if (typeof rawPath !== "string" || rawPath.trim() === "") {
24
- throw new Error("path must be a non-empty string");
25
- }
26
-
27
- const sessionRoot = resolve(cwd);
28
- const imagePath = isAbsolute(rawPath)
29
- ? resolve(rawPath)
30
- : resolve(sessionRoot, rawPath);
31
-
32
- const ext = extname(imagePath).toLowerCase();
33
- if (!ALLOWED_IMAGE_EXTS.has(ext)) {
34
- throw new Error(`unsupported image extension: ${ext || "(none)"}`);
35
- }
36
-
37
- const st = await stat(imagePath);
38
- if (!st.isFile()) throw new Error("image path is not a file");
39
- if (st.size <= 0) throw new Error("image file is empty");
40
- if (st.size > MAX_IMAGE_BYTES) throw new Error("image file is larger than 10MB");
41
- return imagePath;
42
- }
43
-
44
- export async function handleAgentImageRequest(
45
- req: IncomingMessage,
46
- res: ServerResponse,
47
- ): Promise<boolean> {
48
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
49
- if (url.pathname !== AGENT_SEND_IMAGE_PATH) return false;
50
-
51
- if (req.method !== "POST") {
52
- jsonReply(res, 405, { ok: false, error: "Method not allowed" });
53
- return true;
54
- }
55
-
56
- let payload: { session_id?: unknown; path?: unknown; caption?: unknown };
57
- try {
58
- payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
59
- } catch (err) {
60
- jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
61
- return true;
62
- }
63
-
64
- const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
65
- if (!sessionId) {
66
- jsonReply(res, 400, { ok: false, error: "Missing session_id" });
67
- return true;
68
- }
69
-
70
- // 获取 cwd 以校验路径
71
- let cwd: string;
72
- try {
73
- const { getSessionTool } = await import("./session.ts");
74
- const tool = await getSessionTool(sessionId);
75
- const adapter = getAdapterForTool(tool ?? "claude");
76
- const info = await adapter.getSessionInfo(sessionId);
77
- if (!info?.cwd) {
78
- jsonReply(res, 400, { ok: false, error: "Cannot determine cwd for session" });
79
- return true;
80
- }
81
- cwd = info.cwd;
82
- } catch (err) {
83
- jsonReply(res, 500, { ok: false, error: `Failed to get session info: ${(err as Error).message}` });
84
- return true;
85
- }
86
-
87
- let imagePath: string;
88
- try {
89
- imagePath = await resolveAndValidateImagePath(cwd, payload.path);
90
- } catch (err) {
91
- jsonReply(res, 400, { ok: false, error: (err as Error).message });
92
- return true;
93
- }
94
-
95
- // 发送到所有绑定该 session 的群
96
- const chatIds = getChatsForSession(sessionId);
97
- if (chatIds.length === 0) {
98
- jsonReply(res, 404, { ok: false, error: "No chats bound to this session" });
99
- return true;
100
- }
101
-
102
- try {
103
- const token = await getTenantAccessToken();
104
- const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
105
- let sentCount = 0;
106
- for (const cid of chatIds) {
107
- try {
108
- await sendImageReply(token, cid, imagePath);
109
- if (caption) await sendTextReply(token, cid, caption);
110
- sentCount++;
111
- } catch (err) {
112
- console.error(`[${ts()}] [AGENT-IMAGE] send to ${cid} failed: ${(err as Error).message}`);
113
- }
114
- }
115
- console.log(`[${ts()}] [AGENT-IMAGE] sent image to ${sentCount}/${chatIds.length} chats, session=${sessionId} path=${imagePath}`);
116
- jsonReply(res, 200, { ok: true, sentTo: sentCount, total: chatIds.length });
117
- } catch (err) {
118
- console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
119
- jsonReply(res, 500, { ok: false, error: (err as Error).message });
120
- }
121
- return true;
122
- }
123
-
124
- // ---------------------------------------------------------------------------
125
- // 兼容旧版 buildAgentImageCapabilityPrompt(供 im-skills 使用)
126
- // ---------------------------------------------------------------------------
127
-
128
- export function buildAgentImageCapabilityPrompt(input: {
129
- url: string;
130
- sessionId?: string;
131
- cwd?: string;
132
- }): string {
133
- const lines = [
134
- "[ChatCCC local capability: send image]",
135
- "You can send an image to all chats bound to this session by calling this local endpoint.",
136
- "",
137
- `POST ${input.url}`,
138
- "Content-Type: application/json; charset=utf-8",
139
- "",
140
- `Body: {"session_id":"${input.sessionId ?? "YOUR_SESSION_ID"}","path":"absolute image file path","caption":"optional caption"}`,
141
- "",
142
- "Rules:",
143
- "- Save or choose a local image file first, then call the endpoint.",
144
- "- Use an absolute local file path. Do not call Feishu Open Platform directly.",
145
- "- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
146
- "- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
147
- "[/ChatCCC local capability: send image]",
148
- ];
149
- if (input.cwd) {
150
- lines.splice(2, 0, `Current working directory: ${input.cwd}`);
151
- }
152
- 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, sendImageReply, 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_IMAGE_PATH = "/api/agent/send-image";
12
+
13
+ const MAX_REQUEST_BYTES = 64 * 1024;
14
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
15
+ const ALLOWED_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
16
+
17
+ function jsonReply(res: ServerResponse, status: number, data: unknown): void {
18
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
19
+ res.end(JSON.stringify(data));
20
+ }
21
+
22
+ async function resolveAndValidateImagePath(cwd: string, rawPath: unknown): Promise<string> {
23
+ if (typeof rawPath !== "string" || rawPath.trim() === "") {
24
+ throw new Error("path must be a non-empty string");
25
+ }
26
+
27
+ const sessionRoot = resolve(cwd);
28
+ const imagePath = isAbsolute(rawPath)
29
+ ? resolve(rawPath)
30
+ : resolve(sessionRoot, rawPath);
31
+
32
+ const ext = extname(imagePath).toLowerCase();
33
+ if (!ALLOWED_IMAGE_EXTS.has(ext)) {
34
+ throw new Error(`unsupported image extension: ${ext || "(none)"}`);
35
+ }
36
+
37
+ const st = await stat(imagePath);
38
+ if (!st.isFile()) throw new Error("image path is not a file");
39
+ if (st.size <= 0) throw new Error("image file is empty");
40
+ if (st.size > MAX_IMAGE_BYTES) throw new Error("image file is larger than 10MB");
41
+ return imagePath;
42
+ }
43
+
44
+ export async function handleAgentImageRequest(
45
+ req: IncomingMessage,
46
+ res: ServerResponse,
47
+ ): Promise<boolean> {
48
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
49
+ if (url.pathname !== AGENT_SEND_IMAGE_PATH) return false;
50
+
51
+ if (req.method !== "POST") {
52
+ jsonReply(res, 405, { ok: false, error: "Method not allowed" });
53
+ return true;
54
+ }
55
+
56
+ let payload: { session_id?: unknown; path?: unknown; caption?: unknown };
57
+ try {
58
+ payload = await readUtf8JsonBody(req, MAX_REQUEST_BYTES);
59
+ } catch (err) {
60
+ jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
61
+ return true;
62
+ }
63
+
64
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
65
+ if (!sessionId) {
66
+ jsonReply(res, 400, { ok: false, error: "Missing session_id" });
67
+ return true;
68
+ }
69
+
70
+ // 获取 cwd 以校验路径
71
+ let cwd: string;
72
+ try {
73
+ const { getSessionTool } = await import("./session.ts");
74
+ const tool = await getSessionTool(sessionId);
75
+ const adapter = getAdapterForTool(tool ?? "claude");
76
+ const info = await adapter.getSessionInfo(sessionId);
77
+ if (!info?.cwd) {
78
+ jsonReply(res, 400, { ok: false, error: "Cannot determine cwd for session" });
79
+ return true;
80
+ }
81
+ cwd = info.cwd;
82
+ } catch (err) {
83
+ jsonReply(res, 500, { ok: false, error: `Failed to get session info: ${(err as Error).message}` });
84
+ return true;
85
+ }
86
+
87
+ let imagePath: string;
88
+ try {
89
+ imagePath = await resolveAndValidateImagePath(cwd, payload.path);
90
+ } catch (err) {
91
+ jsonReply(res, 400, { ok: false, error: (err as Error).message });
92
+ return true;
93
+ }
94
+
95
+ // 发送到所有绑定该 session 的群
96
+ const chatIds = getChatsForSession(sessionId);
97
+ if (chatIds.length === 0) {
98
+ jsonReply(res, 404, { ok: false, error: "No chats bound to this session" });
99
+ return true;
100
+ }
101
+
102
+ try {
103
+ const token = await getTenantAccessToken();
104
+ const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
105
+ let sentCount = 0;
106
+ for (const cid of chatIds) {
107
+ try {
108
+ await sendImageReply(token, cid, imagePath);
109
+ if (caption) await sendTextReply(token, cid, caption);
110
+ sentCount++;
111
+ } catch (err) {
112
+ console.error(`[${ts()}] [AGENT-IMAGE] send to ${cid} failed: ${(err as Error).message}`);
113
+ }
114
+ }
115
+ console.log(`[${ts()}] [AGENT-IMAGE] sent image to ${sentCount}/${chatIds.length} chats, session=${sessionId} path=${imagePath}`);
116
+ jsonReply(res, 200, { ok: true, sentTo: sentCount, total: chatIds.length });
117
+ } catch (err) {
118
+ console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
119
+ jsonReply(res, 500, { ok: false, error: (err as Error).message });
120
+ }
121
+ return true;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // 兼容旧版 buildAgentImageCapabilityPrompt(供 im-skills 使用)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export function buildAgentImageCapabilityPrompt(input: {
129
+ url: string;
130
+ sessionId?: string;
131
+ cwd?: string;
132
+ }): string {
133
+ const lines = [
134
+ "[ChatCCC local capability: send image]",
135
+ "You can send an image to all chats bound to this session by calling this local endpoint.",
136
+ "",
137
+ `POST ${input.url}`,
138
+ "Content-Type: application/json; charset=utf-8",
139
+ "",
140
+ `Body: {"session_id":"${input.sessionId ?? "YOUR_SESSION_ID"}","path":"absolute image file path","caption":"optional caption"}`,
141
+ "",
142
+ "Rules:",
143
+ "- Save or choose a local image file first, then call the endpoint.",
144
+ "- Use an absolute local file path. Do not call Feishu Open Platform directly.",
145
+ "- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
146
+ "- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
147
+ "[/ChatCCC local capability: send image]",
148
+ ];
149
+ if (input.cwd) {
150
+ lines.splice(2, 0, `Current working directory: ${input.cwd}`);
151
+ }
152
+ return lines.join("\n");
153
153
  }
@@ -1,48 +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
- }
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
+ }
@@ -1,108 +1,108 @@
1
- type JsonObject = Record<string, unknown>;
2
-
3
- function isObject(value: unknown): value is JsonObject {
4
- return !!value && typeof value === "object" && !Array.isArray(value);
5
- }
6
-
7
- function stringValue(value: unknown): string | null {
8
- return typeof value === "string" && value.trim() ? value.trim() : null;
9
- }
10
-
11
- function textContent(value: unknown): string | null {
12
- if (typeof value === "string") return stringValue(value);
13
- if (!isObject(value)) return null;
14
- return stringValue(value.content);
15
- }
16
-
17
- function parseButtonValue(value: unknown): unknown {
18
- if (typeof value !== "string") return value;
19
- try {
20
- return JSON.parse(value);
21
- } catch {
22
- return value;
23
- }
24
- }
25
-
26
- function commandFromValue(value: unknown): string | null {
27
- const parsed = parseButtonValue(value);
28
- if (typeof parsed === "string") {
29
- const trimmed = parsed.trim();
30
- return trimmed.startsWith("/") ? trimmed : null;
31
- }
32
- if (!isObject(parsed)) return null;
33
-
34
- const cmd = stringValue(parsed.cmd);
35
- if (cmd) return cmd.startsWith("/") ? cmd : `/${cmd}`;
36
-
37
- const action = stringValue(parsed.action);
38
- if (!action) return null;
39
-
40
- if (action === "cd") {
41
- const path = stringValue(parsed.path);
42
- return path ? `/cd ${path}` : "/cd";
43
- }
44
- return action.startsWith("/") ? action : `/${action}`;
45
- }
46
-
47
- function buttonText(element: JsonObject): string | null {
48
- const label = textContent(element.text);
49
- const command = commandFromValue(element.value);
50
- if (label && command) return `${label}: ${command}`;
51
- return label ?? command;
52
- }
53
-
54
- function elementToText(element: unknown): string[] {
55
- if (!isObject(element)) return [];
56
- const tag = stringValue(element.tag);
57
-
58
- if (tag === "div") {
59
- const content = textContent(element.text);
60
- return content ? [content] : [];
61
- }
62
-
63
- if (tag === "markdown") {
64
- const content = stringValue(element.content);
65
- return content ? [content] : [];
66
- }
67
-
68
- if (tag === "button") {
69
- const content = buttonText(element);
70
- return content ? [content] : [];
71
- }
72
-
73
- if (tag === "action" && Array.isArray(element.actions)) {
74
- return element.actions.flatMap(elementToText);
75
- }
76
-
77
- return [];
78
- }
79
-
80
- function rootElements(card: JsonObject): unknown[] {
81
- if (Array.isArray(card.elements)) return card.elements;
82
- if (isObject(card.body) && Array.isArray(card.body.elements)) {
83
- return card.body.elements;
84
- }
85
- return [];
86
- }
87
-
88
- export function cardJsonToPlainText(cardJson: string): string | null {
89
- let card: unknown;
90
- try {
91
- card = JSON.parse(cardJson);
92
- } catch {
93
- return null;
94
- }
95
- if (!isObject(card)) return null;
96
-
97
- const title = isObject(card.header) && isObject(card.header.title)
98
- ? textContent(card.header.title)
99
- : null;
100
- const lines = rootElements(card).flatMap(elementToText);
101
- const parts = title ? [`# ${title}`, ...lines] : lines;
102
- const text = parts
103
- .map((part) => part.trim())
104
- .filter(Boolean)
105
- .join("\n\n")
106
- .trim();
107
- return text || null;
108
- }
1
+ type JsonObject = Record<string, unknown>;
2
+
3
+ function isObject(value: unknown): value is JsonObject {
4
+ return !!value && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+
7
+ function stringValue(value: unknown): string | null {
8
+ return typeof value === "string" && value.trim() ? value.trim() : null;
9
+ }
10
+
11
+ function textContent(value: unknown): string | null {
12
+ if (typeof value === "string") return stringValue(value);
13
+ if (!isObject(value)) return null;
14
+ return stringValue(value.content);
15
+ }
16
+
17
+ function parseButtonValue(value: unknown): unknown {
18
+ if (typeof value !== "string") return value;
19
+ try {
20
+ return JSON.parse(value);
21
+ } catch {
22
+ return value;
23
+ }
24
+ }
25
+
26
+ function commandFromValue(value: unknown): string | null {
27
+ const parsed = parseButtonValue(value);
28
+ if (typeof parsed === "string") {
29
+ const trimmed = parsed.trim();
30
+ return trimmed.startsWith("/") ? trimmed : null;
31
+ }
32
+ if (!isObject(parsed)) return null;
33
+
34
+ const cmd = stringValue(parsed.cmd);
35
+ if (cmd) return cmd.startsWith("/") ? cmd : `/${cmd}`;
36
+
37
+ const action = stringValue(parsed.action);
38
+ if (!action) return null;
39
+
40
+ if (action === "cd") {
41
+ const path = stringValue(parsed.path);
42
+ return path ? `/cd ${path}` : "/cd";
43
+ }
44
+ return action.startsWith("/") ? action : `/${action}`;
45
+ }
46
+
47
+ function buttonText(element: JsonObject): string | null {
48
+ const label = textContent(element.text);
49
+ const command = commandFromValue(element.value);
50
+ if (label && command) return `${label}: ${command}`;
51
+ return label ?? command;
52
+ }
53
+
54
+ function elementToText(element: unknown): string[] {
55
+ if (!isObject(element)) return [];
56
+ const tag = stringValue(element.tag);
57
+
58
+ if (tag === "div") {
59
+ const content = textContent(element.text);
60
+ return content ? [content] : [];
61
+ }
62
+
63
+ if (tag === "markdown") {
64
+ const content = stringValue(element.content);
65
+ return content ? [content] : [];
66
+ }
67
+
68
+ if (tag === "button") {
69
+ const content = buttonText(element);
70
+ return content ? [content] : [];
71
+ }
72
+
73
+ if (tag === "action" && Array.isArray(element.actions)) {
74
+ return element.actions.flatMap(elementToText);
75
+ }
76
+
77
+ return [];
78
+ }
79
+
80
+ function rootElements(card: JsonObject): unknown[] {
81
+ if (Array.isArray(card.elements)) return card.elements;
82
+ if (isObject(card.body) && Array.isArray(card.body.elements)) {
83
+ return card.body.elements;
84
+ }
85
+ return [];
86
+ }
87
+
88
+ export function cardJsonToPlainText(cardJson: string): string | null {
89
+ let card: unknown;
90
+ try {
91
+ card = JSON.parse(cardJson);
92
+ } catch {
93
+ return null;
94
+ }
95
+ if (!isObject(card)) return null;
96
+
97
+ const title = isObject(card.header) && isObject(card.header.title)
98
+ ? textContent(card.header.title)
99
+ : null;
100
+ const lines = rootElements(card).flatMap(elementToText);
101
+ const parts = title ? [`# ${title}`, ...lines] : lines;
102
+ const text = parts
103
+ .map((part) => part.trim())
104
+ .filter(Boolean)
105
+ .join("\n\n")
106
+ .trim();
107
+ return text || null;
108
+ }
package/src/feishu-api.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  CODEX_SESSION_PREFIX,
16
16
  ts,
17
17
  } from "./config.ts";
18
+ import { applyPrivacy } from "./privacy.ts";
18
19
  import { buildHelpCard } from "./cards.ts";
19
20
 
20
21
  // ---------------------------------------------------------------------------
@@ -536,9 +537,10 @@ export async function sendTextReply(
536
537
  chatId: string,
537
538
  text: string
538
539
  ): Promise<boolean> {
540
+ const safeText = applyPrivacy(text);
539
541
  const card = JSON.stringify({
540
542
  config: { wide_screen_mode: true },
541
- elements: [{ tag: "markdown", content: text }],
543
+ elements: [{ tag: "markdown", content: safeText }],
542
544
  });
543
545
  try {
544
546
  const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
@@ -778,10 +780,12 @@ export async function sendCardReply(
778
780
  content: string,
779
781
  template = "green"
780
782
  ): Promise<boolean> {
783
+ const safeTitle = applyPrivacy(title);
784
+ const safeContent = applyPrivacy(content);
781
785
  const card = JSON.stringify({
782
786
  config: { wide_screen_mode: true },
783
- header: { template, title: { content: title, tag: "plain_text" } },
784
- elements: [{ tag: "div", text: { tag: "lark_md", content } }],
787
+ header: { template, title: { content: safeTitle, tag: "plain_text" } },
788
+ elements: [{ tag: "div", text: { tag: "lark_md", content: safeContent } }],
785
789
  });
786
790
 
787
791
  try {