chatccc 0.2.44 → 0.2.46
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 +1 -1
- package/im-skills/feishu-skill/receive-send-file.md +3 -11
- package/im-skills/feishu-skill/receive-send-image.md +3 -11
- package/im-skills/feishu-skill/send-file.mjs +5 -6
- package/im-skills/feishu-skill/send-image.mjs +5 -6
- package/im-skills/feishu-skill/skill.md +2 -3
- package/package.json +1 -1
- package/src/__tests__/agent-image-rpc.test.ts +17 -52
- package/src/__tests__/session.test.ts +31 -1
- package/src/agent-file-rpc.ts +108 -106
- package/src/agent-image-rpc.ts +78 -113
- package/src/cards.ts +2 -2
- package/src/index.ts +165 -116
- package/src/session-chat-binding.ts +87 -0
- package/src/session.ts +328 -263
- package/src/stream-state.ts +94 -0
- package/src/agent-grants-rpc.ts +0 -71
package/src/agent-image-rpc.ts
CHANGED
|
@@ -1,123 +1,30 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
2
|
import { extname, isAbsolute, resolve } from "node:path";
|
|
4
3
|
import { stat } from "node:fs/promises";
|
|
5
4
|
|
|
6
5
|
import { getTenantAccessToken, sendImageReply, sendTextReply } from "./feishu-platform.ts";
|
|
7
6
|
import { ts } from "./config.ts";
|
|
8
|
-
import { logTrace } from "./trace.ts";
|
|
9
7
|
import { readUtf8JsonBody } from "./agent-rpc-body.ts";
|
|
8
|
+
import { getAdapterForTool } from "./session.ts";
|
|
9
|
+
import { getChatsForSession } from "./session-chat-binding.ts";
|
|
10
10
|
|
|
11
11
|
export const AGENT_SEND_IMAGE_PATH = "/api/agent/send-image";
|
|
12
12
|
|
|
13
|
-
const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
|
|
14
13
|
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
15
14
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
16
15
|
const ALLOWED_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
17
16
|
|
|
18
|
-
export interface AgentImageGrant {
|
|
19
|
-
token: string;
|
|
20
|
-
url: string;
|
|
21
|
-
chatId: string;
|
|
22
|
-
sessionId: string;
|
|
23
|
-
cwd: string;
|
|
24
|
-
expiresAt: number;
|
|
25
|
-
traceId: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface CreateAgentImageGrantInput {
|
|
29
|
-
chatId: string;
|
|
30
|
-
sessionId: string;
|
|
31
|
-
cwd: string;
|
|
32
|
-
port: number;
|
|
33
|
-
traceId?: string;
|
|
34
|
-
nowMs?: number;
|
|
35
|
-
ttlMs?: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const imageGrants = new Map<string, AgentImageGrant>();
|
|
39
|
-
|
|
40
|
-
function createToken(): string {
|
|
41
|
-
return randomBytes(24).toString("base64url");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function createAgentImageGrant(input: CreateAgentImageGrantInput): AgentImageGrant {
|
|
45
|
-
const now = input.nowMs ?? Date.now();
|
|
46
|
-
const token = createToken();
|
|
47
|
-
const grant: AgentImageGrant = {
|
|
48
|
-
token,
|
|
49
|
-
url: `http://127.0.0.1:${input.port}${AGENT_SEND_IMAGE_PATH}`,
|
|
50
|
-
chatId: input.chatId,
|
|
51
|
-
sessionId: input.sessionId,
|
|
52
|
-
cwd: input.cwd,
|
|
53
|
-
expiresAt: now + (input.ttlMs ?? DEFAULT_GRANT_TTL_MS),
|
|
54
|
-
traceId: input.traceId ?? "",
|
|
55
|
-
};
|
|
56
|
-
imageGrants.set(token, grant);
|
|
57
|
-
if (grant.traceId) logTrace(grant.traceId, "IMAGE_GRANT_CREATED", { sessionId: grant.sessionId, ttlMs: input.ttlMs ?? DEFAULT_GRANT_TTL_MS });
|
|
58
|
-
return grant;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function revokeAgentImageGrant(token: string): void {
|
|
62
|
-
const grant = imageGrants.get(token);
|
|
63
|
-
if (grant?.traceId) logTrace(grant.traceId, "IMAGE_GRANT_REVOKED", {});
|
|
64
|
-
imageGrants.delete(token);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function getAgentImageGrantFromAuthorization(
|
|
68
|
-
authorization: string | undefined,
|
|
69
|
-
nowMs = Date.now(),
|
|
70
|
-
): AgentImageGrant | null {
|
|
71
|
-
const match = (authorization ?? "").match(/^Bearer\s+(.+)$/i);
|
|
72
|
-
if (!match) return null;
|
|
73
|
-
const grant = imageGrants.get(match[1]);
|
|
74
|
-
if (!grant) return null;
|
|
75
|
-
if (grant.expiresAt <= nowMs) {
|
|
76
|
-
imageGrants.delete(match[1]);
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
return grant;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function buildAgentImageCapabilityPrompt(input: {
|
|
83
|
-
url: string;
|
|
84
|
-
token: string;
|
|
85
|
-
cwd?: string;
|
|
86
|
-
}): string {
|
|
87
|
-
const lines = [
|
|
88
|
-
"[ChatCCC local capability: send image]",
|
|
89
|
-
"You can send an image to the current Feishu chat in real time by calling this local endpoint.",
|
|
90
|
-
"",
|
|
91
|
-
`POST ${input.url}`,
|
|
92
|
-
`Authorization: Bearer ${input.token}`,
|
|
93
|
-
"Content-Type: application/json; charset=utf-8",
|
|
94
|
-
"",
|
|
95
|
-
'Body: {"path":"absolute image file path","caption":"optional caption"}',
|
|
96
|
-
"",
|
|
97
|
-
"Rules:",
|
|
98
|
-
"- Save or choose a local image file first, then call the endpoint.",
|
|
99
|
-
"- Use an absolute local file path. Do not call Feishu Open Platform directly.",
|
|
100
|
-
"- Request body must be UTF-8 encoded JSON bytes; caption supports Unicode text, including Chinese.",
|
|
101
|
-
"- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
|
|
102
|
-
"[/ChatCCC local capability: send image]",
|
|
103
|
-
];
|
|
104
|
-
if (input.cwd) {
|
|
105
|
-
lines.splice(2, 0, `Current working directory: ${input.cwd}`);
|
|
106
|
-
}
|
|
107
|
-
return lines.join("\n");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
17
|
function jsonReply(res: ServerResponse, status: number, data: unknown): void {
|
|
111
18
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
112
19
|
res.end(JSON.stringify(data));
|
|
113
20
|
}
|
|
114
21
|
|
|
115
|
-
async function resolveAndValidateImagePath(
|
|
22
|
+
async function resolveAndValidateImagePath(cwd: string, rawPath: unknown): Promise<string> {
|
|
116
23
|
if (typeof rawPath !== "string" || rawPath.trim() === "") {
|
|
117
24
|
throw new Error("path must be a non-empty string");
|
|
118
25
|
}
|
|
119
26
|
|
|
120
|
-
const sessionRoot = resolve(
|
|
27
|
+
const sessionRoot = resolve(cwd);
|
|
121
28
|
const imagePath = isAbsolute(rawPath)
|
|
122
29
|
? resolve(rawPath)
|
|
123
30
|
: resolve(sessionRoot, rawPath);
|
|
@@ -146,43 +53,101 @@ export async function handleAgentImageRequest(
|
|
|
146
53
|
return true;
|
|
147
54
|
}
|
|
148
55
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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" });
|
|
152
67
|
return true;
|
|
153
68
|
}
|
|
154
|
-
const tid = grant.traceId;
|
|
155
69
|
|
|
156
|
-
|
|
70
|
+
// 获取 cwd 以校验路径
|
|
71
|
+
let cwd: string;
|
|
157
72
|
try {
|
|
158
|
-
|
|
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;
|
|
159
82
|
} catch (err) {
|
|
160
|
-
|
|
161
|
-
jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
|
|
83
|
+
jsonReply(res, 500, { ok: false, error: `Failed to get session info: ${(err as Error).message}` });
|
|
162
84
|
return true;
|
|
163
85
|
}
|
|
164
86
|
|
|
165
87
|
let imagePath: string;
|
|
166
88
|
try {
|
|
167
|
-
imagePath = await resolveAndValidateImagePath(
|
|
89
|
+
imagePath = await resolveAndValidateImagePath(cwd, payload.path);
|
|
168
90
|
} catch (err) {
|
|
169
|
-
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_path", path: String(payload.path), error: (err as Error).message });
|
|
170
91
|
jsonReply(res, 400, { ok: false, error: (err as Error).message });
|
|
171
92
|
return true;
|
|
172
93
|
}
|
|
173
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
|
+
|
|
174
102
|
try {
|
|
175
103
|
const token = await getTenantAccessToken();
|
|
176
104
|
const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 });
|
|
182
117
|
} catch (err) {
|
|
183
|
-
if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "send_failed", path: imagePath!, error: (err as Error).message });
|
|
184
118
|
console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
|
|
185
119
|
jsonReply(res, 500, { ok: false, error: (err as Error).message });
|
|
186
120
|
}
|
|
187
121
|
return true;
|
|
188
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
|
+
}
|
package/src/cards.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function buildHelpCard(
|
|
|
119
119
|
"发送 **/new claude** 创建新 Claude 对话",
|
|
120
120
|
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
121
121
|
"发送 **/new codex** 创建新 Codex 会话",
|
|
122
|
-
"发送 **/
|
|
122
|
+
"发送 **/newh** 重置当前会话(保留工作目录,同一群内继续)",
|
|
123
123
|
].join("\n");
|
|
124
124
|
return JSON.stringify({
|
|
125
125
|
config: { wide_screen_mode: true },
|
|
@@ -328,7 +328,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
328
328
|
elements: [
|
|
329
329
|
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
330
330
|
{ tag: "hr" },
|
|
331
|
-
{ tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/
|
|
331
|
+
{ tag: "div", text: { tag: "lark_md", content: "在会话群内发送 **/newh** 可重置当前会话(创建新 Session,保留工作目录和群聊)。\n发送 **/session 数字**(如 `/session 1`)可将当前群聊切换到列表中对应编号的会话。" } },
|
|
332
332
|
{ tag: "hr" },
|
|
333
333
|
{
|
|
334
334
|
tag: "action",
|