chatccc 0.2.21 → 0.2.23
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 +7 -5
- package/images/img_readme_messages.jpg +0 -0
- package/package.json +5 -2
- package/src/__tests__/agent-image-rpc.test.ts +67 -0
- package/src/agent-image-rpc.ts +204 -0
- package/src/feishu-api.ts +115 -23
- package/src/index.ts +66 -7
- package/src/session.ts +35 -2
- package/src/trace.ts +51 -0
- package/src/web-ui.ts +8 -0
- package/images/avatars/brand-sources/claude_code_app_icon.png +0 -0
- package/images/avatars/brand-sources/codex_app_icon.png +0 -0
- package/images/avatars/brand-sources/cursor_icon_512.png +0 -0
- package/images/downloads/img_v3_0211k_d061664d-e25b-41ad-be94-442513e4d22g.webp +0 -0
package/README.md
CHANGED
|
@@ -16,11 +16,13 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
|
|
|
16
16
|
|
|
17
17
|
一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
|
|
18
18
|
|
|
19
|
-
<p align="center">
|
|
20
|
-
<img src="images/
|
|
21
|
-
&
|
|
22
|
-
<img src="images/
|
|
23
|
-
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" align="top" />
|
|
21
|
+
|
|
22
|
+
<img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" align="top" />
|
|
23
|
+
|
|
24
|
+
<img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" align="top" />
|
|
25
|
+
</p>
|
|
24
26
|
|
|
25
27
|
---
|
|
26
28
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"src/",
|
|
13
13
|
"bin/",
|
|
14
|
-
"images/",
|
|
14
|
+
"images/img_readme_*.jpg",
|
|
15
|
+
"images/img_readme_*.png",
|
|
16
|
+
"images/avatars/status_*.png",
|
|
17
|
+
"images/avatars/badges/",
|
|
15
18
|
"package.json",
|
|
16
19
|
"README.md",
|
|
17
20
|
"config.sample.json"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AGENT_SEND_IMAGE_PATH,
|
|
5
|
+
buildAgentImageCapabilityPrompt,
|
|
6
|
+
createAgentImageGrant,
|
|
7
|
+
getAgentImageGrantFromAuthorization,
|
|
8
|
+
revokeAgentImageGrant,
|
|
9
|
+
} from "../agent-image-rpc.ts";
|
|
10
|
+
|
|
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", () => {
|
|
56
|
+
const prompt = buildAgentImageCapabilityPrompt({
|
|
57
|
+
url: `http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`,
|
|
58
|
+
token: "tok_test",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/send-image");
|
|
62
|
+
expect(prompt).toContain("Authorization: Bearer tok_test");
|
|
63
|
+
expect(prompt).toContain("\"path\"");
|
|
64
|
+
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_URL");
|
|
65
|
+
expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_TOKEN");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
import { getTenantAccessToken, sendImageReply, sendTextReply } from "./feishu-api.ts";
|
|
7
|
+
import { ts } from "./config.ts";
|
|
8
|
+
import { logTrace } from "./trace.ts";
|
|
9
|
+
|
|
10
|
+
export const AGENT_SEND_IMAGE_PATH = "/api/agent/send-image";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
|
|
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
|
+
export interface AgentImageGrant {
|
|
18
|
+
token: string;
|
|
19
|
+
url: string;
|
|
20
|
+
chatId: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
cwd: string;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
traceId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CreateAgentImageGrantInput {
|
|
28
|
+
chatId: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
cwd: string;
|
|
31
|
+
port: number;
|
|
32
|
+
traceId?: string;
|
|
33
|
+
nowMs?: number;
|
|
34
|
+
ttlMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const imageGrants = new Map<string, AgentImageGrant>();
|
|
38
|
+
|
|
39
|
+
function createToken(): string {
|
|
40
|
+
return randomBytes(24).toString("base64url");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAgentImageGrant(input: CreateAgentImageGrantInput): AgentImageGrant {
|
|
44
|
+
const now = input.nowMs ?? Date.now();
|
|
45
|
+
const token = createToken();
|
|
46
|
+
const grant: AgentImageGrant = {
|
|
47
|
+
token,
|
|
48
|
+
url: `http://127.0.0.1:${input.port}${AGENT_SEND_IMAGE_PATH}`,
|
|
49
|
+
chatId: input.chatId,
|
|
50
|
+
sessionId: input.sessionId,
|
|
51
|
+
cwd: input.cwd,
|
|
52
|
+
expiresAt: now + (input.ttlMs ?? DEFAULT_GRANT_TTL_MS),
|
|
53
|
+
traceId: input.traceId ?? "",
|
|
54
|
+
};
|
|
55
|
+
imageGrants.set(token, grant);
|
|
56
|
+
if (grant.traceId) logTrace(grant.traceId, "IMAGE_GRANT_CREATED", { sessionId: grant.sessionId, ttlMs: input.ttlMs ?? DEFAULT_GRANT_TTL_MS });
|
|
57
|
+
return grant;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function revokeAgentImageGrant(token: string): void {
|
|
61
|
+
const grant = imageGrants.get(token);
|
|
62
|
+
if (grant?.traceId) logTrace(grant.traceId, "IMAGE_GRANT_REVOKED", {});
|
|
63
|
+
imageGrants.delete(token);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getAgentImageGrantFromAuthorization(
|
|
67
|
+
authorization: string | undefined,
|
|
68
|
+
nowMs = Date.now(),
|
|
69
|
+
): AgentImageGrant | null {
|
|
70
|
+
const match = (authorization ?? "").match(/^Bearer\s+(.+)$/i);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
const grant = imageGrants.get(match[1]);
|
|
73
|
+
if (!grant) return null;
|
|
74
|
+
if (grant.expiresAt <= nowMs) {
|
|
75
|
+
imageGrants.delete(match[1]);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return grant;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildAgentImageCapabilityPrompt(input: {
|
|
82
|
+
url: string;
|
|
83
|
+
token: string;
|
|
84
|
+
cwd?: string;
|
|
85
|
+
}): string {
|
|
86
|
+
const lines = [
|
|
87
|
+
"[ChatCCC local capability: send image]",
|
|
88
|
+
"You can send an image to the current Feishu chat in real time by calling this local endpoint.",
|
|
89
|
+
"",
|
|
90
|
+
`POST ${input.url}`,
|
|
91
|
+
`Authorization: Bearer ${input.token}`,
|
|
92
|
+
"Content-Type: application/json",
|
|
93
|
+
"",
|
|
94
|
+
'Body: {"path":"absolute image file path","caption":"optional caption"}',
|
|
95
|
+
"",
|
|
96
|
+
"Rules:",
|
|
97
|
+
"- Save or choose a local image file first, then call the endpoint.",
|
|
98
|
+
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
99
|
+
"- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
|
|
100
|
+
"[/ChatCCC local capability: send image]",
|
|
101
|
+
];
|
|
102
|
+
if (input.cwd) {
|
|
103
|
+
lines.splice(2, 0, `Current working directory: ${input.cwd}`);
|
|
104
|
+
}
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
109
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
110
|
+
res.end(JSON.stringify(data));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readLimitedBody(req: IncomingMessage): Promise<string> {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
let body = "";
|
|
116
|
+
let size = 0;
|
|
117
|
+
req.on("data", (chunk: Buffer) => {
|
|
118
|
+
size += chunk.length;
|
|
119
|
+
if (size > MAX_REQUEST_BYTES) {
|
|
120
|
+
reject(new Error("Request body too large"));
|
|
121
|
+
req.destroy();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
body += chunk.toString("utf8");
|
|
125
|
+
});
|
|
126
|
+
req.on("end", () => resolve(body));
|
|
127
|
+
req.on("error", reject);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function resolveAndValidateImagePath(grant: AgentImageGrant, rawPath: unknown): Promise<string> {
|
|
132
|
+
if (typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
133
|
+
throw new Error("path must be a non-empty string");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sessionRoot = resolve(grant.cwd);
|
|
137
|
+
const imagePath = isAbsolute(rawPath)
|
|
138
|
+
? resolve(rawPath)
|
|
139
|
+
: resolve(sessionRoot, rawPath);
|
|
140
|
+
|
|
141
|
+
const ext = extname(imagePath).toLowerCase();
|
|
142
|
+
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
|
|
143
|
+
throw new Error(`unsupported image extension: ${ext || "(none)"}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const st = await stat(imagePath);
|
|
147
|
+
if (!st.isFile()) throw new Error("image path is not a file");
|
|
148
|
+
if (st.size <= 0) throw new Error("image file is empty");
|
|
149
|
+
if (st.size > MAX_IMAGE_BYTES) throw new Error("image file is larger than 10MB");
|
|
150
|
+
return imagePath;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function handleAgentImageRequest(
|
|
154
|
+
req: IncomingMessage,
|
|
155
|
+
res: ServerResponse,
|
|
156
|
+
): Promise<boolean> {
|
|
157
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
158
|
+
if (url.pathname !== AGENT_SEND_IMAGE_PATH) return false;
|
|
159
|
+
|
|
160
|
+
if (req.method !== "POST") {
|
|
161
|
+
jsonReply(res, 405, { ok: false, error: "Method not allowed" });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const grant = getAgentImageGrantFromAuthorization(req.headers.authorization);
|
|
166
|
+
if (!grant) {
|
|
167
|
+
jsonReply(res, 401, { ok: false, error: "Invalid or expired image-send token" });
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
const tid = grant.traceId;
|
|
171
|
+
|
|
172
|
+
let payload: { path?: unknown; caption?: unknown };
|
|
173
|
+
try {
|
|
174
|
+
payload = JSON.parse(await readLimitedBody(req));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_json", error: (err as Error).message });
|
|
177
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let imagePath: string;
|
|
182
|
+
try {
|
|
183
|
+
imagePath = await resolveAndValidateImagePath(grant, payload.path);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_path", path: String(payload.path), error: (err as Error).message });
|
|
186
|
+
jsonReply(res, 400, { ok: false, error: (err as Error).message });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const token = await getTenantAccessToken();
|
|
192
|
+
const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
|
|
193
|
+
await sendImageReply(token, grant.chatId, imagePath);
|
|
194
|
+
if (caption) await sendTextReply(token, grant.chatId, caption);
|
|
195
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "sent", path: imagePath, hasCaption: !!caption });
|
|
196
|
+
console.log(`[${ts()}] [AGENT-IMAGE] sent image chat=${grant.chatId} session=${grant.sessionId} path=${imagePath}`);
|
|
197
|
+
jsonReply(res, 200, { ok: true });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "send_failed", path: imagePath!, error: (err as Error).message });
|
|
200
|
+
console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
|
|
201
|
+
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
package/src/feishu-api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { resolve as resolvePath } from "node:path";
|
|
2
|
+
import { extname, resolve as resolvePath } from "node:path";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import sharp from "sharp";
|
|
5
5
|
|
|
@@ -424,7 +424,7 @@ async function loadImageCache(): Promise<void> {
|
|
|
424
424
|
for (const [key, value] of Object.entries(parsed)) {
|
|
425
425
|
if (typeof value === "string" && value.trim()) imageCache.set(key, value);
|
|
426
426
|
}
|
|
427
|
-
} catch { /* missing or malformed cache is not fatal */ }
|
|
427
|
+
} catch { /* missing or malformed cache is not fatal */ }
|
|
428
428
|
}
|
|
429
429
|
|
|
430
430
|
async function persistImageCache(): Promise<void> {
|
|
@@ -475,7 +475,7 @@ export async function getOrDownloadImage(token: string, messageId: string, fileK
|
|
|
475
475
|
await persistImageCache().catch((err) => {
|
|
476
476
|
console.error(`[${ts()}] [IMAGE] persist cache FAIL: ${(err as Error).message}`);
|
|
477
477
|
});
|
|
478
|
-
console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
|
|
478
|
+
console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
|
|
479
479
|
return localPath;
|
|
480
480
|
}
|
|
481
481
|
|
|
@@ -521,23 +521,103 @@ export async function sendTextReply(
|
|
|
521
521
|
token: string,
|
|
522
522
|
chatId: string,
|
|
523
523
|
text: string
|
|
524
|
-
): Promise<
|
|
524
|
+
): Promise<boolean> {
|
|
525
525
|
const card = JSON.stringify({
|
|
526
526
|
config: { wide_screen_mode: true },
|
|
527
527
|
elements: [{ tag: "markdown", content: text }],
|
|
528
528
|
});
|
|
529
|
-
|
|
529
|
+
try {
|
|
530
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: {
|
|
533
|
+
Authorization: `Bearer ${token}`,
|
|
534
|
+
"Content-Type": "application/json",
|
|
535
|
+
},
|
|
536
|
+
body: JSON.stringify({
|
|
537
|
+
receive_id: chatId,
|
|
538
|
+
msg_type: "interactive",
|
|
539
|
+
content: card,
|
|
540
|
+
}),
|
|
541
|
+
});
|
|
542
|
+
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
543
|
+
if (data.code !== 0) {
|
|
544
|
+
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
console.log(`[${ts()}] [SEND] text OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
548
|
+
return true;
|
|
549
|
+
} catch (err) {
|
|
550
|
+
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function messageImageContentType(imagePath: string): string {
|
|
556
|
+
const ext = extname(imagePath).toLowerCase();
|
|
557
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
558
|
+
if (ext === ".webp") return "image/webp";
|
|
559
|
+
if (ext === ".gif") return "image/gif";
|
|
560
|
+
if (ext === ".bmp") return "image/bmp";
|
|
561
|
+
return "image/png";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function uploadMessageImage(token: string, imagePath: string): Promise<string> {
|
|
565
|
+
const buffer = await readFile(imagePath);
|
|
566
|
+
const blob = new Blob([new Uint8Array(buffer)], {
|
|
567
|
+
type: messageImageContentType(imagePath),
|
|
568
|
+
});
|
|
569
|
+
const form = new FormData();
|
|
570
|
+
form.append("image_type", "message");
|
|
571
|
+
form.append("image", blob, imagePath.split(/[\\/]/).pop() || "image.png");
|
|
572
|
+
|
|
573
|
+
const resp = await fetch(`${BASE_URL}/im/v1/images`, {
|
|
530
574
|
method: "POST",
|
|
531
|
-
headers: {
|
|
532
|
-
|
|
533
|
-
"Content-Type": "application/json",
|
|
534
|
-
},
|
|
535
|
-
body: JSON.stringify({
|
|
536
|
-
receive_id: chatId,
|
|
537
|
-
msg_type: "interactive",
|
|
538
|
-
content: card,
|
|
539
|
-
}),
|
|
575
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
576
|
+
body: form,
|
|
540
577
|
});
|
|
578
|
+
const text = await resp.text();
|
|
579
|
+
let data: { code: number; msg?: string; data?: { image_key?: string } };
|
|
580
|
+
try { data = JSON.parse(text); } catch {
|
|
581
|
+
throw new Error(`uploadMessageImage non-JSON response: ${text.slice(0, 200)}`);
|
|
582
|
+
}
|
|
583
|
+
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
584
|
+
const imageKey = data.data?.image_key;
|
|
585
|
+
if (!imageKey) throw new Error("uploadMessageImage response missing image_key");
|
|
586
|
+
return imageKey;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export async function sendImageReply(
|
|
590
|
+
token: string,
|
|
591
|
+
chatId: string,
|
|
592
|
+
imagePath: string,
|
|
593
|
+
): Promise<boolean> {
|
|
594
|
+
try {
|
|
595
|
+
const imageKey = await uploadMessageImage(token, imagePath);
|
|
596
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
597
|
+
method: "POST",
|
|
598
|
+
headers: {
|
|
599
|
+
Authorization: `Bearer ${token}`,
|
|
600
|
+
"Content-Type": "application/json",
|
|
601
|
+
},
|
|
602
|
+
body: JSON.stringify({
|
|
603
|
+
receive_id: chatId,
|
|
604
|
+
msg_type: "image",
|
|
605
|
+
content: JSON.stringify({ image_key: imageKey }),
|
|
606
|
+
}),
|
|
607
|
+
});
|
|
608
|
+
const data = (await resp.json().catch(() => ({}))) as { code?: number; msg?: string; data?: { message_id?: string } };
|
|
609
|
+
if (data.code !== 0) {
|
|
610
|
+
console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
611
|
+
throw new Error(`[${data.code}] ${data.msg ?? "send image failed"}`);
|
|
612
|
+
}
|
|
613
|
+
console.log(`[${ts()}] [SEND] image OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
614
|
+
return true;
|
|
615
|
+
} catch (err) {
|
|
616
|
+
if (!(err instanceof Error && err.message.startsWith("["))) {
|
|
617
|
+
console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} ${(err as Error).message}`);
|
|
618
|
+
}
|
|
619
|
+
throw err;
|
|
620
|
+
}
|
|
541
621
|
}
|
|
542
622
|
|
|
543
623
|
export async function addReaction(
|
|
@@ -561,21 +641,33 @@ export async function sendCardReply(
|
|
|
561
641
|
title: string,
|
|
562
642
|
content: string,
|
|
563
643
|
template = "green"
|
|
564
|
-
): Promise<
|
|
644
|
+
): Promise<boolean> {
|
|
565
645
|
const card = JSON.stringify({
|
|
566
646
|
config: { wide_screen_mode: true },
|
|
567
647
|
header: { template, title: { content: title, tag: "plain_text" } },
|
|
568
648
|
elements: [{ tag: "div", text: { tag: "lark_md", content } }],
|
|
569
649
|
});
|
|
570
650
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
651
|
+
try {
|
|
652
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
653
|
+
method: "POST",
|
|
654
|
+
headers: {
|
|
655
|
+
Authorization: `Bearer ${token}`,
|
|
656
|
+
"Content-Type": "application/json",
|
|
657
|
+
},
|
|
658
|
+
body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
|
|
659
|
+
});
|
|
660
|
+
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
661
|
+
if (data.code !== 0) {
|
|
662
|
+
console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" code=${data.code} msg="${data.msg ?? ""}"`);
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
console.log(`[${ts()}] [SEND] card OK: chatId=${chatId} title="${title}" msgId=${data.data?.message_id ?? "N/A"}`);
|
|
666
|
+
return true;
|
|
667
|
+
} catch (err) {
|
|
668
|
+
console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" ${(err as Error).message}`);
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
579
671
|
}
|
|
580
672
|
|
|
581
673
|
// 重启后,向最后有发言的会话发送 "已重启" 卡片(基于 chat_logs 的文件修改时间)
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,8 @@ import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
|
|
|
31
31
|
import WebSocket from "ws";
|
|
32
32
|
|
|
33
33
|
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging } from "./shared.ts";
|
|
34
|
-
import { createUiRouter, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
34
|
+
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
35
|
+
import { makeTraceId, logTrace } from "./trace.ts";
|
|
35
36
|
import {
|
|
36
37
|
CHATCCC_PORT,
|
|
37
38
|
APP_ID,
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
} from "./feishu-api.ts";
|
|
82
83
|
import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
|
|
83
84
|
import { updateCardKitCard } from "./cardkit.ts";
|
|
85
|
+
import { handleAgentImageRequest } from "./agent-image-rpc.ts";
|
|
84
86
|
import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
|
|
85
87
|
import {
|
|
86
88
|
MAX_PROCESSED,
|
|
@@ -113,7 +115,7 @@ function isUntitledSessionChatName(name: string): boolean {
|
|
|
113
115
|
// ---------------------------------------------------------------------------
|
|
114
116
|
|
|
115
117
|
interface InnerEvent {
|
|
116
|
-
message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; create_time?: string };
|
|
118
|
+
message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; chat_type?: string; create_time?: string };
|
|
117
119
|
sender?: { sender_id?: { open_id?: string; union_id?: string } };
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -242,10 +244,13 @@ let broadcastToRelay: (data: unknown) => void = () => {};
|
|
|
242
244
|
// Command handler
|
|
243
245
|
// ---------------------------------------------------------------------------
|
|
244
246
|
|
|
245
|
-
async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number): Promise<void> {
|
|
247
|
+
async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
|
|
248
|
+
const tid = traceId ?? makeTraceId();
|
|
246
249
|
if (text === "/restart") {
|
|
250
|
+
logTrace(tid, "BRANCH", { cmd: "/restart" });
|
|
247
251
|
const restartToken = await getTenantAccessToken();
|
|
248
252
|
await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
|
|
253
|
+
logTrace(tid, "DONE", { outcome: "restart" });
|
|
249
254
|
console.log(`[${ts()}] [RESTART] Spawning new process...`);
|
|
250
255
|
const child = spawn("npx", ["tsx", "src/index.ts"], {
|
|
251
256
|
cwd: PROJECT_ROOT,
|
|
@@ -259,6 +264,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
259
264
|
}
|
|
260
265
|
|
|
261
266
|
if (text === "/cd" || text.startsWith("/cd ")) {
|
|
267
|
+
logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
|
|
262
268
|
const cdToken = await getTenantAccessToken();
|
|
263
269
|
const currentDir = await getDefaultCwd();
|
|
264
270
|
|
|
@@ -290,10 +296,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
290
296
|
try {
|
|
291
297
|
const s = await stat(targetDir);
|
|
292
298
|
if (!s.isDirectory()) {
|
|
299
|
+
logTrace(tid, "DONE", { outcome: "cd_not_dir", targetDir });
|
|
293
300
|
await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
|
|
294
301
|
return;
|
|
295
302
|
}
|
|
296
303
|
} catch {
|
|
304
|
+
logTrace(tid, "DONE", { outcome: "cd_not_found", targetDir });
|
|
297
305
|
await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
|
|
298
306
|
return;
|
|
299
307
|
}
|
|
@@ -310,6 +318,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
310
318
|
try {
|
|
311
319
|
entries = await readdir(targetDir);
|
|
312
320
|
} catch (err) {
|
|
321
|
+
logTrace(tid, "DONE", { outcome: "cd_readdir_fail", error: (err as Error).message });
|
|
313
322
|
await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
|
|
314
323
|
return;
|
|
315
324
|
}
|
|
@@ -341,10 +350,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
341
350
|
});
|
|
342
351
|
const respData: Record<string, any> = await resp.json().catch(() => ({}));
|
|
343
352
|
console.log(`[${ts()}] [CD] card sent, code=${respData.code}, msgId=${respData.data?.message_id ?? "N/A"}, recentDirs=${recentDirs.length}`);
|
|
353
|
+
logTrace(tid, "DONE", { outcome: "cd_card", code: respData.code, msgId: respData.data?.message_id });
|
|
344
354
|
} else {
|
|
345
355
|
// /cd <path>:切换目录,发送文本卡片
|
|
346
356
|
const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
|
|
347
357
|
await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
|
|
358
|
+
logTrace(tid, "DONE", { outcome: "cd_path", targetDir, isUpdate });
|
|
348
359
|
}
|
|
349
360
|
return;
|
|
350
361
|
}
|
|
@@ -352,8 +363,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
352
363
|
if (text === "/new" || text.startsWith("/new ")) {
|
|
353
364
|
const toolArg = text.slice(5).trim();
|
|
354
365
|
const tool = toolArg || "claude";
|
|
366
|
+
logTrace(tid, "BRANCH", { cmd: "/new", tool });
|
|
355
367
|
const validTools = ["claude", "cursor", "codex"];
|
|
356
368
|
if (!validTools.includes(tool)) {
|
|
369
|
+
logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
|
|
357
370
|
const warnToken = await getTenantAccessToken();
|
|
358
371
|
await sendCardReply(warnToken, chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor), codex (Codex)。`, "red");
|
|
359
372
|
return;
|
|
@@ -361,6 +374,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
361
374
|
const toolLabel = toolDisplayName(tool);
|
|
362
375
|
|
|
363
376
|
if (!openId) {
|
|
377
|
+
logTrace(tid, "DONE", { outcome: "new_no_openid" });
|
|
364
378
|
console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
|
|
365
379
|
const warnToken = await getTenantAccessToken();
|
|
366
380
|
await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
|
|
@@ -378,6 +392,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
378
392
|
console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
|
|
379
393
|
} catch (err) {
|
|
380
394
|
console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
|
|
395
|
+
logTrace(tid, "DONE", { outcome: "new_session_fail", error: (err as Error).message });
|
|
381
396
|
await sendCardReply(
|
|
382
397
|
freshToken, chatId, "Error",
|
|
383
398
|
`Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
|
|
@@ -395,6 +410,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
395
410
|
console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
|
|
396
411
|
} catch (err) {
|
|
397
412
|
console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
|
|
413
|
+
logTrace(tid, "DONE", { outcome: "new_group_fail", error: (err as Error).message });
|
|
398
414
|
await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
|
|
399
415
|
return;
|
|
400
416
|
}
|
|
@@ -405,6 +421,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
405
421
|
console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
|
|
406
422
|
} catch (err) {
|
|
407
423
|
console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
|
|
424
|
+
logTrace(tid, "DONE", { outcome: "new_rename_fail", error: (err as Error).message });
|
|
408
425
|
await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
|
|
409
426
|
return;
|
|
410
427
|
}
|
|
@@ -422,11 +439,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
422
439
|
);
|
|
423
440
|
|
|
424
441
|
console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
|
|
442
|
+
logTrace(tid, "DONE", { outcome: "session_ready", newChatId, sessionId, tool });
|
|
425
443
|
setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
|
|
426
444
|
console.log(`${"=".repeat(60)}`);
|
|
427
445
|
return;
|
|
428
446
|
}
|
|
429
447
|
|
|
448
|
+
// 私聊没有群 description,无法存储/获取 session ID,跳过群聊检测直接发 help card
|
|
449
|
+
if (chatType !== "p2p") {
|
|
430
450
|
try {
|
|
431
451
|
const token = await getTenantAccessToken();
|
|
432
452
|
const chatInfo = await getChatInfo(token, chatId);
|
|
@@ -437,6 +457,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
437
457
|
const sessionId = sessionInfo.sessionId;
|
|
438
458
|
const descriptionTool = sessionInfo.tool;
|
|
439
459
|
const toolLabel = toolDisplayName(descriptionTool);
|
|
460
|
+
logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
|
|
440
461
|
console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
|
|
441
462
|
|
|
442
463
|
const freshToken = await getTenantAccessToken();
|
|
@@ -457,6 +478,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
457
478
|
}
|
|
458
479
|
|
|
459
480
|
if (text === "/stop") {
|
|
481
|
+
logTrace(tid, "BRANCH", { cmd: "/stop" });
|
|
460
482
|
const cEntry = chatSessionMap.get(chatId);
|
|
461
483
|
if (cEntry) {
|
|
462
484
|
cEntry.stopped = true;
|
|
@@ -464,13 +486,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
464
486
|
cEntry.close();
|
|
465
487
|
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
466
488
|
await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
|
|
489
|
+
logTrace(tid, "DONE", { outcome: "stopped" });
|
|
467
490
|
} else {
|
|
468
491
|
await sendTextReply(freshToken, chatId, "当前没有正在进行的会话。").catch(() => {});
|
|
492
|
+
logTrace(tid, "DONE", { outcome: "stop_no_session" });
|
|
469
493
|
}
|
|
470
494
|
return;
|
|
471
495
|
}
|
|
472
496
|
|
|
473
497
|
if (text === "/status") {
|
|
498
|
+
logTrace(tid, "BRANCH", { cmd: "/status" });
|
|
474
499
|
const status = await getSessionStatus(chatId);
|
|
475
500
|
const running = chatSessionMap.get(chatId);
|
|
476
501
|
const isActive = running && !running.stopped;
|
|
@@ -507,10 +532,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
507
532
|
});
|
|
508
533
|
const statusRespData: Record<string, any> = await statusResp.json().catch(() => ({}));
|
|
509
534
|
console.log(`[${ts()}] [STATUS] card sent, code=${statusRespData.code}, msgId=${statusRespData.data?.message_id ?? "N/A"}`);
|
|
535
|
+
logTrace(tid, "DONE", { outcome: "status", code: statusRespData.code });
|
|
510
536
|
return;
|
|
511
537
|
}
|
|
512
538
|
|
|
513
539
|
if (text === "/sessions") {
|
|
540
|
+
logTrace(tid, "BRANCH", { cmd: "/sessions" });
|
|
514
541
|
const allSessions = await getAllSessionsStatus();
|
|
515
542
|
const now = Date.now();
|
|
516
543
|
const cardData = allSessions.map(s => ({
|
|
@@ -532,10 +559,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
532
559
|
});
|
|
533
560
|
const sessionsRespData: Record<string, any> = await sessionsResp.json().catch(() => ({}));
|
|
534
561
|
console.log(`[${ts()}] [SESSIONS] card sent, code=${sessionsRespData.code}, count=${allSessions.length}`);
|
|
562
|
+
logTrace(tid, "DONE", { outcome: "sessions", code: sessionsRespData.code, count: allSessions.length });
|
|
535
563
|
return;
|
|
536
564
|
}
|
|
537
565
|
|
|
538
566
|
if (text === "/forget") {
|
|
567
|
+
logTrace(tid, "BRANCH", { cmd: "/forget" });
|
|
539
568
|
const adapter = getAdapterForTool(descriptionTool);
|
|
540
569
|
let cwd: string;
|
|
541
570
|
try {
|
|
@@ -558,6 +587,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
558
587
|
const init = await initClaudeSession(descriptionTool, cwd);
|
|
559
588
|
newSessionId = init.sessionId;
|
|
560
589
|
} catch (err) {
|
|
590
|
+
logTrace(tid, "DONE", { outcome: "forget_session_fail", error: (err as Error).message });
|
|
561
591
|
await sendCardReply(freshToken, chatId, "Error", `Failed to create new session:\n${(err as Error).message}`, "red");
|
|
562
592
|
return;
|
|
563
593
|
}
|
|
@@ -587,6 +617,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
587
617
|
);
|
|
588
618
|
|
|
589
619
|
console.log(`[${ts()}] [FORGET] Session ${sessionId} → ${newSessionId} (same cwd=${cwd})`);
|
|
620
|
+
logTrace(tid, "DONE", { outcome: "forget", newSessionId, cwd });
|
|
590
621
|
return;
|
|
591
622
|
}
|
|
592
623
|
|
|
@@ -595,7 +626,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
595
626
|
// getDefaultCwd(下一次 /new 才会使用的默认路径)。
|
|
596
627
|
if (text.startsWith("/git ") || text === "/git") {
|
|
597
628
|
const args = text === "/git" ? "" : text.slice(5).trim();
|
|
629
|
+
logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
|
|
598
630
|
if (!args) {
|
|
631
|
+
logTrace(tid, "DONE", { outcome: "git_no_args" });
|
|
599
632
|
await sendCardReply(
|
|
600
633
|
freshToken, chatId, "/git",
|
|
601
634
|
"用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。",
|
|
@@ -613,6 +646,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
613
646
|
console.error(`[${ts()}] [GIT] getSessionInfo FAIL: ${(err as Error).message}`);
|
|
614
647
|
}
|
|
615
648
|
if (!cwd) {
|
|
649
|
+
logTrace(tid, "DONE", { outcome: "git_no_cwd", tool: descriptionTool });
|
|
616
650
|
// Cursor 会话的 cwd 依赖 state/cursor-session-meta.json 持久化映射;
|
|
617
651
|
// 升级前创建的旧会话或映射文件丢失时,向会话发送一次普通消息即可触发
|
|
618
652
|
// adapter 自动学习并补全(resume 流首条 init 事件携带 cwd)。
|
|
@@ -630,12 +664,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
630
664
|
const content = formatGitResult(args, cwd, result);
|
|
631
665
|
const template = gitResultHeaderTemplate(result);
|
|
632
666
|
await sendCardReply(freshToken, chatId, "/git 输出", content, template);
|
|
667
|
+
logTrace(tid, "DONE", { outcome: "git_result", exitCode: result.exitCode, durationMs: result.durationMs });
|
|
633
668
|
return;
|
|
634
669
|
}
|
|
635
670
|
|
|
636
671
|
const existing = chatSessionMap.get(chatId);
|
|
637
672
|
if (existing && !existing.stopped) {
|
|
638
673
|
if (msgTimestamp <= existing.msgTimestamp) {
|
|
674
|
+
logTrace(tid, "DONE", { outcome: "skip_old_message", msgTimestamp, existingTimestamp: existing.msgTimestamp });
|
|
639
675
|
console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
|
|
640
676
|
return;
|
|
641
677
|
}
|
|
@@ -644,6 +680,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
644
680
|
existing.close();
|
|
645
681
|
chatSessionMap.delete(chatId);
|
|
646
682
|
console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
|
|
683
|
+
logTrace(tid, "INTERRUPT", { oldSessionId: sessionId });
|
|
647
684
|
if (existing.cardId) {
|
|
648
685
|
while (existing.cardBusy) {
|
|
649
686
|
await new Promise(r => setTimeout(r, 20));
|
|
@@ -662,9 +699,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
662
699
|
}
|
|
663
700
|
|
|
664
701
|
try {
|
|
665
|
-
|
|
702
|
+
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
703
|
+
await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool, tid);
|
|
704
|
+
logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
|
|
666
705
|
console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
|
|
667
706
|
} catch (err) {
|
|
707
|
+
logTrace(tid, "DONE", { outcome: "resume_fail", error: (err as Error).message });
|
|
668
708
|
console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
|
|
669
709
|
fileLog.flush();
|
|
670
710
|
await sendCardReply(
|
|
@@ -675,13 +715,19 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
675
715
|
}
|
|
676
716
|
return;
|
|
677
717
|
}
|
|
718
|
+
// 群聊但 description 中没有 session info → 也走 help card
|
|
719
|
+
logTrace(tid, "BRANCH", { reason: "no_session_info_in_group" });
|
|
678
720
|
} catch (err) {
|
|
721
|
+
logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
|
|
679
722
|
console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
|
|
680
723
|
}
|
|
724
|
+
} // end if (chatType !== "p2p")
|
|
681
725
|
|
|
726
|
+
// 私聊或群聊无 session info → 发送 help card
|
|
727
|
+
logTrace(tid, "SEND", { method: "help_card", chatId });
|
|
682
728
|
const replyToken = await getTenantAccessToken();
|
|
683
729
|
const card = buildHelpCard(text);
|
|
684
|
-
await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
730
|
+
const helpResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
685
731
|
method: "POST",
|
|
686
732
|
headers: {
|
|
687
733
|
Authorization: `Bearer ${replyToken}`,
|
|
@@ -689,6 +735,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
689
735
|
},
|
|
690
736
|
body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
|
|
691
737
|
});
|
|
738
|
+
const helpData = (await helpResp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
739
|
+
if (helpData.code !== 0) {
|
|
740
|
+
console.error(`[${ts()}] [SEND] help_card FAIL: chatId=${chatId} code=${helpData.code} msg="${helpData.msg ?? ""}"`);
|
|
741
|
+
logTrace(tid, "DONE", { outcome: "help_card_fail", code: helpData.code, msg: helpData.msg });
|
|
742
|
+
} else {
|
|
743
|
+
console.log(`[${ts()}] [SEND] help_card OK: chatId=${chatId} msgId=${helpData.data?.message_id ?? "N/A"}`);
|
|
744
|
+
logTrace(tid, "DONE", { outcome: "help_card_sent", msgId: helpData.data?.message_id });
|
|
745
|
+
}
|
|
692
746
|
}
|
|
693
747
|
|
|
694
748
|
// ---------------------------------------------------------------------------
|
|
@@ -779,6 +833,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
779
833
|
const eventDispatcher = new EventDispatcher({});
|
|
780
834
|
eventDispatcher.register({
|
|
781
835
|
"im.message.receive_v1": async (data: Evt) => {
|
|
836
|
+
const traceId = makeTraceId();
|
|
782
837
|
try {
|
|
783
838
|
broadcastToRelay(data);
|
|
784
839
|
|
|
@@ -803,8 +858,9 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
803
858
|
const sender = event.sender;
|
|
804
859
|
const openId = sender?.sender_id?.open_id ?? "";
|
|
805
860
|
const chatId = message.chat_id ?? "";
|
|
861
|
+
const chatType = message.chat_type ?? "group";
|
|
806
862
|
|
|
807
|
-
console.log(`[MSG] sender=${openId} chat=${chatId} text="${text}"`);
|
|
863
|
+
console.log(`[MSG] sender=${openId} chat=${chatId} type=${chatType} text="${text}"`);
|
|
808
864
|
appendChatLog(chatId, openId, text);
|
|
809
865
|
|
|
810
866
|
if (messageId) {
|
|
@@ -819,13 +875,15 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
819
875
|
|
|
820
876
|
if (!text) return;
|
|
821
877
|
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
878
|
+
logTrace(traceId, "RECV", { chatId, chatType, text: text.slice(0, 100) });
|
|
822
879
|
const delayNotice = formatDelayNotice(msgTimestamp);
|
|
823
880
|
if (delayNotice) {
|
|
824
881
|
const delayToken = await getTenantAccessToken();
|
|
825
882
|
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
826
883
|
}
|
|
827
|
-
await handleCommand(text, chatId, openId, msgTimestamp);
|
|
884
|
+
await handleCommand(text, chatId, openId, msgTimestamp, chatType, traceId);
|
|
828
885
|
} catch (err) {
|
|
886
|
+
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
829
887
|
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
|
830
888
|
}
|
|
831
889
|
},
|
|
@@ -990,6 +1048,7 @@ async function main(): Promise<void> {
|
|
|
990
1048
|
appIdMask: maskAppId(APP_ID),
|
|
991
1049
|
});
|
|
992
1050
|
});
|
|
1051
|
+
setExtraApiHandler(handleAgentImageRequest);
|
|
993
1052
|
|
|
994
1053
|
console.log(`[启动 2/7] 环境与凭证检查`);
|
|
995
1054
|
reportEnvironmentVariableReadout();
|
package/src/session.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CLAUDE_BASE_URL,
|
|
7
7
|
CLAUDE_EFFORT,
|
|
8
8
|
CLAUDE_MODEL,
|
|
9
|
+
CHATCCC_PORT,
|
|
9
10
|
SESSIONS_FILE,
|
|
10
11
|
addRecentDir,
|
|
11
12
|
anthropicConfigDisplay,
|
|
@@ -23,11 +24,17 @@ import {
|
|
|
23
24
|
updateCardKitCard,
|
|
24
25
|
} from "./cardkit.ts";
|
|
25
26
|
import { sendTextReply, setChatAvatar } from "./feishu-api.ts";
|
|
27
|
+
import { logTrace } from "./trace.ts";
|
|
26
28
|
import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
|
|
27
29
|
import type { ToolAdapter } from "./adapters/adapter-interface.ts";
|
|
28
30
|
import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
29
31
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
30
32
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
33
|
+
import {
|
|
34
|
+
buildAgentImageCapabilityPrompt,
|
|
35
|
+
createAgentImageGrant,
|
|
36
|
+
revokeAgentImageGrant,
|
|
37
|
+
} from "./agent-image-rpc.ts";
|
|
31
38
|
|
|
32
39
|
// ---------------------------------------------------------------------------
|
|
33
40
|
// Shared state (imported by index.ts)
|
|
@@ -251,7 +258,7 @@ export function accumulateBlockContent(
|
|
|
251
258
|
}
|
|
252
259
|
|
|
253
260
|
// ---------------------------------------------------------------------------
|
|
254
|
-
// AI tool session management
|
|
261
|
+
// AI tool session management
|
|
255
262
|
// ---------------------------------------------------------------------------
|
|
256
263
|
|
|
257
264
|
/**
|
|
@@ -300,13 +307,34 @@ export async function resumeAndPrompt(
|
|
|
300
307
|
chatId: string,
|
|
301
308
|
msgTimestamp: number,
|
|
302
309
|
tool: string,
|
|
310
|
+
traceId?: string,
|
|
303
311
|
): Promise<void> {
|
|
312
|
+
const tid = traceId ?? "";
|
|
304
313
|
const adapter = getAdapterForTool(tool);
|
|
305
314
|
const info = await adapter.getSessionInfo(sessionId);
|
|
306
315
|
const cwd = info?.cwd ?? (await getDefaultCwd());
|
|
316
|
+
if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(chatId)?.turnCount ?? 0) + 1 });
|
|
307
317
|
console.log(
|
|
308
318
|
`[${ts()}] Resuming ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
|
|
309
319
|
);
|
|
320
|
+
const imageGrant = createAgentImageGrant({
|
|
321
|
+
chatId,
|
|
322
|
+
sessionId,
|
|
323
|
+
cwd,
|
|
324
|
+
port: CHATCCC_PORT,
|
|
325
|
+
traceId: tid || undefined,
|
|
326
|
+
});
|
|
327
|
+
const userTextWithCapabilities = [
|
|
328
|
+
buildAgentImageCapabilityPrompt({
|
|
329
|
+
url: imageGrant.url,
|
|
330
|
+
token: imageGrant.token,
|
|
331
|
+
cwd,
|
|
332
|
+
}),
|
|
333
|
+
"",
|
|
334
|
+
"[User message]",
|
|
335
|
+
userText,
|
|
336
|
+
"[/User message]",
|
|
337
|
+
].join("\n");
|
|
310
338
|
|
|
311
339
|
const controller = new AbortController();
|
|
312
340
|
|
|
@@ -338,6 +366,7 @@ export async function resumeAndPrompt(
|
|
|
338
366
|
|
|
339
367
|
let cardId: string | null = null;
|
|
340
368
|
cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
|
|
369
|
+
if (tid) logTrace(tid, "CARD_CREATE_FAIL", { error: (err as Error).message });
|
|
341
370
|
console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
342
371
|
fileLog.flush();
|
|
343
372
|
sendTextReply(token, chatId, "⚠️ 流式卡片创建失败(可能因限流),将使用文本回复。").catch(() => {});
|
|
@@ -347,6 +376,7 @@ export async function resumeAndPrompt(
|
|
|
347
376
|
const cEntry = chatSessionMap.get(chatId);
|
|
348
377
|
if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
|
|
349
378
|
const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
|
|
379
|
+
if (tid) logTrace(tid, "CARD_SEND_FAIL", { cardId, error: (err as Error).message });
|
|
350
380
|
console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
|
|
351
381
|
fileLog.flush();
|
|
352
382
|
return false;
|
|
@@ -445,7 +475,7 @@ export async function resumeAndPrompt(
|
|
|
445
475
|
}
|
|
446
476
|
|
|
447
477
|
try {
|
|
448
|
-
for await (const unifiedMsg of adapter.prompt(sessionId,
|
|
478
|
+
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
|
|
449
479
|
for (const block of unifiedMsg.blocks) {
|
|
450
480
|
accumulateBlockContent(block, state);
|
|
451
481
|
|
|
@@ -460,6 +490,7 @@ export async function resumeAndPrompt(
|
|
|
460
490
|
console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
|
|
461
491
|
} finally {
|
|
462
492
|
if (sendInterval) clearInterval(sendInterval);
|
|
493
|
+
revokeAgentImageGrant(imageGrant.token);
|
|
463
494
|
}
|
|
464
495
|
|
|
465
496
|
const cEntry = chatSessionMap.get(chatId);
|
|
@@ -501,6 +532,7 @@ export async function resumeAndPrompt(
|
|
|
501
532
|
);
|
|
502
533
|
}
|
|
503
534
|
console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${state.chunkCount})`);
|
|
535
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
504
536
|
return;
|
|
505
537
|
}
|
|
506
538
|
|
|
@@ -530,6 +562,7 @@ export async function resumeAndPrompt(
|
|
|
530
562
|
}
|
|
531
563
|
|
|
532
564
|
console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
|
|
565
|
+
if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
|
|
533
566
|
}
|
|
534
567
|
|
|
535
568
|
// ---------------------------------------------------------------------------
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量级消息全链路 trace 工具。
|
|
3
|
+
*
|
|
4
|
+
* 每条消息生成唯一 traceId,在关键决策点、API 调用、消息发送时输出结构化日志。
|
|
5
|
+
* grep traceId 即可看到该消息的完整 RECV → BRANCH → SEND → DONE 链路。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则:trace 自身绝不能抛异常导致主流程中断——所有函数内都有 try/catch 兜底。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let _traceSeq = 0;
|
|
11
|
+
|
|
12
|
+
/** 基于时间戳 + 递增序号生成短 trace ID(约 10 字符) */
|
|
13
|
+
export function makeTraceId(): string {
|
|
14
|
+
try {
|
|
15
|
+
_traceSeq++;
|
|
16
|
+
const ts = Date.now().toString(36).slice(-6);
|
|
17
|
+
const seq = _traceSeq.toString(36).padStart(3, "0");
|
|
18
|
+
return `${ts}${seq}`;
|
|
19
|
+
} catch {
|
|
20
|
+
// 极端情况(计数器溢出等)降级为纯时间戳
|
|
21
|
+
return Date.now().toString(36);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 输出一条 trace 日志。
|
|
27
|
+
* detail 中的值做浅层截断(字符串 > 200 字符会截断),避免日志膨胀。
|
|
28
|
+
*/
|
|
29
|
+
export function logTrace(traceId: string, step: string, detail?: Record<string, unknown>): void {
|
|
30
|
+
try {
|
|
31
|
+
const safe: Record<string, unknown> = {};
|
|
32
|
+
if (detail) {
|
|
33
|
+
for (const [k, v] of Object.entries(detail)) {
|
|
34
|
+
if (typeof v === "string" && v.length > 200) {
|
|
35
|
+
safe[k] = v.slice(0, 200) + "...(truncated)";
|
|
36
|
+
} else {
|
|
37
|
+
safe[k] = v;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const extra = Object.keys(safe).length > 0 ? " " + JSON.stringify(safe) : "";
|
|
42
|
+
console.log(`[TRACE ${traceId}] ${step}${extra}`);
|
|
43
|
+
} catch {
|
|
44
|
+
// trace 自身绝不能抛异常
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 重置序号(仅单测使用,确保 trace ID 可预测) */
|
|
49
|
+
export function _resetTraceSeqForTest(): void {
|
|
50
|
+
_traceSeq = 0;
|
|
51
|
+
}
|
package/src/web-ui.ts
CHANGED
|
@@ -1570,6 +1570,8 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
|
|
1570
1570
|
const url = req.url ?? "/";
|
|
1571
1571
|
const method = req.method ?? "GET";
|
|
1572
1572
|
|
|
1573
|
+
if (extraApiHandler && await extraApiHandler(req, res)) return;
|
|
1574
|
+
|
|
1573
1575
|
// API routes
|
|
1574
1576
|
if (url === "/api/check" && method === "GET") return handleApiCheck(req, res);
|
|
1575
1577
|
if (url === "/api/config" && method === "GET") return handleGetConfig(req, res);
|
|
@@ -1662,6 +1664,8 @@ let setupActivateHook: SetupActivateHook | null = null;
|
|
|
1662
1664
|
// 由 index.ts 注入,因为 web-ui.ts 自身**不应**直接 import config.ts——后者顶层
|
|
1663
1665
|
// 有 loadConfig 副作用,被 web-ui.ts 间接 import 会污染所有依赖 web-ui.ts 的单测。
|
|
1664
1666
|
let reloadConfigHook: (() => void) | null = null;
|
|
1667
|
+
type ExtraApiHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean;
|
|
1668
|
+
let extraApiHandler: ExtraApiHandler | null = null;
|
|
1665
1669
|
|
|
1666
1670
|
/**
|
|
1667
1671
|
* 注册"reload config"回调。约定:
|
|
@@ -1673,6 +1677,10 @@ export function setReloadConfigHook(hook: () => void | Promise<void>): void {
|
|
|
1673
1677
|
reloadConfigHook = hook;
|
|
1674
1678
|
}
|
|
1675
1679
|
|
|
1680
|
+
export function setExtraApiHandler(handler: ExtraApiHandler): void {
|
|
1681
|
+
extraApiHandler = handler;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1676
1684
|
export function startSetupMode(port: number, options: StartSetupModeOptions = {}): void {
|
|
1677
1685
|
const router = createUiRouter();
|
|
1678
1686
|
const server = createServer(router);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|