chatccc 0.2.5 → 0.2.7

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/index.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  BASE_URL,
37
37
  CLAUDE_EFFORT,
38
38
  CLAUDE_MODEL,
39
+ GIT_TIMEOUT_MS,
39
40
  anthropicConfigDisplay,
40
41
  LOCAL_RELAY_URL,
41
42
  PID_FILE,
@@ -48,13 +49,17 @@ import {
48
49
  getDefaultCwd,
49
50
  maskAppId,
50
51
  setDefaultCwd,
52
+ getRecentDirs,
53
+ addRecentDir,
54
+ sessionPrefixForTool,
55
+ toolDisplayName,
51
56
  ts,
52
57
  } from "./config.ts";
53
58
  import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.ts";
54
59
  import {
55
60
  addReaction,
56
61
  createGroupChat,
57
- extractSessionId,
62
+ extractSessionInfo,
58
63
  getChatInfo,
59
64
  getTenantAccessToken,
60
65
  recallMessage,
@@ -66,8 +71,9 @@ import {
66
71
  verifyAllPermissions,
67
72
  reportPermissionResults,
68
73
  } from "./feishu-api.ts";
69
- import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildSessionsCard } from "./cards.ts";
74
+ import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
70
75
  import { updateCardKitCard } from "./cardkit.ts";
76
+ import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
71
77
  import {
72
78
  MAX_PROCESSED,
73
79
  chatSessionMap,
@@ -78,8 +84,7 @@ import {
78
84
  resetState,
79
85
  resumeAndPrompt,
80
86
  sessionInfoMap,
81
- initAdapter,
82
- getAdapter,
87
+ getAdapterForTool,
83
88
  } from "./session.ts";
84
89
 
85
90
  // ---------------------------------------------------------------------------
@@ -97,7 +102,11 @@ function getInnerEvent(data: Evt): InnerEvent {
97
102
  return (data.event ?? data) as InnerEvent;
98
103
  }
99
104
 
100
- function extractText(message: { message_type?: string; content?: string }): string {
105
+ /**
106
+ * 将飞书消息的原始 content JSON 结构转成可读文本,保留代码块等结构信息。
107
+ * 未知类型直接返回 JSON 原文,让 AI 自行理解。
108
+ */
109
+ function formatMessageContent(message: { message_type?: string; content?: string }): string {
101
110
  const contentStr = message.content ?? "{}";
102
111
  let content: Record<string, unknown>;
103
112
  try { content = JSON.parse(contentStr); } catch { return ""; }
@@ -109,7 +118,36 @@ function extractText(message: { message_type?: string; content?: string }): stri
109
118
  text = text.replace(/&nbsp;/gi, " ");
110
119
  return text.trim();
111
120
  }
112
- return "";
121
+
122
+ if (message.message_type === "post") {
123
+ return formatPostContent(content);
124
+ }
125
+
126
+ // 其他类型(image, file, audio, media, sticker)直接给原始 JSON
127
+ return contentStr;
128
+ }
129
+
130
+ function formatPostContent(content: Record<string, unknown>): string {
131
+ const paragraphs = content.content as unknown[][];
132
+ if (!Array.isArray(paragraphs)) return "";
133
+
134
+ const parts: string[] = [];
135
+ for (const line of paragraphs) {
136
+ if (!Array.isArray(line)) continue;
137
+ for (const elem of line) {
138
+ const el = elem as Record<string, unknown>;
139
+ if (!el || typeof el !== "object") continue;
140
+ const t = typeof el.text === "string" ? el.text : "";
141
+
142
+ if (el.tag === "code_block") {
143
+ const lang = typeof el.language === "string" ? el.language : "";
144
+ parts.push("```" + lang + "\n" + t + "\n```");
145
+ } else if (el.tag === "p" || el.tag === "text") {
146
+ if (t) parts.push(t);
147
+ }
148
+ }
149
+ }
150
+ return parts.join("\n").trim();
113
151
  }
114
152
 
115
153
  // ---------------------------------------------------------------------------
@@ -139,8 +177,12 @@ function parseCardAction(data: unknown): CardActionResult | null {
139
177
  }
140
178
  if (!cmd) return null;
141
179
 
142
- const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions" };
143
- const text = CMD_MAP[cmd] ?? "";
180
+ const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new cursor": "/new cursor", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions" };
181
+ let text = CMD_MAP[cmd] ?? "";
182
+ if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
183
+ const path = (action.value as Record<string, string>).path;
184
+ if (path) text = `/cd ${path}`;
185
+ }
144
186
  if (!text) return null;
145
187
 
146
188
  const chatId =
@@ -189,9 +231,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
189
231
  let sessionCwd: string | undefined;
190
232
  try {
191
233
  const chatInfo = await getChatInfo(cdToken, chatId);
192
- const sid = extractSessionId(chatInfo.description);
193
- if (sid) {
194
- const info = await getAdapter().getSessionInfo(sid);
234
+ const sessionInfoResult = extractSessionInfo(chatInfo.description);
235
+ if (sessionInfoResult) {
236
+ const adapter = getAdapterForTool(sessionInfoResult.tool);
237
+ const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
195
238
  sessionCwd = info?.cwd;
196
239
  }
197
240
  } catch { /* 非会话群或获取失败,不显示 */ }
@@ -224,6 +267,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
224
267
  const isUpdate = !!arg && targetDir !== currentDir;
225
268
  if (isUpdate) {
226
269
  await setDefaultCwd(targetDir);
270
+ await addRecentDir(targetDir);
227
271
  }
228
272
 
229
273
  // Read directory entries
@@ -248,12 +292,39 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
248
292
  return a.name.localeCompare(b.name);
249
293
  });
250
294
 
251
- const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
252
- await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
295
+ if (!arg) {
296
+ // /cd 无参数:展示卡片(含最近使用路径按钮)
297
+ const recentDirs = await getRecentDirs();
298
+ const card = buildCdCard(targetDir, withStats, recentDirs, sessionCwd);
299
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
300
+ method: "POST",
301
+ headers: {
302
+ Authorization: `Bearer ${cdToken}`,
303
+ "Content-Type": "application/json",
304
+ },
305
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
306
+ });
307
+ const respData: Record<string, any> = await resp.json().catch(() => ({}));
308
+ console.log(`[${ts()}] [CD] card sent, code=${respData.code}, msgId=${respData.data?.message_id ?? "N/A"}, recentDirs=${recentDirs.length}`);
309
+ } else {
310
+ // /cd <path>:切换目录,发送文本卡片
311
+ const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
312
+ await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
313
+ }
253
314
  return;
254
315
  }
255
316
 
256
- if (text === "/new") {
317
+ if (text === "/new" || text.startsWith("/new ")) {
318
+ const toolArg = text.slice(5).trim();
319
+ const tool = toolArg || "claude";
320
+ const validTools = ["claude", "cursor"];
321
+ if (!validTools.includes(tool)) {
322
+ const warnToken = await getTenantAccessToken();
323
+ await sendCardReply(warnToken, chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor)。`, "red");
324
+ return;
325
+ }
326
+ const toolLabel = toolDisplayName(tool);
327
+
257
328
  if (!openId) {
258
329
  console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
259
330
  const warnToken = await getTenantAccessToken();
@@ -265,13 +336,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
265
336
 
266
337
  let sessionId: string;
267
338
  try {
268
- sessionId = await initClaudeSession();
269
- console.log(`[${ts()}] [STEP 1/4] Claude SDK session created: ${sessionId} → OK`);
339
+ sessionId = await initClaudeSession(tool);
340
+ console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
270
341
  } catch (err) {
271
342
  console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
272
343
  await sendCardReply(
273
344
  freshToken, chatId, "Error",
274
- `Failed to initialize Claude session:\n${(err as Error).message}`,
345
+ `Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
275
346
  "red"
276
347
  );
277
348
  return;
@@ -289,8 +360,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
289
360
 
290
361
  try {
291
362
  const initialName = `新会话-${sessionId}`;
292
- await updateChatInfo(freshToken, newChatId, initialName, `Claude Session: ${sessionId}`);
293
- console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" → OK`);
363
+ const descPrefix = sessionPrefixForTool(tool);
364
+ await updateChatInfo(freshToken, newChatId, initialName, `${descPrefix} ${sessionId}`);
365
+ console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
294
366
  } catch (err) {
295
367
  console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
296
368
  await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
@@ -298,9 +370,15 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
298
370
  }
299
371
 
300
372
  const cwd = await getDefaultCwd();
373
+ const adapter = getAdapterForTool(tool);
301
374
  await sendCardReply(
302
- freshToken, newChatId, "Claude Session Ready",
303
- `群聊已创建,这是你的 Claude 会话群。\n\n**Session ID:** ${sessionId}\n**工作目录:** \`${cwd}\`\n\n直接在这里发消息即可与 Claude 对话。\n\n发送 **/sessions** 查看所有会话状态。`,
375
+ freshToken, newChatId, `${toolLabel} Session Ready`,
376
+ `群聊已创建,这是你的 **${toolLabel}** 会话群。\n\n` +
377
+ `**Session ID:** ${sessionId}\n` +
378
+ `**工作目录:** \`${cwd}\`\n\n` +
379
+ `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
380
+ `发送 **/sessions** 查看所有会话状态。\n` +
381
+ `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
304
382
  "green"
305
383
  );
306
384
 
@@ -313,10 +391,13 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
313
391
  const token = await getTenantAccessToken();
314
392
  const chatInfo = await getChatInfo(token, chatId);
315
393
  const description = chatInfo.description;
316
- const sessionId = extractSessionId(description);
394
+ const sessionInfo = extractSessionInfo(description);
317
395
 
318
- if (sessionId) {
319
- console.log(`[${ts()}] [RESUME] 克劳德会话群 detected, session=${sessionId}`);
396
+ if (sessionInfo) {
397
+ const sessionId = sessionInfo.sessionId;
398
+ const descriptionTool = sessionInfo.tool;
399
+ const toolLabel = toolDisplayName(descriptionTool);
400
+ console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
320
401
 
321
402
  const freshToken = await getTenantAccessToken();
322
403
 
@@ -347,16 +428,21 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
347
428
  }
348
429
 
349
430
  if (text === "/status") {
350
- const status = getSessionStatus(chatId);
431
+ const status = await getSessionStatus(chatId);
351
432
  const running = chatSessionMap.get(chatId);
352
433
  const isActive = running && !running.stopped;
353
434
  const statusText = [
354
435
  `**Session ID:** \`${status?.sessionId ?? sessionId}\``,
436
+ `**工具:** ${toolLabel}`,
355
437
  `**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
356
438
  `**已对话轮数:** ${status?.turnCount ?? 0}`,
357
439
  `**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
358
- `**Effort:** ${status?.effort ?? anthropicConfigDisplay(CLAUDE_EFFORT)}`,
359
440
  ];
441
+ // effort 仅在该工具有此概念时显示(status?.effort 为 null 表示
442
+ // 当前工具没有 effort,如 Cursor,应隐藏整行避免误导)
443
+ if (status?.effort != null) {
444
+ statusText.push(`**Effort:** ${status.effort}`);
445
+ }
360
446
  if (isActive) {
361
447
  const elapsed = Math.floor((Date.now() - (status!.startTime)) / 1000);
362
448
  const mins = Math.floor(elapsed / 60);
@@ -382,7 +468,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
382
468
  }
383
469
 
384
470
  if (text === "/sessions") {
385
- const allSessions = getAllSessionsStatus();
471
+ const allSessions = await getAllSessionsStatus();
386
472
  const now = Date.now();
387
473
  const cardData = allSessions.map(s => ({
388
474
  sessionId: s.sessionId,
@@ -390,6 +476,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
390
476
  turnCount: s.turnCount,
391
477
  elapsedSeconds: s.active ? Math.floor((now - s.startTime) / 1000) : null,
392
478
  model: s.model,
479
+ tool: s.tool,
393
480
  }));
394
481
  const card = buildSessionsCard(cardData);
395
482
  const sessionsResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
@@ -405,6 +492,49 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
405
492
  return;
406
493
  }
407
494
 
495
+ // /git <args>:在「当前会话工作目录」执行 git 命令,把输出回发到群里。
496
+ // 注意 cwd 必须取自 adapter.getSessionInfo(会话真实 cwd),而非
497
+ // getDefaultCwd(下一次 /new 才会使用的默认路径)。
498
+ if (text.startsWith("/git ") || text === "/git") {
499
+ const args = text === "/git" ? "" : text.slice(5).trim();
500
+ if (!args) {
501
+ await sendCardReply(
502
+ freshToken, chatId, "/git",
503
+ "用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。",
504
+ "yellow"
505
+ );
506
+ return;
507
+ }
508
+
509
+ const adapter = getAdapterForTool(descriptionTool);
510
+ let cwd: string | undefined;
511
+ try {
512
+ const info = await adapter.getSessionInfo(sessionId);
513
+ cwd = info?.cwd;
514
+ } catch (err) {
515
+ console.error(`[${ts()}] [GIT] getSessionInfo FAIL: ${(err as Error).message}`);
516
+ }
517
+ if (!cwd) {
518
+ // Cursor 会话的 cwd 依赖 .claude/cursor-session-cwd.json 持久化映射;
519
+ // 升级前创建的旧会话或映射文件丢失时,向会话发送一次普通消息即可触发
520
+ // adapter 自动学习并补全(resume 流首条 init 事件携带 cwd)。
521
+ const isCursor = descriptionTool === "cursor";
522
+ const hint = isCursor
523
+ ? "无法获取当前 Cursor 会话的工作目录(缺少 sessionId→cwd 持久化映射)。请先在本群发送一条普通消息(让 adapter 从 cursor-agent 流中自动补回 cwd),然后再试 /git;若仍失败,可用 /new 重建会话。"
524
+ : `无法获取当前会话的工作目录(${toolLabel} adapter 未返回 cwd)。请先与 AI 对话一次再试,或检查会话是否仍存在。`;
525
+ await sendCardReply(freshToken, chatId, "/git", hint, "red");
526
+ return;
527
+ }
528
+
529
+ console.log(`[${ts()}] [GIT] chat=${chatId} cwd=${cwd} cmd="git ${args}" timeoutMs=${GIT_TIMEOUT_MS}`);
530
+ const result = await runGitCommand(args, cwd, { timeoutMs: GIT_TIMEOUT_MS });
531
+ console.log(`[${ts()}] [GIT] exitCode=${result.exitCode}, durationMs=${result.durationMs}, truncated=${result.truncated}, timedOut=${result.timedOut}`);
532
+ const content = formatGitResult(args, cwd, result);
533
+ const template = gitResultHeaderTemplate(result);
534
+ await sendCardReply(freshToken, chatId, "/git 输出", content, template);
535
+ return;
536
+ }
537
+
408
538
  const existing = chatSessionMap.get(chatId);
409
539
  if (existing && !existing.stopped) {
410
540
  if (msgTimestamp <= existing.msgTimestamp) {
@@ -434,13 +564,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
434
564
  }
435
565
 
436
566
  try {
437
- await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp);
567
+ await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool);
438
568
  console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
439
569
  } catch (err) {
440
570
  console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
571
+ fileLog.flush();
441
572
  await sendCardReply(
442
573
  freshToken, chatId, "Error",
443
- `Failed to resume Claude session:\n${(err as Error).message}`,
574
+ `Failed to resume ${toolLabel} session:\n${(err as Error).message}`,
444
575
  "red"
445
576
  );
446
577
  }
@@ -580,7 +711,7 @@ async function main(): Promise<void> {
580
711
  }
581
712
  }
582
713
 
583
- const text = extractText(message);
714
+ const text = formatMessageContent(message);
584
715
  const sender = event.sender;
585
716
  const openId = sender?.sender_id?.open_id ?? "";
586
717
  const chatId = message.chat_id ?? "";
@@ -649,7 +780,6 @@ async function main(): Promise<void> {
649
780
  });
650
781
 
651
782
  if (USE_LOCAL) {
652
- initAdapter();
653
783
  console.log(`\n[启动 6/7] 本地区 relay 模式:正在连接 ${LOCAL_RELAY_URL} …`);
654
784
  console.log(" 若失败:请先在 SDK 模式下启动主进程,或确认本机中继已在该地址监听。");
655
785
  let localRelayOpened = false;
@@ -673,7 +803,7 @@ async function main(): Promise<void> {
673
803
  const event = getInnerEvent(data);
674
804
  const message = event.message;
675
805
  if (!message) return;
676
- const text = extractText(message);
806
+ const text = formatMessageContent(message);
677
807
  const openId = event.sender?.sender_id?.open_id ?? "";
678
808
  const chatId = message.chat_id ?? "";
679
809
  appendChatLog(chatId, openId, text);
@@ -695,13 +825,12 @@ async function main(): Promise<void> {
695
825
  });
696
826
  } else {
697
827
  resetState();
698
- initAdapter();
699
828
 
700
829
  const wsClient = new WSClient({
701
830
  appId: APP_ID,
702
831
  appSecret: APP_SECRET,
703
- onReady: () => { resetState(); initAdapter(); },
704
- onReconnected: () => { resetState(); initAdapter(); },
832
+ onReady: () => { resetState(); },
833
+ onReconnected: () => { resetState(); },
705
834
  });
706
835
 
707
836
  console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);