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.
- package/README.md +95 -256
- package/bin/chatccc.mjs +23 -23
- package/demo/ilink_echo_probe.ts +222 -222
- package/im-skills/feishu-skill/download-video.mjs +162 -162
- package/im-skills/feishu-skill/receive-send-file.md +66 -66
- package/im-skills/feishu-skill/receive-send-image.md +27 -27
- package/im-skills/feishu-skill/send-file.mjs +50 -50
- package/im-skills/feishu-skill/send-image.mjs +50 -50
- package/im-skills/feishu-skill/skill.md +10 -10
- package/package.json +59 -59
- package/src/__tests__/agent-image-rpc.test.ts +33 -33
- package/src/__tests__/agent-rpc-body.test.ts +41 -41
- package/src/__tests__/card-plain-text.test.ts +42 -42
- package/src/__tests__/codex-adapter.test.ts +304 -304
- package/src/__tests__/config-reload.test.ts +26 -26
- package/src/__tests__/config-sample.test.ts +19 -19
- package/src/__tests__/crash-logging.test.ts +62 -62
- package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
- package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
- package/src/__tests__/im-skills.test.ts +46 -46
- package/src/__tests__/privacy.test.ts +143 -0
- package/src/__tests__/session.test.ts +57 -2
- package/src/__tests__/sim-agent.test.ts +173 -173
- package/src/__tests__/sim-platform.test.ts +76 -76
- package/src/__tests__/sim-store.test.ts +213 -213
- package/src/__tests__/stream-state.test.ts +134 -134
- package/src/__tests__/wechat-platform.test.ts +57 -32
- package/src/adapters/codex-adapter.ts +294 -294
- package/src/adapters/codex-session-meta-store.ts +130 -130
- package/src/agent-file-rpc.ts +156 -156
- package/src/agent-image-rpc.ts +152 -152
- package/src/agent-rpc-body.ts +48 -48
- package/src/card-plain-text.ts +108 -108
- package/src/feishu-api.ts +7 -3
- package/src/im-skills.ts +109 -109
- package/src/platform-adapter.ts +60 -60
- package/src/privacy.ts +68 -0
- package/src/session-chat-binding.ts +6 -1
- package/src/session.ts +40 -35
- package/src/sim-agent.ts +167 -167
- package/src/trace.ts +50 -50
- package/src/wechat-platform.ts +4 -1
package/src/agent-image-rpc.ts
CHANGED
|
@@ -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
|
}
|
package/src/agent-rpc-body.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/card-plain-text.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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 {
|