chatccc 0.2.20 → 0.2.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/feishu-api.ts CHANGED
@@ -405,6 +405,80 @@ export async function setChatAvatar(token: string, chatId: string, tool: string,
405
405
  }
406
406
  }
407
407
 
408
+ // ---------------------------------------------------------------------------
409
+ // Image download & cache
410
+ // ---------------------------------------------------------------------------
411
+
412
+ const IMAGE_DOWNLOAD_DIR = resolvePath(PROJECT_ROOT, "images", "downloads");
413
+ const IMAGE_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "image-cache.json");
414
+
415
+ const imageCache = new Map<string, string>();
416
+ let imageCacheLoaded = false;
417
+
418
+ async function loadImageCache(): Promise<void> {
419
+ if (imageCacheLoaded) return;
420
+ imageCacheLoaded = true;
421
+ try {
422
+ const raw = await readFile(IMAGE_CACHE_FILE, "utf-8");
423
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
424
+ for (const [key, value] of Object.entries(parsed)) {
425
+ if (typeof value === "string" && value.trim()) imageCache.set(key, value);
426
+ }
427
+ } catch { /* missing or malformed cache is not fatal */ }
428
+ }
429
+
430
+ async function persistImageCache(): Promise<void> {
431
+ await mkdir(dirname(IMAGE_CACHE_FILE), { recursive: true });
432
+ await writeFile(
433
+ IMAGE_CACHE_FILE,
434
+ JSON.stringify(Object.fromEntries(imageCache.entries()), null, 2),
435
+ "utf-8",
436
+ );
437
+ }
438
+
439
+ function extFromContentType(contentType: string | null): string {
440
+ if (!contentType) return ".png";
441
+ const mime = contentType.split(";")[0].trim().toLowerCase();
442
+ const map: Record<string, string> = {
443
+ "image/png": ".png",
444
+ "image/jpeg": ".jpg",
445
+ "image/webp": ".webp",
446
+ "image/gif": ".gif",
447
+ "image/bmp": ".bmp",
448
+ "image/svg+xml": ".svg",
449
+ };
450
+ return map[mime] ?? ".png";
451
+ }
452
+
453
+ async function downloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
454
+ const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}/resources/${fileKey}?type=image`, {
455
+ headers: { Authorization: `Bearer ${token}` },
456
+ });
457
+ if (!resp.ok) {
458
+ const text = await resp.text().catch(() => "");
459
+ throw new Error(`downloadImage HTTP ${resp.status}: ${text.slice(0, 200)}`);
460
+ }
461
+ const ext = extFromContentType(resp.headers.get("content-type"));
462
+ const buffer = Buffer.from(await resp.arrayBuffer());
463
+ await mkdir(IMAGE_DOWNLOAD_DIR, { recursive: true });
464
+ const localPath = resolvePath(IMAGE_DOWNLOAD_DIR, `${fileKey}${ext}`);
465
+ await writeFile(localPath, buffer);
466
+ return localPath;
467
+ }
468
+
469
+ export async function getOrDownloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
470
+ await loadImageCache();
471
+ const cached = imageCache.get(fileKey);
472
+ if (cached) return cached;
473
+ const localPath = await downloadImage(token, messageId, fileKey);
474
+ imageCache.set(fileKey, localPath);
475
+ await persistImageCache().catch((err) => {
476
+ console.error(`[${ts()}] [IMAGE] persist cache FAIL: ${(err as Error).message}`);
477
+ });
478
+ console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
479
+ return localPath;
480
+ }
481
+
408
482
  // ---------------------------------------------------------------------------
409
483
  // Messaging
410
484
  // ---------------------------------------------------------------------------
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 (omitting tool uses the configured default Agent):
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,12 +54,12 @@ import {
54
54
  maskAppId,
55
55
  setDefaultCwd,
56
56
  getRecentDirs,
57
- addRecentDir,
58
- sessionPrefixForTool,
59
- resolveDefaultAgentTool,
60
- toolDisplayName,
61
- ts,
62
- } from "./config.ts";
57
+ addRecentDir,
58
+ sessionPrefixForTool,
59
+ resolveDefaultAgentTool,
60
+ toolDisplayName,
61
+ ts,
62
+ } from "./config.ts";
63
63
  import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.ts";
64
64
  import {
65
65
  addReaction,
@@ -74,6 +74,7 @@ import {
74
74
  setChatAvatar,
75
75
  updateCardMessage,
76
76
  updateChatInfo,
77
+ getOrDownloadImage,
77
78
  sendRestartCard,
78
79
  verifyAllPermissions,
79
80
  reportPermissionResults,
@@ -126,7 +127,7 @@ function getInnerEvent(data: Evt): InnerEvent {
126
127
  * 将飞书消息的原始 content JSON 结构转成可读文本,保留代码块等结构信息。
127
128
  * 未知类型直接返回 JSON 原文,让 AI 自行理解。
128
129
  */
129
- function formatMessageContent(message: { message_type?: string; content?: string }): string {
130
+ async function formatMessageContent(message: { message_id?: string; message_type?: string; content?: string }): Promise<string> {
130
131
  const contentStr = message.content ?? "{}";
131
132
  let content: Record<string, unknown>;
132
133
  try { content = JSON.parse(contentStr); } catch { return ""; }
@@ -143,7 +144,21 @@ function formatMessageContent(message: { message_type?: string; content?: string
143
144
  return formatPostContent(content);
144
145
  }
145
146
 
146
- // 其他类型(image, file, audio, media, sticker)直接给原始 JSON
147
+ if (message.message_type === "image") {
148
+ const imageKey = content.image_key as string | undefined;
149
+ const messageId = message.message_id;
150
+ if (!imageKey || !messageId) return contentStr;
151
+ try {
152
+ const token = await getTenantAccessToken();
153
+ const localPath = await getOrDownloadImage(token, messageId, imageKey);
154
+ return `[图片] ${localPath}`;
155
+ } catch (err) {
156
+ console.error(`[${ts()}] [IMAGE] download failed for ${imageKey}: ${(err as Error).message}`);
157
+ return `[图片: ${imageKey}]`;
158
+ }
159
+ }
160
+
161
+ // 其他类型(file, audio, media, sticker)直接给原始 JSON
147
162
  return contentStr;
148
163
  }
149
164
 
@@ -197,7 +212,7 @@ function parseCardAction(data: unknown): CardActionResult | null {
197
212
  }
198
213
  if (!cmd) return null;
199
214
 
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" };
215
+ 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" };
201
216
  let text = CMD_MAP[cmd] ?? "";
202
217
  if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
203
218
  const path = (action.value as Record<string, string>).path;
@@ -723,8 +738,8 @@ async function startBotServiceCore(): Promise<void> {
723
738
  console.log(`${"=".repeat(60)}`);
724
739
  console.log(` ChatCCC — Feishu Bot Bridge for Claude Code${modeTag}`);
725
740
  console.log(`${"=".repeat(60)}`);
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.`);
741
+ console.log(` Send "/new" to the bot to create a new group + ${toolDisplayName(resolveDefaultAgentTool())} session.`);
742
+ console.log(` In a session group, send any message to resume & prompt.`);
728
743
  console.log(`${"=".repeat(60)}`);
729
744
 
730
745
  console.log(`\n[启动 4/7] 向飞书开放平台申请 tenant_access_token …`);
@@ -784,7 +799,7 @@ async function startBotServiceCore(): Promise<void> {
784
799
  }
785
800
  }
786
801
 
787
- const text = formatMessageContent(message);
802
+ const text = await formatMessageContent(message);
788
803
  const sender = event.sender;
789
804
  const openId = sender?.sender_id?.open_id ?? "";
790
805
  const chatId = message.chat_id ?? "";
@@ -868,7 +883,7 @@ async function startBotServiceCore(): Promise<void> {
868
883
  console.log("[启动 7/7] 已连接本地中继,可接收转发事件。\n");
869
884
  printServiceRunningHint("local", `http://127.0.0.1:${CHATCCC_PORT}`);
870
885
  });
871
- ws.on("message", (raw: Buffer) => {
886
+ ws.on("message", async (raw: Buffer) => {
872
887
  try {
873
888
  const data = JSON.parse(raw.toString()) as Evt;
874
889
  const action = parseCardAction(data);
@@ -881,7 +896,7 @@ async function startBotServiceCore(): Promise<void> {
881
896
  const event = getInnerEvent(data);
882
897
  const message = event.message;
883
898
  if (!message) return;
884
- const text = formatMessageContent(message);
899
+ const text = await formatMessageContent(message);
885
900
  const openId = event.sender?.sender_id?.open_id ?? "";
886
901
  const chatId = message.chat_id ?? "";
887
902
  appendChatLog(chatId, openId, text);