chatccc 0.2.18 → 0.2.20
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 +21 -17
- package/bin/chatccc.mjs +23 -23
- package/config.sample.json +29 -26
- package/images/avatars/badges/badge_claude.png +0 -0
- package/images/avatars/badges/badge_codex.png +0 -0
- package/images/avatars/badges/badge_cursor.png +0 -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/package.json +1 -1
- package/src/__tests__/config-reload.test.ts +41 -19
- package/src/adapters/codex-adapter.ts +294 -294
- package/src/cards.ts +23 -18
- package/src/config.ts +651 -616
- package/src/feishu-api.ts +115 -16
- package/src/index.ts +18 -11
- package/src/session.ts +3 -3
- package/src/web-ui.ts +1708 -1591
package/src/feishu-api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readdir, stat, readFile } from "node:fs/promises";
|
|
1
|
+
import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { resolve as resolvePath } from "node:path";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import sharp from "sharp";
|
|
5
5
|
|
|
6
6
|
import {
|
|
@@ -261,19 +261,79 @@ export function extractSessionId(description: string): string | null {
|
|
|
261
261
|
// ---------------------------------------------------------------------------
|
|
262
262
|
|
|
263
263
|
const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
|
|
264
|
+
const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
|
|
265
|
+
const AVATAR_KEY_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "avatar-image-keys.json");
|
|
264
266
|
const AVATAR_SOURCES: Record<string, string> = {
|
|
265
267
|
new: resolvePath(AVATAR_DIR, "status_new.png"),
|
|
266
268
|
busy: resolvePath(AVATAR_DIR, "status_busy.png"),
|
|
267
269
|
idle: resolvePath(AVATAR_DIR, "status_idle.png"),
|
|
268
270
|
};
|
|
271
|
+
const AVATAR_BADGES: Record<string, string> = {
|
|
272
|
+
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
273
|
+
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
274
|
+
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
275
|
+
};
|
|
276
|
+
const AVATAR_SIZE = 256;
|
|
277
|
+
const BADGE_SIZE = 92;
|
|
278
|
+
const BADGE_MARGIN = 10;
|
|
269
279
|
|
|
270
280
|
const avatarKeyCache = new Map<string, string>();
|
|
281
|
+
let avatarKeyCacheLoaded = false;
|
|
282
|
+
|
|
283
|
+
function normalizeAvatarTool(tool: string): string {
|
|
284
|
+
return AVATAR_BADGES[tool] ? tool : "claude";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeAvatarStatus(status: string): string {
|
|
288
|
+
return AVATAR_SOURCES[status] ? status : "idle";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function avatarCacheKey(tool: string, status: string): string {
|
|
292
|
+
return `${normalizeAvatarTool(tool)}:${normalizeAvatarStatus(status)}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function loadAvatarKeyCache(): Promise<void> {
|
|
296
|
+
if (avatarKeyCacheLoaded) return;
|
|
297
|
+
avatarKeyCacheLoaded = true;
|
|
298
|
+
try {
|
|
299
|
+
const raw = await readFile(AVATAR_KEY_CACHE_FILE, "utf-8");
|
|
300
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
301
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
302
|
+
if (typeof value === "string" && value.trim()) avatarKeyCache.set(key, value);
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// Missing or malformed cache should not block avatar updates.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function persistAvatarKeyCache(): Promise<void> {
|
|
310
|
+
await mkdir(dirname(AVATAR_KEY_CACHE_FILE), { recursive: true });
|
|
311
|
+
await writeFile(
|
|
312
|
+
AVATAR_KEY_CACHE_FILE,
|
|
313
|
+
JSON.stringify(Object.fromEntries(avatarKeyCache.entries()), null, 2),
|
|
314
|
+
"utf-8",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
271
317
|
|
|
272
|
-
async function
|
|
273
|
-
const
|
|
318
|
+
async function renderAvatar(tool: string, status: string): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
319
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
320
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
321
|
+
const badge = await sharp(await readFile(AVATAR_BADGES[normalizedTool]))
|
|
322
|
+
.resize(BADGE_SIZE, BADGE_SIZE, {
|
|
323
|
+
fit: "contain",
|
|
324
|
+
kernel: sharp.kernel.lanczos3,
|
|
325
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
326
|
+
})
|
|
327
|
+
.png()
|
|
328
|
+
.toBuffer();
|
|
274
329
|
|
|
275
|
-
const jpeg = await sharp(
|
|
276
|
-
.resize(
|
|
330
|
+
const jpeg = await sharp(await readFile(AVATAR_SOURCES[normalizedStatus]))
|
|
331
|
+
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" })
|
|
332
|
+
.composite([{
|
|
333
|
+
input: badge,
|
|
334
|
+
left: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
|
|
335
|
+
top: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
|
|
336
|
+
}])
|
|
277
337
|
.flatten({ background: "#ffffff" })
|
|
278
338
|
.removeAlpha()
|
|
279
339
|
.jpeg({ quality: 95, progressive: false })
|
|
@@ -282,12 +342,12 @@ async function loadAvatarSource(source: string): Promise<{ buffer: Buffer; conte
|
|
|
282
342
|
return {
|
|
283
343
|
buffer: jpeg,
|
|
284
344
|
contentType: "image/jpeg",
|
|
285
|
-
filename:
|
|
345
|
+
filename: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
286
346
|
};
|
|
287
347
|
}
|
|
288
348
|
|
|
289
|
-
async function uploadImage(token: string,
|
|
290
|
-
const image = await
|
|
349
|
+
async function uploadImage(token: string, tool: string, status: string): Promise<string> {
|
|
350
|
+
const image = await renderAvatar(tool, status);
|
|
291
351
|
const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
|
|
292
352
|
const form = new FormData();
|
|
293
353
|
form.append("image_type", "avatar");
|
|
@@ -308,18 +368,23 @@ async function uploadImage(token: string, source: string): Promise<string> {
|
|
|
308
368
|
return data.data!.image_key!;
|
|
309
369
|
}
|
|
310
370
|
|
|
311
|
-
async function getOrUploadAvatarKey(token: string, status: string): Promise<string> {
|
|
312
|
-
|
|
371
|
+
async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
|
|
372
|
+
await loadAvatarKeyCache();
|
|
373
|
+
const keyName = avatarCacheKey(tool, status);
|
|
374
|
+
const cached = avatarKeyCache.get(keyName);
|
|
313
375
|
if (cached) return cached;
|
|
314
|
-
const key = await uploadImage(token,
|
|
315
|
-
avatarKeyCache.set(
|
|
376
|
+
const key = await uploadImage(token, tool, status);
|
|
377
|
+
avatarKeyCache.set(keyName, key);
|
|
378
|
+
await persistAvatarKeyCache().catch((err) => {
|
|
379
|
+
console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
|
|
380
|
+
});
|
|
316
381
|
console.log(`[${ts()}] [AVATAR] Uploaded "${status}" → image_key=${key}`);
|
|
317
382
|
return key;
|
|
318
383
|
}
|
|
319
384
|
|
|
320
|
-
export async function setChatAvatar(token: string, chatId: string, status: string): Promise<void> {
|
|
385
|
+
export async function setChatAvatar(token: string, chatId: string, tool: string, status: string): Promise<void> {
|
|
321
386
|
try {
|
|
322
|
-
const avatarKey = await getOrUploadAvatarKey(token, status);
|
|
387
|
+
const avatarKey = await getOrUploadAvatarKey(token, tool, status);
|
|
323
388
|
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
324
389
|
method: "PUT",
|
|
325
390
|
headers: {
|
|
@@ -336,7 +401,7 @@ export async function setChatAvatar(token: string, chatId: string, status: strin
|
|
|
336
401
|
}
|
|
337
402
|
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
338
403
|
} catch (err) {
|
|
339
|
-
console.error(`[${ts()}] [AVATAR] setChatAvatar FAIL: chatId=${chatId} status=${status} ${(err as Error).message}`);
|
|
404
|
+
console.error(`[${ts()}] [AVATAR] setChatAvatar FAIL: chatId=${chatId} tool=${tool} status=${status} ${(err as Error).message}`);
|
|
340
405
|
}
|
|
341
406
|
}
|
|
342
407
|
|
|
@@ -344,6 +409,40 @@ export async function setChatAvatar(token: string, chatId: string, status: strin
|
|
|
344
409
|
// Messaging
|
|
345
410
|
// ---------------------------------------------------------------------------
|
|
346
411
|
|
|
412
|
+
const DELAY_NOTICE_THRESHOLD_MS = 15 * 60 * 1000; // 15 分钟
|
|
413
|
+
|
|
414
|
+
/** 消息延迟超过阈值时生成提醒文本,否则返回 null */
|
|
415
|
+
export function formatDelayNotice(createTimeMs: number, nowMs?: number): string | null {
|
|
416
|
+
const now = nowMs ?? Date.now();
|
|
417
|
+
const delayMs = now - createTimeMs;
|
|
418
|
+
if (delayMs < DELAY_NOTICE_THRESHOLD_MS) return null;
|
|
419
|
+
|
|
420
|
+
const sendDate = new Date(createTimeMs);
|
|
421
|
+
const month = sendDate.getMonth() + 1;
|
|
422
|
+
const day = sendDate.getDate();
|
|
423
|
+
const hour = String(sendDate.getHours()).padStart(2, "0");
|
|
424
|
+
const min = String(sendDate.getMinutes()).padStart(2, "0");
|
|
425
|
+
const sendTimeStr = `${month}月${day}日 ${hour}:${min}`;
|
|
426
|
+
|
|
427
|
+
const totalMinutes = Math.floor(delayMs / 60000);
|
|
428
|
+
let delayStr: string;
|
|
429
|
+
if (totalMinutes < 60) {
|
|
430
|
+
delayStr = `${totalMinutes} 分钟`;
|
|
431
|
+
} else {
|
|
432
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
433
|
+
const mins = totalMinutes % 60;
|
|
434
|
+
if (hours < 24) {
|
|
435
|
+
delayStr = mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`;
|
|
436
|
+
} else {
|
|
437
|
+
const days = Math.floor(hours / 24);
|
|
438
|
+
const remainHours = hours % 24;
|
|
439
|
+
delayStr = remainHours > 0 ? `${days} 天 ${remainHours} 小时` : `${days} 天`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达`;
|
|
444
|
+
}
|
|
445
|
+
|
|
347
446
|
export async function sendTextReply(
|
|
348
447
|
token: string,
|
|
349
448
|
chatId: string,
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* =================================================================
|
|
4
4
|
* Supported tools: Claude Code, Cursor, Codex (OpenAI).
|
|
5
5
|
*
|
|
6
|
-
* When a user sends "/new [tool]" to the bot:
|
|
6
|
+
* When a user sends "/new [tool]" to the bot (omitting tool uses the configured default Agent):
|
|
7
7
|
* 1. Create an AI tool session via the corresponding adapter, get session ID
|
|
8
8
|
* 2. Create a new Feishu group chat and add the user
|
|
9
9
|
* 3. Rename the group (name + description) to the session ID
|
|
@@ -54,16 +54,18 @@ import {
|
|
|
54
54
|
maskAppId,
|
|
55
55
|
setDefaultCwd,
|
|
56
56
|
getRecentDirs,
|
|
57
|
-
addRecentDir,
|
|
58
|
-
sessionPrefixForTool,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
addRecentDir,
|
|
58
|
+
sessionPrefixForTool,
|
|
59
|
+
resolveDefaultAgentTool,
|
|
60
|
+
toolDisplayName,
|
|
61
|
+
ts,
|
|
62
|
+
} from "./config.ts";
|
|
62
63
|
import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.ts";
|
|
63
64
|
import {
|
|
64
65
|
addReaction,
|
|
65
66
|
createGroupChat,
|
|
66
67
|
extractSessionInfo,
|
|
68
|
+
formatDelayNotice,
|
|
67
69
|
getChatInfo,
|
|
68
70
|
getTenantAccessToken,
|
|
69
71
|
recallMessage,
|
|
@@ -195,7 +197,7 @@ function parseCardAction(data: unknown): CardActionResult | null {
|
|
|
195
197
|
}
|
|
196
198
|
if (!cmd) return null;
|
|
197
199
|
|
|
198
|
-
const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", forget: "/forget" };
|
|
200
|
+
const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", forget: "/forget" };
|
|
199
201
|
let text = CMD_MAP[cmd] ?? "";
|
|
200
202
|
if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
|
|
201
203
|
const path = (action.value as Record<string, string>).path;
|
|
@@ -405,7 +407,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
405
407
|
);
|
|
406
408
|
|
|
407
409
|
console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
|
|
408
|
-
setChatAvatar(freshToken, newChatId, "new").catch(() => {});
|
|
410
|
+
setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
|
|
409
411
|
console.log(`${"=".repeat(60)}`);
|
|
410
412
|
return;
|
|
411
413
|
}
|
|
@@ -558,7 +560,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
558
560
|
tool: descriptionTool,
|
|
559
561
|
});
|
|
560
562
|
|
|
561
|
-
setChatAvatar(freshToken, chatId, "new").catch(() => {});
|
|
563
|
+
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
562
564
|
|
|
563
565
|
await sendCardReply(
|
|
564
566
|
freshToken, chatId, `${toolLabel} Session Reset`,
|
|
@@ -721,8 +723,8 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
721
723
|
console.log(`${"=".repeat(60)}`);
|
|
722
724
|
console.log(` ChatCCC — Feishu Bot Bridge for Claude Code${modeTag}`);
|
|
723
725
|
console.log(`${"=".repeat(60)}`);
|
|
724
|
-
console.log(` Send "/new" to the bot to create a new group +
|
|
725
|
-
console.log(` In a
|
|
726
|
+
console.log(` Send "/new" to the bot to create a new group + ${toolDisplayName(resolveDefaultAgentTool())} session.`);
|
|
727
|
+
console.log(` In a session group, send any message to resume & prompt.`);
|
|
726
728
|
console.log(`${"=".repeat(60)}`);
|
|
727
729
|
|
|
728
730
|
console.log(`\n[启动 4/7] 向飞书开放平台申请 tenant_access_token …`);
|
|
@@ -802,6 +804,11 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
802
804
|
|
|
803
805
|
if (!text) return;
|
|
804
806
|
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
807
|
+
const delayNotice = formatDelayNotice(msgTimestamp);
|
|
808
|
+
if (delayNotice) {
|
|
809
|
+
const delayToken = await getTenantAccessToken();
|
|
810
|
+
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
811
|
+
}
|
|
805
812
|
await handleCommand(text, chatId, openId, msgTimestamp);
|
|
806
813
|
} catch (err) {
|
|
807
814
|
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
package/src/session.ts
CHANGED
|
@@ -251,7 +251,7 @@ export function accumulateBlockContent(
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
// ---------------------------------------------------------------------------
|
|
254
|
-
//
|
|
254
|
+
// AI tool session management
|
|
255
255
|
// ---------------------------------------------------------------------------
|
|
256
256
|
|
|
257
257
|
/**
|
|
@@ -324,7 +324,7 @@ export async function resumeAndPrompt(
|
|
|
324
324
|
});
|
|
325
325
|
const myGen = sessionGen;
|
|
326
326
|
|
|
327
|
-
setChatAvatar(token, chatId, "busy").catch(() => {});
|
|
327
|
+
setChatAvatar(token, chatId, tool, "busy").catch(() => {});
|
|
328
328
|
|
|
329
329
|
const now = Date.now();
|
|
330
330
|
const existingInfo = sessionInfoMap.get(chatId);
|
|
@@ -466,7 +466,7 @@ export async function resumeAndPrompt(
|
|
|
466
466
|
if (!cEntry || cEntry.gen !== myGen) return;
|
|
467
467
|
const wasStopped = cEntry.stopped;
|
|
468
468
|
chatSessionMap.delete(chatId);
|
|
469
|
-
setChatAvatar(token, chatId, "idle").catch(() => {});
|
|
469
|
+
setChatAvatar(token, chatId, tool, "idle").catch(() => {});
|
|
470
470
|
|
|
471
471
|
const finalCardContent = state.accumulatedContent || " ";
|
|
472
472
|
if (cardId) {
|