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/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 loadAvatarSource(source: string): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
273
- const input = await readFile(source);
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(input)
276
- .resize(256, 256, { fit: "cover", position: "center" })
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: "avatar.jpg",
345
+ filename: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
286
346
  };
287
347
  }
288
348
 
289
- async function uploadImage(token: string, source: string): Promise<string> {
290
- const image = await loadAvatarSource(source);
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
- const cached = avatarKeyCache.get(status);
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, AVATAR_SOURCES[status]);
315
- avatarKeyCache.set(status, key);
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
- toolDisplayName,
60
- ts,
61
- } from "./config.ts";
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 + Claude session.`);
725
- console.log(` In a Claude session group, send any message to resume & prompt.`);
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
- // Claude session management
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) {