🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

cc-im

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cc-im - npm Package Compare versions

Comparing version
1.0.1
to
1.1.0
+88
dist/hook/ensure-hook.js
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { createLogger } from '../logger.js';
const log = createLogger('Hook');
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
const HOOK_MATCHER = 'Bash|Write|Edit';
/**
* 获取 hook-script.js 的绝对路径。
* 无论从 src/ 还是 dist/ 运行,始终返回 dist/hook/hook-script.js
*/
function getHookScriptPath() {
const thisFile = fileURLToPath(import.meta.url);
// thisFile = <project>/(dist|src)/hook/ensure-hook.(js|ts)
const projectRoot = dirname(dirname(dirname(thisFile)));
return join(projectRoot, 'dist', 'hook', 'hook-script.js');
}
/** 判断一个 hook command 是否指向本项目的 hook-script.js */
function isOurHook(command, projectHookPath) {
if (!command)
return false;
// 精确匹配,或者同项目下的 src/ 版本(dev 模式残留)
if (command === projectHookPath)
return true;
const srcVariant = projectHookPath.replace('/dist/hook/', '/src/hook/');
return command === srcVariant;
}
/**
* 确保 Claude CLI 的 PreToolUse hook 已配置。
* 如果 ~/.claude/settings.json 中缺少对应 hook,自动写入。
* 如果存在指向 src/ 的旧条目,自动修正为 dist/。
*/
export function ensureHookConfigured() {
const hookScriptPath = getHookScriptPath();
if (!existsSync(hookScriptPath)) {
log.warn(`Hook script not found at ${hookScriptPath}, run "pnpm build" first`);
return false;
}
let settings;
try {
settings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'));
}
catch {
settings = {};
}
const hooks = (settings.hooks ?? {});
const preToolUse = (hooks.PreToolUse ?? []);
// 查找已有的本项目 hook 条目
let found = false;
let needsWrite = false;
for (const entry of preToolUse) {
for (const h of entry.hooks ?? []) {
if (!isOurHook(h.command, hookScriptPath))
continue;
found = true;
if (h.command !== hookScriptPath) {
// 修正路径(src/ → dist/)
log.info(`Fixing hook path: ${h.command} → ${hookScriptPath}`);
h.command = hookScriptPath;
needsWrite = true;
}
}
}
if (found && !needsWrite) {
log.info('PreToolUse hook already configured');
return true;
}
if (!found) {
preToolUse.push({
matcher: HOOK_MATCHER,
hooks: [{ type: 'command', command: hookScriptPath }],
});
needsWrite = true;
}
hooks.PreToolUse = preToolUse;
settings.hooks = hooks;
try {
mkdirSync(dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
log.info(`PreToolUse hook auto-configured → ${hookScriptPath}`);
return true;
}
catch (err) {
log.error(`Failed to write hook config to ${CLAUDE_SETTINGS_PATH}:`, err);
return false;
}
}
/**
* 共享的 Claude 任务执行逻辑
* 封装两个平台重复的节流更新、完成统计、竞态保护等代码
*/
import { runClaude } from '../claude/cli-runner.js';
import { formatToolStats, formatToolCallNotification, trackCost, getContextWarning } from './utils.js';
import { createLogger } from '../logger.js';
const log = createLogger('ClaudeTask');
/**
* 构建完成 note(耗时/费用/工具统计/模型/上下文警告)
*/
function buildCompletionNote(result, sessionManager, ctx) {
const toolInfo = formatToolStats(result.toolStats, result.numTurns);
const noteParts = [];
if (result.cost > 0) {
noteParts.push(`耗时 ${(result.durationMs / 1000).toFixed(1)}s`);
noteParts.push(`费用 $${result.cost.toFixed(4)}`);
}
else {
noteParts.push('完成');
}
if (toolInfo)
noteParts.push(toolInfo);
if (result.model)
noteParts.push(result.model);
// 轮次追踪 & 上下文警告
const totalTurns = ctx.threadId
? sessionManager.addTurnsForThread(ctx.userId, ctx.threadId, result.numTurns)
: sessionManager.addTurns(ctx.userId, result.numTurns);
const ctxWarning = getContextWarning(totalTurns);
if (ctxWarning)
noteParts.push(ctxWarning);
return noteParts.join(' | ');
}
/**
* 执行 Claude 任务的共享逻辑
*
* @param onTaskReady - 任务启动后的回调,传入可变的 TaskRunState 对象供调用方存入 runningTasks
* @param onFirstContent - 首次收到内容时的回调(可选,飞书用来清除 waitingTimer)
*/
export function runClaudeTask(config, sessionManager, ctx, prompt, adapter, userCosts, onTaskReady, onFirstContent) {
return new Promise((resolve) => {
let lastUpdateTime = 0;
let pendingUpdate = null;
let settled = false;
let firstContentLogged = false;
let wasThinking = false;
let thinkingText = '';
let toolLines = [];
const startTime = Date.now();
// 任务状态对象(可变引用,调用方通过 onTaskReady 存入 runningTasks)
let taskState;
const cleanup = () => {
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
adapter.extraCleanup?.();
};
const settle = () => {
if (settled)
return;
settled = true;
cleanup();
resolve();
};
const throttledUpdate = (content) => {
taskState.latestContent = content;
const now = Date.now();
const elapsed = now - lastUpdateTime;
if (elapsed >= adapter.throttleMs) {
lastUpdateTime = now;
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
const toolNote = toolLines.length > 0 ? toolLines.slice(-3).join('\n') : undefined;
adapter.streamUpdate(taskState.latestContent, toolNote);
}
else if (!pendingUpdate) {
pendingUpdate = setTimeout(() => {
pendingUpdate = null;
lastUpdateTime = Date.now();
const toolNote = toolLines.length > 0 ? toolLines.slice(-3).join('\n') : undefined;
adapter.streamUpdate(taskState.latestContent, toolNote);
}, adapter.throttleMs - elapsed);
}
};
const handle = runClaude(config.claudeCliPath, prompt, ctx.sessionId, ctx.workDir, {
onSessionId: (id) => {
if (ctx.threadId) {
sessionManager.setSessionIdForThread(ctx.userId, ctx.threadId, id);
log.info(`Session created for user ${ctx.userId}, thread=${ctx.threadId}: ${id}`);
}
else if (ctx.convId) {
sessionManager.setSessionIdForConv(ctx.userId, ctx.convId, id);
log.info(`Session created for user ${ctx.userId}, convId=${ctx.convId}: ${id}`);
}
},
onThinking: (thinking) => {
if (!firstContentLogged) {
firstContentLogged = true;
log.debug(`First content (thinking) for user ${ctx.userId} after ${Date.now() - startTime}ms`);
onFirstContent?.();
}
wasThinking = true;
thinkingText = thinking;
const display = `💭 **思考中...**\n\n${thinking}`;
throttledUpdate(display);
},
onText: (accumulated) => {
if (!firstContentLogged) {
firstContentLogged = true;
log.debug(`First content (text) for user ${ctx.userId} after ${Date.now() - startTime}ms`);
onFirstContent?.();
}
if (wasThinking && adapter.onThinkingToText) {
wasThinking = false;
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
lastUpdateTime = Date.now();
taskState.latestContent = accumulated;
adapter.onThinkingToText(accumulated);
return;
}
wasThinking = false;
throttledUpdate(accumulated);
},
onToolUse: (toolName, toolInput) => {
const notification = formatToolCallNotification(toolName, toolInput);
toolLines.push(notification);
if (toolLines.length > 5)
toolLines = toolLines.slice(-5);
throttledUpdate(taskState.latestContent);
},
onComplete: async (result) => {
if (settled)
return;
settled = true;
// 先清除 pending 的节流定时器,防止它在 sendComplete 期间触发
// 导致 streaming 更新覆盖 done 状态
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
const note = buildCompletionNote(result, sessionManager, ctx);
log.info(`Claude completed for user ${ctx.userId}: success=${result.success}, cost=$${result.cost.toFixed(4)}`);
trackCost(userCosts, ctx.userId, result.cost, result.durationMs);
const finalContent = result.accumulated || result.result || '(无输出)';
try {
await adapter.sendComplete(finalContent, note, thinkingText || undefined);
}
catch (err) {
log.error('Failed to send complete:', err);
}
cleanup();
resolve();
},
onError: async (error) => {
if (settled)
return;
settled = true;
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
log.error(`Claude error for user ${ctx.userId}, sessionId=${ctx.sessionId ?? 'new'}: ${error}`);
try {
await adapter.sendError(error);
}
catch (err) {
log.error('Failed to send error:', err);
}
cleanup();
resolve();
},
}, {
skipPermissions: config.claudeSkipPermissions,
timeoutMs: config.claudeTimeoutMs,
model: sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel,
chatId: ctx.chatId,
hookPort: config.hookPort,
threadRootMsgId: ctx.threadRootMsgId,
threadId: ctx.threadId,
platform: ctx.platform,
});
taskState = { handle, latestContent: '', settle, startedAt: Date.now() };
onTaskReady(taskState);
});
}
import { readFile, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const PAGE_SIZE = 10;
function encodeWorkDir(dir) {
return dir.replace(/\//g, '-');
}
function extractText(content) {
if (typeof content === 'string')
return content;
if (Array.isArray(content)) {
return content
.filter((b) => b.type === 'text' && b.text)
.map((b) => String(b.text))
.join('\n');
}
return '';
}
export async function getHistory(workDir, sessionId, page) {
const projectDir = join(homedir(), '.claude', 'projects', encodeWorkDir(workDir));
if (!sessionId) {
let files;
try {
files = (await readdir(projectDir)).filter(f => f.endsWith('.jsonl'));
}
catch {
return { ok: false, error: '未找到会话记录目录。' };
}
if (files.length === 0)
return { ok: false, error: '未找到会话记录。' };
// 按修改时间排序取最新
const withMtime = await Promise.all(files.map(async (f) => ({ f, mtime: (await stat(join(projectDir, f)).catch(() => ({ mtimeMs: 0 }))).mtimeMs })));
withMtime.sort((a, b) => a.mtime - b.mtime);
sessionId = withMtime[withMtime.length - 1].f.replace('.jsonl', '');
}
const filePath = join(projectDir, `${sessionId}.jsonl`);
let raw;
try {
raw = await readFile(filePath, 'utf-8');
}
catch {
return { ok: false, error: `未找到会话文件: ${sessionId}` };
}
const entries = [];
for (const line of raw.split('\n')) {
if (!line)
continue;
try {
const obj = JSON.parse(line);
const type = obj.type;
if (type !== 'user' && type !== 'assistant')
continue;
const msg = obj.message;
if (!msg)
continue;
const text = extractText(msg.content).trim();
if (!text)
continue;
if (text.startsWith('<local-command') || text.startsWith('<command-name>'))
continue;
entries.push({ role: msg.role, text, timestamp: obj.timestamp });
}
catch { /* skip malformed lines */ }
}
if (entries.length === 0)
return { ok: false, error: '会话中没有可显示的消息。' };
const totalPages = Math.ceil(entries.length / PAGE_SIZE);
const p = Math.max(1, Math.min(page, totalPages));
const start = (p - 1) * PAGE_SIZE;
const pageEntries = entries.slice(start, start + PAGE_SIZE);
return { ok: true, data: { entries: pageEntries, page: p, totalPages, sessionId } };
}
export function formatHistoryPage(result) {
const lines = [`📜 会话历史 (${result.page}/${result.totalPages}) — ${result.sessionId.slice(-8)}`, ''];
for (const e of result.entries) {
const prefix = e.role === 'user' ? '👤' : '🤖';
const preview = e.text.length > 300 ? e.text.slice(0, 297) + '...' : e.text;
lines.push(`${prefix} ${preview}`);
}
if (result.page < result.totalPages) {
lines.push('', `使用 /history ${result.page + 1} 查看下一页`);
}
return lines.join('\n');
}
import { DEDUP_TTL_MS } from '../constants.js';
const MAX_DEDUP_SIZE = 1000;
/**
* 消息去重器
* 基于消息 ID 和时间戳进行去重,自动过期和大小限制
*/
export class MessageDedup {
processedMessages = new Map();
/**
* 检查消息是否为重复消息
* 如果是新消息,自动记录并返回 false
* 如果是重复消息,返回 true
*/
isDuplicate(messageId) {
const now = Date.now();
if (this.processedMessages.has(messageId))
return true;
this.processedMessages.set(messageId, now);
// 清除过期条目(Map 保持插入顺序,遇到未过期即可停止)
for (const [id, ts] of this.processedMessages) {
if (now - ts > DEDUP_TTL_MS) {
this.processedMessages.delete(id);
}
else {
break;
}
}
// 限制最大容量
while (this.processedMessages.size > MAX_DEDUP_SIZE) {
const oldest = this.processedMessages.keys().next().value;
if (oldest !== undefined)
this.processedMessages.delete(oldest);
else
break;
}
return false;
}
}
import { createLogger } from '../logger.js';
const log = createLogger('Retry');
export async function withRetry(fn, opts) {
const maxRetries = opts?.maxRetries ?? 3;
const baseDelay = opts?.baseDelayMs ?? 500;
const maxDelay = opts?.maxDelayMs ?? 5000;
for (let attempt = 0;; attempt++) {
try {
return await fn();
}
catch (err) {
if (attempt >= maxRetries)
throw err;
const delay = Math.min(baseDelay * 2 ** attempt + Math.random() * 200, maxDelay);
log.warn(`Retry ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms: ${err?.message ?? err}`);
await new Promise(r => setTimeout(r, delay));
}
}
}
import { createLogger } from '../logger.js';
const log = createLogger('TaskCleanup');
const TASK_TIMEOUT_MS = 30 * 60 * 1000;
const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
/**
* 启动定期任务清理(30分钟超时,每10分钟检查一次)
* 返回停止函数
*/
export function startTaskCleanup(runningTasks) {
const timer = setInterval(() => {
const now = Date.now();
for (const [key, task] of runningTasks) {
if (now - task.startedAt > TASK_TIMEOUT_MS) {
log.warn(`Auto-cleaning timeout task: ${key}`);
task.settle();
task.handle.abort();
runningTasks.delete(key);
}
}
}, TASK_CLEANUP_INTERVAL_MS);
timer.unref();
return () => clearInterval(timer);
}
+9
-3

@@ -54,2 +54,3 @@ import { spawn } from 'node:child_process';

if (!completed && !child.killed) {
completed = true;
child.kill('SIGTERM');

@@ -159,6 +160,10 @@ callbacks.onError(`执行超时(${options.timeoutMs}ms),已终止进程`);

let stderrData = '';
if (stderrTotal <= MAX_HEAD_LEN + MAX_TAIL_LEN) {
// 内容未超限,直接使用
stderrData = stderrHead + (headFull ? stderrTail : '');
if (!headFull) {
// head 未满,stderrHead 包含全部内容
stderrData = stderrHead;
}
else if (stderrTotal <= MAX_HEAD_LEN + MAX_TAIL_LEN) {
// head 已满但总量未超限,去掉 tail 中与 head 重叠的部分
stderrData = stderrHead + stderrTail.slice(stderrTail.length - (stderrTotal - MAX_HEAD_LEN));
}
else {

@@ -200,2 +205,3 @@ // 内容超限,显示首尾部分

if (!completed) {
completed = true;
callbacks.onError(`Failed to start Claude CLI: ${err.message}`);

@@ -202,0 +208,0 @@ }

#!/usr/bin/env node
import { join } from 'path';
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync, openSync, closeSync } from 'fs';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { createLogger } from './logger.js';
import { APP_HOME } from './constants.js';
const PID_FILE = join(APP_HOME, 'pid');
const LOG_DIR = join(APP_HOME, 'logs');
const logger = createLogger('CLI');

@@ -77,3 +80,5 @@ function getPidFromFile() {

}
catch { }
catch (e) {
logger.debug('closeSync cleanup failed:', e);
}
}

@@ -87,18 +92,62 @@ const error = err;

}
try {
const { main } = await import('./index.js');
await main();
}
finally {
if (existsSync(PID_FILE)) {
unlinkSync(PID_FILE);
// 进程退出时清理 PID 文件
const cleanupPid = () => {
try {
if (existsSync(PID_FILE))
unlinkSync(PID_FILE);
}
catch (e) {
logger.debug('PID file cleanup failed:', e);
}
};
process.on('exit', cleanupPid);
const { main } = await import('./index.js');
await main();
}
function parseArgs() {
const args = process.argv.slice(2);
let command = 'start';
let daemon = false;
for (const arg of args) {
if (arg === 'stop') {
command = 'stop';
}
else if (arg === '-d' || arg === '--daemon') {
daemon = true;
}
}
return { command, daemon };
}
const command = process.argv[2];
function startDaemon() {
// 检查是否已在运行
const oldPid = getPidFromFile();
if (oldPid && isRunning(oldPid)) {
logger.info(`服务已在运行中 (PID: ${oldPid})`);
logger.info(`请先运行: cc-im stop`);
process.exit(1);
}
mkdirSync(LOG_DIR, { recursive: true });
const outLog = join(LOG_DIR, 'daemon.log');
const outFd = openSync(outLog, 'a');
const scriptPath = fileURLToPath(import.meta.url);
const child = spawn(process.execPath, [scriptPath], {
detached: true,
stdio: ['ignore', outFd, outFd],
env: process.env,
});
child.unref();
closeSync(outFd);
logger.info(`服务已在后台启动 (PID: ${child.pid})`);
logger.info(`日志文件: ${outLog}`);
logger.info(`停止服务: cc-im stop`);
}
const { command, daemon } = parseArgs();
if (command === 'stop') {
await stop();
}
else if (daemon) {
startDaemon();
}
else {
await start();
}

@@ -1,8 +0,9 @@

import { saveRuntimeConfig } from '../config.js';
import { resolveLatestPermission, getPendingCount, listPending } from '../hook/permission-server.js';
import { resolveLatestPermission, getPendingCount } from '../hook/permission-server.js';
import { TERMINAL_ONLY_COMMANDS } from '../constants.js';
import { readFileSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { getHistory, formatHistoryPage } from '../shared/history.js';
/**

@@ -17,8 +18,2 @@ * 共享的命令处理器

/**
* 更新运行中的任务数量
*/
updateRunningTasksSize(size) {
this.deps.runningTasksSize = size;
}
/**
* 统一命令分发:识别并处理所有命令,返回 true 表示已处理

@@ -51,4 +46,2 @@ */

return this.handleDoctor(chatId, userId, threadCtx);
if (trimmed === '/todos')
return this.handleTodos(chatId, userId, handleClaudeRequest, threadCtx);
if (trimmed === '/allow' || trimmed === '/y')

@@ -58,6 +51,2 @@ return this.handleAllow(chatId, threadCtx);

return this.handleDeny(chatId, threadCtx);
if (trimmed === '/allowall')
return this.handleAllowAll(chatId, threadCtx);
if (trimmed === '/pending')
return this.handlePending(chatId, threadCtx);
// 带可选参数的命令

@@ -68,3 +57,3 @@ if (trimmed === '/cd' || trimmed.startsWith('/cd ')) {

if (trimmed === '/model' || trimmed.startsWith('/model ')) {
return this.handleModel(chatId, trimmed.slice(6).trim(), threadCtx);
return this.handleModel(chatId, userId, trimmed.slice(6).trim(), threadCtx);
}

@@ -74,2 +63,5 @@ if (trimmed === '/compact' || trimmed.startsWith('/compact ')) {

}
if (trimmed === '/history' || trimmed.startsWith('/history ')) {
return this.handleHistory(chatId, userId, trimmed.slice(8).trim(), threadCtx);
}
// 仅终端可用的命令

@@ -103,8 +95,6 @@ const cmdName = trimmed.split(/\s+/)[0];

'/list - 列出所有工作区',
'/todos - 列出当前 TODO 项',
'/history [页码] - 浏览会话历史记录',
threadsCmd,
'/allow (/y) - 允许权限请求',
'/deny (/n) - 拒绝权限请求',
'/allowall - 批量允许所有待确认权限',
'/pending - 查看待确认权限列表',
'/allow (/y) - 允许权限请求(按钮不可用时的备选)',
'/deny (/n) - 拒绝权限请求(按钮不可用时的备选)',
].filter(Boolean).join('\n');

@@ -147,3 +137,9 @@ await this.deps.sender.sendTextReply(chatId, helpText, threadCtx);

: this.deps.sessionManager.getWorkDir(userId);
await this.deps.sender.sendTextReply(chatId, `当前工作目录: ${workDir}`, threadCtx);
const subdirs = await this.listSubDirs(workDir);
const lines = [`当前工作目录: ${workDir}`];
if (subdirs.length > 0) {
lines.push('', '📁 子目录:', ...subdirs.map(d => ` ${d}/`));
lines.push('', '使用 /cd <目录名> 切换');
}
await this.deps.sender.sendTextReply(chatId, lines.join('\n'), threadCtx);
return true;

@@ -253,6 +249,9 @@ }

*/
async handleModel(chatId, args, threadCtx) {
async handleModel(chatId, userId, args, threadCtx) {
const modelArg = args.trim();
const threadId = threadCtx?.threadId;
if (!modelArg) {
await this.deps.sender.sendTextReply(chatId, `当前模型: ${this.deps.config.claudeModel ?? '默认 (由 Claude Code 决定)'}\n\n可选模型: sonnet, opus, haiku 或完整模型名\n用法: /model <模型名>`, threadCtx);
const currentModel = this.deps.sessionManager.getModel(userId, threadId);
const scope = threadId ? '当前话题' : '当前';
await this.deps.sender.sendTextReply(chatId, `${scope}模型: ${currentModel ?? this.deps.config.claudeModel ?? '默认 (由 Claude Code 决定)'}\n\n可选模型: sonnet, opus, haiku 或完整模型名\n用法: /model <模型名>`, threadCtx);
}

@@ -264,5 +263,5 @@ else {

}
this.deps.config.claudeModel = modelArg;
saveRuntimeConfig(this.deps.config);
await this.deps.sender.sendTextReply(chatId, `模型已切换为: ${modelArg}\n后续对话将使用此模型。`, threadCtx);
this.deps.sessionManager.setModel(userId, modelArg, threadId);
const scope = threadId ? '当前话题' : '';
await this.deps.sender.sendTextReply(chatId, `${scope}模型已切换为: ${modelArg}\n后续对话将使用此模型。`, threadCtx);
}

@@ -286,3 +285,3 @@ return true;

`允许的基础目录: ${this.deps.config.allowedBaseDirs.join(', ')}`,
`活跃任务数: ${this.deps.runningTasksSize}`,
`活跃任务数: ${this.deps.getRunningTasksSize()}`,
];

@@ -312,3 +311,3 @@ await this.deps.sender.sendTextReply(chatId, lines.join('\n'), threadCtx);

const enqueueResult = this.deps.requestQueue.enqueue(userId, queueKey, compactPrompt, async (prompt) => {
await handleClaudeRequest(this.deps.config, this.deps.sessionManager, userId, chatId, prompt, workDir, undefined, threadCtx);
await handleClaudeRequest(userId, chatId, prompt, workDir, undefined, threadCtx);
});

@@ -324,24 +323,4 @@ if (enqueueResult === 'rejected') {

/**
* 处理 /todos 命令
* 处理 /allow 或 /y 命令(按钮不可用时的 fallback)
*/
async handleTodos(chatId, userId, handleClaudeRequest, threadCtx) {
const workDir = threadCtx
? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
: this.deps.sessionManager.getWorkDir(userId);
const queueKey = threadCtx ? threadCtx.threadId : this.deps.sessionManager.getConvId(userId);
const todosPrompt = '请列出当前项目中所有的 TODO 项(检查代码中的 TODO、FIXME、HACK 注释)。';
const enqueueResult = this.deps.requestQueue.enqueue(userId, queueKey, todosPrompt, async (prompt) => {
await handleClaudeRequest(this.deps.config, this.deps.sessionManager, userId, chatId, prompt, workDir, undefined, threadCtx);
});
if (enqueueResult === 'rejected') {
await this.deps.sender.sendTextReply(chatId, '请求队列已满,请等待当前任务完成后再试。', threadCtx);
}
else if (enqueueResult === 'queued') {
await this.deps.sender.sendTextReply(chatId, '前面还有任务在处理中,请求已排队等待。', threadCtx);
}
return true;
}
/**
* 处理 /allow 或 /y 命令
*/
async handleAllow(chatId, threadCtx) {

@@ -359,3 +338,3 @@ const reqId = resolveLatestPermission(chatId, 'allow');

/**
* 处理 /deny 或 /n 命令
* 处理 /deny 或 /n 命令(按钮不可用时的 fallback)
*/

@@ -374,14 +353,18 @@ async handleDeny(chatId, threadCtx) {

/**
* 处理 /allowall 命令
* 处理 /history 命令 - 浏览会话历史
*/
async handleAllowAll(chatId, threadCtx) {
let count = 0;
while (resolveLatestPermission(chatId, 'allow')) {
count++;
async handleHistory(chatId, userId, args, threadCtx) {
const page = parseInt(args, 10) || 1;
const workDir = threadCtx
? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
: this.deps.sessionManager.getWorkDir(userId);
const sessionId = threadCtx
? this.deps.sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
: this.deps.sessionManager.getSessionIdForConv(userId, this.deps.sessionManager.getConvId(userId));
const result = await getHistory(workDir, sessionId, page);
if (!result.ok) {
await this.deps.sender.sendTextReply(chatId, result.error, threadCtx);
}
if (count > 0) {
await this.deps.sender.sendTextReply(chatId, `✅ 已批量允许 ${count} 个权限请求`, threadCtx);
}
else {
await this.deps.sender.sendTextReply(chatId, 'ℹ️ 没有待确认的权限请求', threadCtx);
await this.deps.sender.sendTextReply(chatId, formatHistoryPage(result.data), threadCtx);
}

@@ -391,16 +374,2 @@ return true;

/**
* 处理 /pending 命令
*/
async handlePending(chatId, threadCtx) {
const pending = listPending(chatId);
if (pending.length === 0) {
await this.deps.sender.sendTextReply(chatId, 'ℹ️ 没有待确认的权限请求', threadCtx);
}
else {
const list = pending.map((p, i) => `${i + 1}. ${p.toolName} (ID: ${p.id})`).join('\n');
await this.deps.sender.sendTextReply(chatId, `🔐 待确认权限列表:\n\n${list}\n\n使用 /allow 允许最早的请求`, threadCtx);
}
return true;
}
/**
* 处理 /threads 命令 - 列出所有话题会话

@@ -424,2 +393,18 @@ */

/**
* 列出目录下的子目录(排除隐藏目录,最多30个)
*/
async listSubDirs(dir) {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries
.filter(d => d.isDirectory() && !d.name.startsWith('.'))
.map(d => d.name)
.sort()
.slice(0, 30);
}
catch {
return [];
}
}
/**
* 列出 Claude 项目

@@ -426,0 +411,0 @@ */

import 'dotenv/config';
import { readFileSync, writeFileSync, mkdirSync, accessSync, constants } from 'node:fs';
import { readFileSync, accessSync, constants } from 'node:fs';
import { execFileSync } from 'node:child_process';

@@ -76,4 +76,4 @@ import { join, isAbsolute } from 'node:path';

const claudeTimeoutMs = process.env.CLAUDE_TIMEOUT_MS !== undefined
? parseInt(process.env.CLAUDE_TIMEOUT_MS, 10) || 300000
: file.claudeTimeoutMs ?? 300000;
? parseInt(process.env.CLAUDE_TIMEOUT_MS, 10) || 600000
: file.claudeTimeoutMs ?? 600000;
// 验证 Claude CLI 路径

@@ -108,4 +108,5 @@ if (isAbsolute(claudeCliPath) || claudeCliPath.includes('/')) {

? parseInt(process.env.HOOK_SERVER_PORT, 10) || 18900
: 18900;
: file.hookPort ?? 18900;
const logDir = process.env.LOG_DIR ?? file.logDir ?? join(APP_HOME, 'logs');
const logLevel = (process.env.LOG_LEVEL?.toUpperCase() ?? file.logLevel ?? 'DEBUG');
return {

@@ -122,33 +123,7 @@ enabledPlatforms,

claudeTimeoutMs,
claudeModel: file.claudeModel,
claudeModel: process.env.CLAUDE_MODEL ?? file.claudeModel,
hookPort,
logDir,
logLevel,
};
}
/**
* 将运行时可变配置(如 claudeModel)持久化到配置文件
*/
export function saveRuntimeConfig(config) {
const configPath = join(APP_HOME, 'config.json');
try {
let existing = {};
try {
existing = JSON.parse(readFileSync(configPath, 'utf-8'));
}
catch {
// 文件不存在或格式错误,从空对象开始
}
if (config.claudeModel) {
existing.claudeModel = config.claudeModel;
}
else {
delete existing.claudeModel;
}
mkdirSync(APP_HOME, { recursive: true });
writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
logger.debug('Runtime config saved');
}
catch (err) {
logger.warn('Failed to save runtime config:', err);
}
}

@@ -1,3 +0,3 @@

import { MAX_STREAMING_CONTENT_LENGTH } from '../constants.js';
import { splitLongContent as sharedSplitLongContent } from '../shared/utils.js';
import { MAX_STREAMING_CONTENT_LENGTH, MAX_CARD_CONTENT_LENGTH } from '../constants.js';
import { splitLongContent as sharedSplitLongContent, truncateText } from '../shared/utils.js';
import { buildInputSummary } from '../shared/utils.js';

@@ -18,12 +18,4 @@ const HEADER_TEMPLATES = {

};
const MAX_CARD_CONTENT_LENGTH = 3800;
export function truncateForCard(text) {
if (text.length <= MAX_CARD_CONTENT_LENGTH)
return text;
// 保留尾部内容,在换行符处截断以避免断行
const keepLen = MAX_CARD_CONTENT_LENGTH - 20;
const tail = text.slice(text.length - keepLen);
const lineBreak = tail.indexOf('\n');
const clean = lineBreak > 0 && lineBreak < 200 ? tail.slice(lineBreak + 1) : tail;
return `...(前文已省略)...\n${clean}`;
return truncateText(text, MAX_CARD_CONTENT_LENGTH);
}

@@ -89,3 +81,20 @@ export function buildCardObject(options, messageId) {

{ tag: 'markdown', content: truncateForCard(inputSummary) },
{ tag: 'note', elements: [{ tag: 'plain_text', content: `ID: ${requestId} | 回复 /allow 允许 · /deny 拒绝` }] },
{
tag: 'action',
actions: [
{
tag: 'button',
text: { tag: 'plain_text', content: '✅ 允许' },
type: 'primary',
value: JSON.stringify({ action: 'allow', requestId }),
},
{
tag: 'button',
text: { tag: 'plain_text', content: '❌ 拒绝' },
type: 'danger',
value: JSON.stringify({ action: 'deny', requestId }),
},
],
},
{ tag: 'note', elements: [{ tag: 'plain_text', content: `ID: ${requestId}` }] },
],

@@ -111,9 +120,3 @@ };

export function truncateForStreaming(text) {
if (text.length <= MAX_STREAMING_CONTENT_LENGTH)
return text;
const keepLen = MAX_STREAMING_CONTENT_LENGTH - 20;
const tail = text.slice(text.length - keepLen);
const lineBreak = tail.indexOf('\n');
const clean = lineBreak > 0 && lineBreak < 200 ? tail.slice(lineBreak + 1) : tail;
return `...(前文已省略)...\n${clean}`;
return truncateText(text, MAX_STREAMING_CONTENT_LENGTH);
}

@@ -120,0 +123,0 @@ export function buildCardV2Object(options, cardId) {

import { getClient } from './client.js';
import { createLogger } from '../logger.js';
import { safeStringify } from '../shared/utils.js';
import { withRetry } from '../shared/retry.js';
const log = createLogger('CardKit');

@@ -9,12 +10,17 @@ const sessions = new Map();

const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
const cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [id, s] of sessions) {
if (now - s.createdAt > SESSION_TTL_MS) {
sessions.delete(id);
log.info(`Auto-cleaned expired card session: ${id}`);
let cleanupTimer = null;
function ensureCleanupTimer() {
if (cleanupTimer)
return;
cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [id, s] of sessions) {
if (now - s.createdAt > SESSION_TTL_MS) {
sessions.delete(id);
log.info(`Auto-cleaned expired card session: ${id}`);
}
}
}
}, CLEANUP_INTERVAL_MS);
cleanupTimer.unref();
}, CLEANUP_INTERVAL_MS);
cleanupTimer.unref();
}
function nextSeq(cardId) {

@@ -31,2 +37,4 @@ const s = sessions.get(cardId);

export async function createCard(cardJson) {
ensureCleanupTimer();
// 不使用 withRetry:创建操作不幂等,重试会产生孤儿卡片
const client = getClient();

@@ -49,18 +57,20 @@ const res = await client.cardkit.v1.card.create({

export async function enableStreaming(cardId) {
const client = getClient();
const res = await client.cardkit.v1.card.settings({
path: { card_id: cardId },
data: {
settings: JSON.stringify({ streaming_mode: true }),
sequence: nextSeq(cardId),
},
return withRetry(async () => {
const client = getClient();
const res = await client.cardkit.v1.card.settings({
path: { card_id: cardId },
data: {
settings: JSON.stringify({ streaming_mode: true }),
sequence: nextSeq(cardId),
},
});
if (res?.code && res.code !== 0) {
log.error(`enableStreaming failed: code=${res.code}, msg=${res.msg}`);
throw new Error(`enableStreaming error: code=${res.code}, msg=${res.msg}`);
}
const s = sessions.get(cardId);
if (s)
s.streamingEnabled = true;
log.debug(`Streaming enabled for card ${cardId}`);
});
if (res?.code && res.code !== 0) {
log.error(`enableStreaming failed: code=${res.code}, msg=${res.msg}`);
throw new Error(`enableStreaming error: code=${res.code}, msg=${res.msg}`);
}
const s = sessions.get(cardId);
if (s)
s.streamingEnabled = true;
log.debug(`Streaming enabled for card ${cardId}`);
}

@@ -78,3 +88,15 @@ /**

};
const res = await call(nextSeq(cardId));
let res;
try {
res = await call(nextSeq(cardId));
}
catch (err) {
// Lark SDK 在 HTTP 4xx 时抛异常(如 99991400 平台级限频),而非返回 response 对象
// 流式更新失败不影响功能,下次节流周期会重试
const respData = err?.response?.data;
if (respData?.code === 99991400)
return; // 平台级限频,静默忽略
log.warn(`streamContent exception: ${err?.message ?? err}`);
return;
}
const code = res?.code;

@@ -116,19 +138,21 @@ if (!code || code === 0)

export async function updateCardFull(cardId, cardJson) {
const client = getClient();
const res = await client.cardkit.v1.card.update({
path: { card_id: cardId },
data: {
card: { type: 'card_json', data: cardJson },
sequence: nextSeq(cardId),
},
return withRetry(async () => {
const client = getClient();
const res = await client.cardkit.v1.card.update({
path: { card_id: cardId },
data: {
card: { type: 'card_json', data: cardJson },
sequence: nextSeq(cardId),
},
});
const code = res?.code;
if (code && code !== 0) {
// 200810: 用户正在交互;300317: sequence 冲突(并发更新)→ 静默忽略
if (code === 200810 || code === 300317)
return;
log.error(`updateCardFull failed: code=${code}, msg=${res.msg}`);
throw new Error(`updateCardFull error: code=${code}, msg=${res.msg}`);
}
log.debug(`Card ${cardId} fully updated`);
});
const code = res?.code;
if (code && code !== 0) {
// 200810: 用户正在交互;300317: sequence 冲突(并发更新)→ 静默忽略
if (code === 200810 || code === 300317)
return;
log.error(`updateCardFull failed: code=${code}, msg=${res.msg}`);
throw new Error(`updateCardFull error: code=${code}, msg=${res.msg}`);
}
log.debug(`Card ${cardId} fully updated`);
}

@@ -183,3 +207,3 @@ /**

data: {
settings: JSON.stringify({ config: { streaming_mode: false } }),
settings: JSON.stringify({ streaming_mode: false }),
sequence: nextSeq(cardId),

@@ -186,0 +210,0 @@ },

@@ -6,6 +6,12 @@ import * as Lark from '@larksuiteoapi/node-sdk';

let wsClient;
let botOpenId;
export function getClient() {
if (!client)
throw new Error('Feishu client not initialized. Call initFeishu() first.');
return client;
}
export function initFeishu(config, eventDispatcher) {
export function getBotOpenId() {
return botOpenId;
}
export async function initFeishu(config, eventDispatcher) {
const baseConfig = {

@@ -16,2 +22,15 @@ appId: config.feishuAppId,

client = new Lark.Client(baseConfig);
try {
const res = await client.request({ method: 'GET', url: '/open-apis/bot/v3/info/' });
botOpenId = res?.bot?.open_id;
if (botOpenId) {
log.info(`Bot open_id obtained`);
}
else {
log.warn('Failed to get bot open_id from /bot/v3/info, group @mention check will be skipped');
}
}
catch (e) {
log.warn(`Failed to fetch bot info: ${e instanceof Error ? e.message : e}, group @mention check will be skipped`);
}
wsClient = new Lark.WSClient({

@@ -18,0 +37,0 @@ ...baseConfig,

@@ -5,40 +5,43 @@ import { join } from 'node:path';

import { AccessControl } from '../access/access-control.js';
import { SessionManager } from '../session/session-manager.js';
import { RequestQueue } from '../queue/request-queue.js';
import { runClaude } from '../claude/cli-runner.js';
import { sendThinkingCard, streamContentUpdate, sendFinalCards, sendErrorCard, sendTextReply, sendPermissionCard, updatePermissionCard, fetchThreadDescription } from './message-sender.js';
import { getClient } from './client.js';
import { getClient, getBotOpenId } from './client.js';
import { buildCardV2 } from './card-builder.js';
import { destroySession, updateCardFull, disableStreaming } from './cardkit-manager.js';
import { registerPermissionSender } from '../hook/permission-server.js';
import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
import { CommandHandler } from '../commands/handler.js';
import { trackCost, formatToolStats, formatToolCallNotification, safeStringify } from '../shared/utils.js';
import { DEDUP_TTL_MS, CARDKIT_THROTTLE_MS, IMAGE_DIR } from '../constants.js';
import { safeStringify } from '../shared/utils.js';
import { runClaudeTask } from '../shared/claude-task.js';
import { startTaskCleanup } from '../shared/task-cleanup.js';
import { MessageDedup } from '../shared/message-dedup.js';
import { CARDKIT_THROTTLE_MS, IMAGE_DIR } from '../constants.js';
import { setActiveChatId } from '../shared/active-chats.js';
import { createLogger } from '../logger.js';
const log = createLogger('EventHandler');
// 费用跟踪(按用户累积)
const userCosts = new Map();
const runningTasks = new Map();
// 定期清理超时任务(30分钟超时,每10分钟检查一次)
const TASK_TIMEOUT_MS = 30 * 60 * 1000;
const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
const taskCleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, task] of runningTasks) {
if (now - task.startedAt > TASK_TIMEOUT_MS) {
log.warn(`Auto-cleaning timeout task: ${key}`);
task.handle.abort();
task.settle();
runningTasks.delete(key);
/**
* 从富文本消息(post)中一次遍历提取所有图片 image_key 和文字内容
*/
export function parsePostContent(postContent) {
const content = postContent?.content;
if (!Array.isArray(content))
return { imageKeys: [], text: null };
const imageKeys = [];
const texts = [];
for (const block of content) {
if (!Array.isArray(block))
continue;
for (const element of block) {
if (!element || typeof element !== 'object')
continue;
const el = element;
if (el.tag === 'img' && el.image_key) {
imageKeys.push(el.image_key);
}
else if (el.tag === 'text' && el.text) {
texts.push(el.text);
}
}
}
}, TASK_CLEANUP_INTERVAL_MS);
taskCleanupTimer.unref();
export function stopEventHandler() {
clearInterval(taskCleanupTimer);
return { imageKeys, text: texts.length > 0 ? texts.join('\n') : null };
}
export function getRunningTaskCount() {
return runningTasks.size;
}
async function downloadFeishuImage(messageId, imageKey) {

@@ -56,6 +59,10 @@ await mkdir(IMAGE_DIR, { recursive: true });

}
export function createEventDispatcher(config) {
export function createEventDispatcher(config, sessionManager) {
const accessControl = new AccessControl(config.allowedUserIds);
const sessionManager = new SessionManager(config.claudeWorkDir, config.allowedBaseDirs);
const requestQueue = new RequestQueue();
// 费用跟踪(按用户累积)
const userCosts = new Map();
const runningTasks = new Map();
const stopTaskCleanup = startTaskCleanup(runningTasks);
const dedup = new MessageDedup();
// Create command handler with dependencies

@@ -68,3 +75,3 @@ const commandHandler = new CommandHandler({

userCosts,
runningTasksSize: 0, // Will be updated dynamically
getRunningTasksSize: () => runningTasks.size,
});

@@ -74,14 +81,150 @@ // Register feishu permission sender

sendPermissionCard,
updatePermissionCard: (_chatId, messageId, toolName, decision) => updatePermissionCard(messageId, toolName, decision),
updatePermissionCard: ({ messageId, toolName, decision }) => updatePermissionCard(messageId, toolName, decision),
});
// Dedup: track processed message IDs with timestamps (max 1000 entries)
const processedMessages = new Map();
const MAX_DEDUP_SIZE = 1000;
// ─── 内部函数(闭包访问 runningTasks 等) ───
async function handleClaudeRequest(userId, chatId, prompt, workDir, convId, threadCtx) {
const sessionId = threadCtx && threadCtx.threadId
? sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
: convId
? sessionManager.getSessionIdForConv(userId, convId)
: undefined;
log.info(`Running Claude for user ${userId}, ${threadCtx ? `thread=${threadCtx.threadId}` : `convId=${convId}`}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`);
let cardHandle;
try {
cardHandle = await sendThinkingCard(chatId, threadCtx);
}
catch (err) {
log.error('Failed to send thinking card:', err);
return;
}
const { messageId, cardId } = cardHandle;
if (!cardId) {
log.error('No card_id returned for thinking card');
return;
}
const finalThreadCtx = threadCtx;
const taskKey = `${userId}:${cardId}`;
let waitingTimer = null;
await runClaudeTask(config, sessionManager, {
userId,
chatId,
workDir,
sessionId,
convId,
threadId: finalThreadCtx?.threadId,
threadRootMsgId: finalThreadCtx?.rootMessageId,
platform: 'feishu',
taskKey,
}, prompt, {
throttleMs: CARDKIT_THROTTLE_MS,
streamUpdate: (content, toolNote) => {
streamContentUpdate(cardId, content, toolNote).catch((e) => log.warn('Stream update failed:', e?.message ?? e));
},
sendComplete: async (content, note, thinkingText) => {
try {
await sendFinalCards(chatId, messageId, cardId, content, note, finalThreadCtx, thinkingText);
}
catch (err) {
log.error('Failed to send final cards:', err);
}
},
sendError: async (error) => {
try {
await sendErrorCard(cardId, error);
}
catch (err) {
log.error('Failed to send error card:', err);
}
},
onThinkingToText: (content) => {
const resetCard = buildCardV2({ content: content || '...', status: 'streaming' }, cardId);
updateCardFull(cardId, resetCard)
.catch((e) => log.warn('Thinking→text transition update failed:', e?.message ?? e));
},
extraCleanup: () => {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
runningTasks.delete(taskKey);
},
}, userCosts, (state) => {
runningTasks.set(taskKey, { ...state, cardId, messageId });
// 等待首次内容期间,每3秒更新卡片显示已等待时间
const startTime = Date.now();
waitingTimer = setInterval(() => {
const taskInfo = runningTasks.get(taskKey);
if (!taskInfo) {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
return;
}
const elapsed = Math.floor((Date.now() - startTime) / 1000);
streamContentUpdate(cardId, `等待 Claude 响应... (${elapsed}s)`).catch(() => { });
}, 3000);
}, () => {
// 首次内容到达,清除等待计时器
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
});
}
async function routeToThread(userId, chatId, threadId, rootMessageId, text) {
let threadSession = sessionManager.getThreadSession(userId, threadId);
if (!threadSession) {
const description = await fetchThreadDescription(rootMessageId) ?? text;
const displayName = description.slice(0, 20) + (description.length > 20 ? '...' : '');
sessionManager.setThreadSession(userId, threadId, {
workDir: sessionManager.getWorkDir(userId),
rootMessageId,
threadId,
displayName,
description,
});
threadSession = sessionManager.getThreadSession(userId, threadId);
}
else if (!threadSession.description) {
const description = await fetchThreadDescription(rootMessageId);
if (description) {
threadSession.description = description;
threadSession.displayName = description.slice(0, 20) + (description.length > 20 ? '...' : '');
sessionManager.setThreadSession(userId, threadId, threadSession);
}
}
const workDir = threadSession.workDir;
const threadCtx = { rootMessageId, threadId };
const enqueueResult = requestQueue.enqueue(userId, threadId, text, async (prompt) => {
await handleClaudeRequest(userId, chatId, prompt, workDir, undefined, threadCtx);
});
if (enqueueResult === 'rejected') {
log.warn(`Queue full for user: ${userId}, thread: ${threadId}`);
await sendTextReply(chatId, '您的请求队列已满,请等待当前任务完成后再试。', threadCtx);
}
else if (enqueueResult === 'queued') {
await sendTextReply(chatId, '前面还有任务在处理中,您的请求已排队等待。', threadCtx);
}
}
async function routeToDefault(userId, chatId, text) {
const workDirSnapshot = sessionManager.getWorkDir(userId);
const convIdSnapshot = sessionManager.getConvId(userId);
const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, text, async (prompt) => {
await handleClaudeRequest(userId, chatId, prompt, workDirSnapshot, convIdSnapshot);
});
if (enqueueResult === 'rejected') {
log.warn(`Queue full for user: ${userId}`);
await sendTextReply(chatId, '您的请求队列已满,请等待当前任务完成后再试。');
}
else if (enqueueResult === 'queued') {
await sendTextReply(chatId, '前面还有任务在处理中,您的请求已排队等待。');
}
}
// ─── 事件注册 ───
const dispatcher = new Lark.EventDispatcher({
// @ts-ignore - defaultCallback 是自定义选项
// 添加通用事件监听器,捕获所有事件
defaultCallback: async (data) => {
const eventType = data?.header?.event_type || data?.type || 'unknown';
log.info(`Received event: ${eventType}`);
// 如果是卡片交互事件,记录详细信息
log.info(`Received event: ${eventType}`, safeStringify(data).slice(0, 500));
if (eventType.includes('card') || eventType.includes('action')) {

@@ -92,3 +235,2 @@ log.info('Card/Action event data:', safeStringify(data, 2));

});
// 注册卡片交互事件处理器
dispatcher.register({

@@ -104,3 +246,2 @@ 'card.action.trigger': async (data) => {

}
// 解析按钮的 value(SDK 可能返回对象或字符串,也可能被双重 JSON 编码)
let actionData;

@@ -111,3 +252,2 @@ try {

parsed = JSON.parse(parsed);
// 如果解析结果仍是字符串,说明被双重编码了,需要再解析一次
if (typeof parsed === 'string') {

@@ -128,3 +268,2 @@ parsed = JSON.parse(parsed);

}
// 处理停止按钮
if (actionData.action === 'stop') {

@@ -141,11 +280,6 @@ const cardId = actionData.card_id;

log.info(`User ${userId} stopped task for card ${cardId}`);
// 保存当前内容
const stoppedContent = taskInfo.latestContent || '(任务已停止,暂无输出)';
// 先从 Map 中删除任务,防止 onComplete 覆盖
runningTasks.delete(taskKey);
// 标记任务已完成(settled=true 后 onComplete/onError 不会再更新卡片)
taskInfo.settle();
// 中止任务
taskInfo.handle.abort();
// 通过 CardKit API 关闭流式模式并更新卡片为已停止状态,然后清理 session
const stoppedCard = buildCardV2({ content: stoppedContent, status: 'done', note: '⏹️ 已停止' });

@@ -160,5 +294,20 @@ disableStreaming(cardId)

log.info(`Current running tasks: ${Array.from(runningTasks.keys()).join(', ')}`);
// 任务已结束,返回空响应保持卡片不变
}
}
else if (actionData.action === 'allow' || actionData.action === 'deny') {
// 处理权限按钮点击
const requestId = actionData.requestId;
const decision = actionData.action;
if (!requestId) {
log.warn('No requestId in permission action');
return;
}
const resolvedId = resolvePermissionById(requestId, decision);
if (resolvedId) {
log.info(`Permission ${decision} via button for request ${requestId}`);
}
else {
log.warn(`No pending permission request found for requestId: ${requestId}`);
}
}
},

@@ -180,27 +329,7 @@ });

const messageId = message.message_id;
// Dedup
if (processedMessages.has(messageId))
if (dedup.isDuplicate(messageId))
return;
const now = Date.now();
processedMessages.set(messageId, now);
// Evict expired entries
for (const [id, ts] of processedMessages) {
if (now - ts > DEDUP_TTL_MS) {
processedMessages.delete(id);
}
else {
break; // Map preserves insertion order, so we can stop early
}
}
// Enforce max size: remove oldest entries
while (processedMessages.size > MAX_DEDUP_SIZE) {
const oldest = processedMessages.keys().next().value;
if (oldest !== undefined)
processedMessages.delete(oldest);
else
break;
}
const chatId = message.chat_id;
const senderId = data.sender?.sender_id?.open_id;
const chatType = message.chat_type; // 'p2p' | 'group' | 'topic'
const chatType = message.chat_type;
const isGroup = chatType === 'group' || chatType === 'topic';

@@ -211,6 +340,4 @@ const threadId = message.thread_id;

return;
// 仅私聊时追踪活跃聊天(启动通知只发私聊)
if (!isGroup)
setActiveChatId('feishu', chatId);
// 群聊非话题消息:要求 @机器人 才响应
if (isGroup && !threadId) {

@@ -221,4 +348,7 @@ const mentions = message.mentions;

}
const myOpenId = getBotOpenId();
if (myOpenId && !mentions.some(m => m.id?.open_id === myOpenId)) {
return;
}
}
// Access control
if (!accessControl.isAllowed(senderId)) {

@@ -229,3 +359,2 @@ log.warn(`Access denied for user: ${senderId}`);

}
// 构造 threadCtx(如果在话题中)
let threadCtx;

@@ -235,7 +364,7 @@ if (isGroup && threadId && rootId) {

}
// Parse message content
const msgType = message.message_type;
if (msgType !== 'text' && msgType !== 'image') {
// 话题内的非文本消息(如创建话题的系统消息)直接忽略,不回复
log.debug(`Message type: ${msgType}, threadId: ${threadId}, content: ${message.content?.slice(0, 200)}`);
if (msgType !== 'text' && msgType !== 'image' && msgType !== 'post') {
if (threadId) {
log.debug(`Ignoring non-text/image/post message in thread: ${msgType}`);
return;

@@ -249,3 +378,2 @@ }

if (msgType === 'image') {
// 下载图片并构造 prompt
let imageKey;

@@ -271,2 +399,31 @@ try {

}
else if (msgType === 'post') {
// 话题中发送的图片会被包装成富文本消息(post),解析其中的图片和文字
try {
const postContent = JSON.parse(message.content);
const { imageKeys, text: textContent } = parsePostContent(postContent);
log.debug(`Post message - images: ${imageKeys.length}, textContent: ${textContent?.slice(0, 100)}`);
if (imageKeys.length > 0) {
const imagePaths = await Promise.all(imageKeys.map(key => downloadFeishuImage(message.message_id, key)));
const pathList = imagePaths.map(p => `- ${p}`).join('\n');
if (textContent) {
text = `用户发送了 ${imagePaths.length} 张图片和文字:\n\n图片路径:\n${pathList}\n\n文字内容:${textContent}\n\n请用 Read 工具查看图片并分析。`;
}
else {
text = `用户发送了 ${imagePaths.length} 张图片,已保存到:\n${pathList}\n\n请用 Read 工具查看并分析图片内容。`;
}
isImageMessage = true;
}
else if (textContent) {
text = textContent;
}
else {
return;
}
}
catch (err) {
log.error('Failed to parse post message:', err);
return;
}
}
else {

@@ -279,3 +436,2 @@ try {

}
// 去掉飞书 @ 占位符(如 @_user_1)
text = text.replace(/@_user_\d+/g, '').trim();

@@ -286,271 +442,18 @@ if (!text?.trim())

log.info(`User ${senderId}${isImageMessage ? ' [image]' : ''}: ${text.slice(0, 100)}${text.length > 100 ? '...' : ''}`);
// Update runningTasksSize for CommandHandler
commandHandler.updateRunningTasksSize(runningTasks.size);
// 统一命令分发(图片消息不走命令分发)
if (!isImageMessage && await commandHandler.dispatch(text, chatId, senderId, 'feishu', handleClaudeRequest, threadCtx)) {
return;
}
// 路由逻辑:话题 vs 非话题
if (isGroup && threadId && rootId) {
// 群聊话题内:使用话题会话
await routeToThread(config, sessionManager, requestQueue, senderId, chatId, threadId, rootId, text);
await routeToThread(senderId, chatId, threadId, rootId, text);
}
else {
// 群聊主聊天区 + P2P:使用默认会话
await routeToDefault(config, sessionManager, requestQueue, senderId, chatId, text);
await routeToDefault(senderId, chatId, text);
}
},
});
return dispatcher;
return {
dispatcher,
stop: () => stopTaskCleanup(),
getRunningTaskCount: () => runningTasks.size,
};
}
// ─── 路由函数 ───
async function routeToThread(config, sessionManager, requestQueue, userId, chatId, threadId, rootMessageId, text) {
let threadSession = sessionManager.getThreadSession(userId, threadId);
if (!threadSession) {
// 首次遇到该话题:从 API 获取话题描述(根消息内容),获取不到时降级用首条用户消息
const description = await fetchThreadDescription(rootMessageId) ?? text;
const displayName = description.slice(0, 20) + (description.length > 20 ? '...' : '');
sessionManager.setThreadSession(userId, threadId, {
workDir: sessionManager.getWorkDir(userId),
rootMessageId,
threadId,
displayName,
description,
});
threadSession = sessionManager.getThreadSession(userId, threadId);
}
else if (!threadSession.description) {
// 回填:已有话题缺少描述
const description = await fetchThreadDescription(rootMessageId);
if (description) {
threadSession.description = description;
threadSession.displayName = description.slice(0, 20) + (description.length > 20 ? '...' : '');
sessionManager.setThreadSession(userId, threadId, threadSession);
}
}
const workDir = threadSession.workDir;
const threadCtx = { rootMessageId, threadId };
const enqueueResult = requestQueue.enqueue(userId, threadId, text, async (prompt) => {
await handleClaudeRequest(config, sessionManager, userId, chatId, prompt, workDir, undefined, threadCtx);
});
if (enqueueResult === 'rejected') {
log.warn(`Queue full for user: ${userId}, thread: ${threadId}`);
await sendTextReply(chatId, '您的请求队列已满,请等待当前任务完成后再试。', threadCtx);
}
else if (enqueueResult === 'queued') {
await sendTextReply(chatId, '前面还有任务在处理中,您的请求已排队等待。', threadCtx);
}
}
async function routeToDefault(config, sessionManager, requestQueue, userId, chatId, text) {
const workDirSnapshot = sessionManager.getWorkDir(userId);
const convIdSnapshot = sessionManager.getConvId(userId);
const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, text, async (prompt) => {
await handleClaudeRequest(config, sessionManager, userId, chatId, prompt, workDirSnapshot, convIdSnapshot);
});
if (enqueueResult === 'rejected') {
log.warn(`Queue full for user: ${userId}`);
await sendTextReply(chatId, '您的请求队列已满,请等待当前任务完成后再试。');
}
else if (enqueueResult === 'queued') {
await sendTextReply(chatId, '前面还有任务在处理中,您的请求已排队等待。');
}
}
async function handleClaudeRequest(config, sessionManager, userId, chatId, prompt, workDir, convId, threadCtx) {
// sessionId 获取:话题模式用 threadId,非话题用 convId
const sessionId = threadCtx && threadCtx.threadId
? sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
: convId
? sessionManager.getSessionIdForConv(userId, convId)
: undefined;
log.info(`Running Claude for user ${userId}, ${threadCtx ? `thread=${threadCtx.threadId}` : `convId=${convId}`}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`);
// Send thinking card
let cardHandle;
try {
cardHandle = await sendThinkingCard(chatId, threadCtx);
}
catch (err) {
log.error('Failed to send thinking card:', err);
return;
}
const { messageId, cardId } = cardHandle;
if (!cardId) {
log.error('No card_id returned for thinking card');
return;
}
// 捕获最终的 threadCtx 用于闭包
const finalThreadCtx = threadCtx;
return new Promise((resolve) => {
let lastUpdateTime = 0;
let pendingUpdate = null;
let waitingTimer = null;
let latestContent = '';
let settled = false;
let firstContentLogged = false;
let wasThinking = false;
let thinkingText = '';
let toolLines = [];
const startTime = Date.now();
const taskKey = `${userId}:${cardId}`;
const cleanup = () => {
if (waitingTimer) {
clearInterval(waitingTimer);
waitingTimer = null;
}
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
runningTasks.delete(taskKey);
};
const settle = () => {
if (settled)
return;
settled = true;
cleanup();
resolve();
};
const throttledUpdate = (content) => {
latestContent = content;
// 更新 runningTasks 中的最新内容
const taskInfo = runningTasks.get(taskKey);
if (taskInfo) {
taskInfo.latestContent = content;
}
const now = Date.now();
const elapsed = now - lastUpdateTime;
if (elapsed >= CARDKIT_THROTTLE_MS) {
lastUpdateTime = now;
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
streamContentUpdate(cardId, latestContent, toolLines.slice(-3).join('\n') || undefined).catch((e) => log.warn('Stream update failed:', e?.message ?? e));
}
else if (!pendingUpdate) {
pendingUpdate = setTimeout(() => {
pendingUpdate = null;
lastUpdateTime = Date.now();
streamContentUpdate(cardId, latestContent, toolLines.slice(-3).join('\n') || undefined).catch((e) => log.warn('Stream update failed:', e?.message ?? e));
}, CARDKIT_THROTTLE_MS - elapsed);
}
};
const handle = runClaude(config.claudeCliPath, prompt, sessionId, workDir, {
onSessionId: (id) => {
if (finalThreadCtx?.threadId) {
sessionManager.setSessionIdForThread(userId, finalThreadCtx.threadId, id);
log.info(`Session created for user ${userId}, thread=${finalThreadCtx.threadId}: ${id}`);
}
else if (convId) {
sessionManager.setSessionIdForConv(userId, convId, id);
log.info(`Session created for user ${userId}, convId=${convId}: ${id}`);
}
},
onThinking: (thinking) => {
if (!firstContentLogged) {
firstContentLogged = true;
log.debug(`First content (thinking) for user ${userId} after ${Date.now() - startTime}ms`);
}
wasThinking = true;
thinkingText = thinking;
// 思考阶段也更新卡片,让用户看到 Claude 在想什么
const display = `💭 **思考中...**\n\n${thinking}`;
throttledUpdate(display);
},
onText: (accumulated) => {
if (!firstContentLogged) {
firstContentLogged = true;
log.debug(`First content (text) for user ${userId} after ${Date.now() - startTime}ms`);
}
if (wasThinking) {
wasThinking = false;
// 思考→文本切换:内容前缀完全改变,CardKit 无法做增量渲染
// 用 updateCardFull 重置卡片基线,后续流式更新才能正常增量
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
const resetCard = buildCardV2({ content: accumulated || '...', status: 'streaming' }, cardId);
updateCardFull(cardId, resetCard)
.catch((e) => log.warn('Thinking→text transition update failed:', e?.message ?? e));
lastUpdateTime = Date.now();
latestContent = accumulated;
const taskInfo = runningTasks.get(taskKey);
if (taskInfo)
taskInfo.latestContent = accumulated;
return;
}
throttledUpdate(accumulated);
},
onToolUse: (toolName, toolInput) => {
const notification = formatToolCallNotification(toolName, toolInput);
toolLines.push(notification);
if (toolLines.length > 5)
toolLines = toolLines.slice(-5);
// 工具调用时立即更新卡片 note,避免工具执行期间卡片长时间静止
throttledUpdate(latestContent);
},
onComplete: async (result) => {
if (settled)
return;
const toolInfo = formatToolStats(result.toolStats, result.numTurns);
const noteParts = [];
if (result.cost > 0) {
noteParts.push(`耗时 ${(result.durationMs / 1000).toFixed(1)}s`);
noteParts.push(`费用 $${result.cost.toFixed(4)}`);
}
else {
noteParts.push('完成');
}
if (toolInfo)
noteParts.push(toolInfo);
if (result.model)
noteParts.push(result.model);
const note = noteParts.join(' | ');
log.info(`Claude completed for user ${userId}: success=${result.success}, cost=$${result.cost.toFixed(4)}`);
// 累积费用统计
trackCost(userCosts, userId, result.cost, result.durationMs);
// 优先使用流式累积的原始文本,避免 result.result 中的 HTML 实体编码
const finalContent = result.accumulated || result.result || '(无输出)';
try {
await sendFinalCards(chatId, messageId, cardId, finalContent, note, finalThreadCtx, thinkingText || undefined);
}
catch (err) {
log.error('Failed to send final cards:', err);
}
settle(); // 在卡片更新后再清理任务
},
onError: async (error) => {
if (settled)
return;
log.error(`Claude error for user ${userId}: ${error}`);
try {
await sendErrorCard(cardId, error);
}
catch (err) {
log.error('Failed to send error card:', err);
}
settle(); // 在卡片更新后再清理任务
},
}, {
skipPermissions: config.claudeSkipPermissions,
model: config.claudeModel,
chatId,
hookPort: config.hookPort,
threadRootMsgId: finalThreadCtx?.rootMessageId,
threadId: finalThreadCtx?.threadId,
platform: 'feishu',
});
// 将任务 handle 和 settle 函数存储到 Map 中,以便能够在用户点击停止按钮时中止
runningTasks.set(taskKey, { handle, cardId, messageId, latestContent: '', settle, startedAt: Date.now() });
// 等待首次内容期间,每3秒更新卡片显示已等待时间
waitingTimer = setInterval(() => {
if (firstContentLogged || settled) {
clearInterval(waitingTimer);
waitingTimer = null;
return;
}
const elapsed = Math.floor((Date.now() - startTime) / 1000);
streamContentUpdate(cardId, `等待 Claude 响应... (${elapsed}s)`).catch(() => { });
}, 3000);
});
}

@@ -28,3 +28,15 @@ import { createServer } from 'node:http';

}
// Resolve the latest pending request for a given chatId
// Resolve a specific pending request by its ID
export function resolvePermissionById(requestId, decision) {
const pending = pendingRequests.get(requestId);
if (!pending)
return null;
pending.resolve(decision);
const platformSender = senders.get(pending.platform);
platformSender?.updatePermissionCard({ messageId: pending.messageId, chatId: pending.chatId, toolName: pending.toolName, decision }).catch(() => { });
pendingRequests.delete(requestId);
removeFromIndex(pending.chatId, requestId);
return requestId;
}
// Resolve the oldest pending request for a given chatId (fallback for /allow, /deny commands)
export function resolveLatestPermission(chatId, decision) {

@@ -43,8 +55,3 @@ const ids = chatIdIndex.get(chatId);

return null;
oldest.resolve(decision);
const platformSender = senders.get(oldest.platform);
platformSender?.updatePermissionCard(oldest.chatId, oldest.messageId, oldest.toolName, decision).catch(() => { });
pendingRequests.delete(oldest.id);
removeFromIndex(chatId, oldest.id);
return oldest.id;
return resolvePermissionById(oldest.id, decision);
}

@@ -54,14 +61,2 @@ export function getPendingCount(chatId) {

}
export function listPending(chatId) {
const ids = chatIdIndex.get(chatId);
if (!ids)
return [];
const result = [];
for (const id of ids) {
const req = pendingRequests.get(id);
if (req)
result.push(req);
}
return result.sort((a, b) => a.createdAt - b.createdAt);
}
function readBody(req) {

@@ -151,3 +146,3 @@ return new Promise((resolve, reject) => {

if (savedMessageId) {
platformSender.updatePermissionCard(chatId, savedMessageId, toolName, 'deny').catch(() => { });
platformSender.updatePermissionCard({ messageId: savedMessageId, chatId, toolName, decision: 'deny' }).catch(() => { });
}

@@ -196,3 +191,3 @@ }

export function startPermissionServer(port) {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {

@@ -206,7 +201,11 @@ handleRequest(req, res).catch((err) => {

});
server.on('error', (err) => {
log.error(`Permission server failed to start on port ${port}:`, err);
reject(err);
});
server.listen(port, '127.0.0.1', () => {
log.info(`Permission server listening on 127.0.0.1:${port}`);
resolve();
resolve({ close: () => server.close() });
});
});
}
import { loadConfig } from './config.js';
import { initFeishu, stopFeishu } from './feishu/client.js';
import { initTelegram, stopTelegram } from './telegram/client.js';
import { createEventDispatcher, getRunningTaskCount as getFeishuTaskCount, stopEventHandler as stopFeishuEventHandler } from './feishu/event-handler.js';
import { createEventDispatcher } from './feishu/event-handler.js';
import { sendTextReply as feishuSendText } from './feishu/message-sender.js';
import { setupTelegramHandlers, getRunningTaskCount as getTelegramTaskCount, stopTelegramEventHandler } from './telegram/event-handler.js';
import { setupTelegramHandlers } from './telegram/event-handler.js';
import { sendTextReply as telegramSendText } from './telegram/message-sender.js';
import { startPermissionServer } from './hook/permission-server.js';
import { ensureHookConfigured } from './hook/ensure-hook.js';
import { SessionManager } from './session/session-manager.js';
import { loadActiveChats, getActiveChatId } from './shared/active-chats.js';
import { cleanOldImages } from './shared/utils.js';
import { initLogger, createLogger, closeLogger } from './logger.js';
import { execFileSync } from 'node:child_process';
const log = createLogger('Main');
function getClaudeVersion(cliPath) {
try {
return execFileSync(cliPath, ['--version'], { timeout: 5000 }).toString().trim();
}
catch {
return '未知';
}
}
async function sendLifecycleNotification(activeBots, message) {

@@ -29,3 +40,3 @@ const tasks = [];

const config = loadConfig();
initLogger(config.logDir);
initLogger(config.logDir, config.logLevel);
loadActiveChats();

@@ -40,32 +51,15 @@ log.info('Starting cc-im bridge service...');

log.info(`Allowed base dirs: ${config.allowedBaseDirs.length} dirs configured`);
/**
* Permission Hook Server
*
* When CLAUDE_SKIP_PERMISSIONS is false (default), we start a local HTTP server
* to handle permission confirmation requests from the Claude Code PreToolUse hook.
*
* How it works:
* 1. Claude Code invokes the hook script before executing sensitive tools (Bash, Write, Edit, etc.)
* 2. The hook script sends a permission request to this server
* 3. The server sends a permission card/message to the user via the messaging platform
* 4. User responds with /allow or /deny
* 5. The decision is returned to the hook script, which returns it to Claude Code
*
* Important limitations:
* - The server is started only once at application startup
* - Changing CLAUDE_SKIP_PERMISSIONS at runtime requires restarting the application
* - The server listens only on localhost (127.0.0.1) for security
* - Permission requests timeout after 5 minutes if user doesn't respond
*
* Configuration:
* - CLAUDE_SKIP_PERMISSIONS: Set to "true" to disable permission checks (NOT recommended)
* - HOOK_SERVER_PORT: Port for the permission server (default: 18900)
*/
let permissionServer = null;
if (!config.claudeSkipPermissions) {
await startPermissionServer(config.hookPort);
ensureHookConfigured();
permissionServer = await startPermissionServer(config.hookPort);
log.info(`Permission hook server started on port ${config.hookPort}`);
}
// 创建共享的 SessionManager 单例
const sessionManager = new SessionManager(config.claudeWorkDir, config.allowedBaseDirs);
// Initialize enabled platforms in parallel
const activeBots = [];
const initTasks = [];
let feishuHandle = null;
let telegramHandle = null;
if (config.enabledPlatforms.includes('telegram')) {

@@ -79,3 +73,5 @@ log.debug('Initializing Telegram platform...');

}
initTasks.push(initTelegram(config, (bot) => setupTelegramHandlers(bot, config))
initTasks.push(initTelegram(config, (bot) => {
telegramHandle = setupTelegramHandlers(bot, config, sessionManager);
})
.then(() => {

@@ -93,5 +89,5 @@ log.info('Telegram bot initialized');

initTasks.push(Promise.resolve()
.then(() => {
const eventDispatcher = createEventDispatcher(config);
initFeishu(config, eventDispatcher);
.then(async () => {
feishuHandle = createEventDispatcher(config, sessionManager);
await initFeishu(config, feishuHandle.dispatcher);
log.info('Feishu bot initialized');

@@ -118,2 +114,4 @@ return { platform: 'Feishu', success: true };

// 发送启动通知
const startedAt = Date.now();
const claudeVer = getClaudeVersion(config.claudeCliPath);
const startupMsg = [

@@ -126,2 +124,4 @@ '🟢 cc-im 服务已启动',

config.claudeModel ? `模型: ${config.claudeModel}` : '',
`Claude CLI: ${claudeVer}`,
`Node: ${process.version}`,
].filter(Boolean).join('\n');

@@ -142,16 +142,21 @@ sendLifecycleNotification(activeBots, startupMsg).catch(() => { });

// 发送关闭通知
await sendLifecycleNotification(activeBots, '🔴 cc-im 服务正在关闭...').catch(() => { });
const uptimeSec = Math.floor((Date.now() - startedAt) / 1000);
const h = Math.floor(uptimeSec / 3600);
const m = Math.floor((uptimeSec % 3600) / 60);
const uptimeStr = h > 0 ? `${h}h${m}m` : `${m}m`;
await sendLifecycleNotification(activeBots, `🔴 cc-im 服务正在关闭...\n运行时长: ${uptimeStr}`).catch(() => { });
// 停止接受新消息
telegramHandle?.stop();
if (config.enabledPlatforms.includes('telegram')) {
stopTelegramEventHandler();
stopTelegram();
}
feishuHandle?.stop();
if (config.enabledPlatforms.includes('feishu')) {
stopFeishuEventHandler();
stopFeishu();
}
permissionServer?.close();
// 等待运行中的任务完成(最多 30 秒)
const maxWait = 30_000;
const start = Date.now();
const getTotalTasks = () => getFeishuTaskCount() + getTelegramTaskCount();
const getTotalTasks = () => (feishuHandle?.getRunningTaskCount() ?? 0) + (telegramHandle?.getRunningTaskCount() ?? 0);
let remaining = getTotalTasks();

@@ -158,0 +163,0 @@ if (remaining > 0) {

@@ -7,3 +7,5 @@ import { createWriteStream, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';

const MAX_LOG_FILES = 10;
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
let logDir = DEFAULT_LOG_DIR;
let minLevel = LOG_LEVELS.DEBUG;
let logStream;

@@ -35,6 +37,9 @@ function pad(n) {

}
export function initLogger(dir) {
export function initLogger(dir, level) {
if (dir) {
logDir = dir;
}
if (level) {
minLevel = LOG_LEVELS[level] ?? LOG_LEVELS.DEBUG;
}
if (!existsSync(logDir)) {

@@ -60,2 +65,4 @@ mkdirSync(logDir, { recursive: true });

function write(level, tag, msg, ...args) {
if (LOG_LEVELS[level] < minLevel)
return;
const extra = args.length > 0

@@ -62,0 +69,0 @@ ? ' ' + args.map((a) => (a instanceof Error ? a.stack ?? a.message : String(a))).join(' ')

import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { writeFile, realpath } from 'node:fs/promises';
import { realpath } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';

@@ -18,2 +18,4 @@ import { dirname, resolve, join } from 'node:path';

convSessionMap = new Map(); // userId:convId -> sessionId
rootMsgIndex = new Map(); // rootMessageId -> location
static MAX_CONV_SESSION_MAP_SIZE = 200;
defaultWorkDir;

@@ -76,16 +78,23 @@ allowedBaseDirs;

this.convSessionMap.set(`${userId}:${convId}`, sessionId);
this.pruneConvSessionMap();
}
}
pruneConvSessionMap() {
while (this.convSessionMap.size > SessionManager.MAX_CONV_SESSION_MAP_SIZE) {
const oldest = this.convSessionMap.keys().next().value;
if (oldest !== undefined)
this.convSessionMap.delete(oldest);
else
break;
}
}
getWorkDir(userId) {
return this.sessions.get(userId)?.workDir ?? this.defaultWorkDir;
}
async setWorkDir(userId, workDir) {
const currentDir = this.getWorkDir(userId);
const resolved = resolve(currentDir, workDir);
async resolveAndValidatePath(baseDir, targetDir) {
const resolved = resolve(baseDir, targetDir);
if (!existsSync(resolved)) {
throw new Error(`目录不存在: ${resolved}`);
}
// 解析符号链接获取真实路径,防止路径遍历攻击
const realPath = await realpath(resolved);
// Check path is under an allowed base directory
const allowed = this.allowedBaseDirs.some((base) => realPath === base || realPath.startsWith(base + '/'));

@@ -95,2 +104,7 @@ if (!allowed) {

}
return realPath;
}
async setWorkDir(userId, workDir) {
const currentDir = this.getWorkDir(userId);
const realPath = await this.resolveAndValidatePath(currentDir, workDir);
const session = this.sessions.get(userId);

@@ -101,2 +115,3 @@ if (session) {

this.convSessionMap.set(`${userId}:${session.activeConvId}`, session.sessionId);
this.pruneConvSessionMap();
}

@@ -121,5 +136,7 @@ session.workDir = realPath;

this.convSessionMap.set(`${userId}:${session.activeConvId}`, session.sessionId);
this.pruneConvSessionMap();
}
session.sessionId = undefined;
session.activeConvId = this.generateConvId();
session.totalTurns = 0;
this.flushSync();

@@ -131,2 +148,45 @@ log.info(`New session started for user: ${userId}`);

}
addTurns(userId, turns) {
const session = this.sessions.get(userId);
if (!session)
return 0;
session.totalTurns = (session.totalTurns ?? 0) + turns;
this.save();
return session.totalTurns;
}
addTurnsForThread(userId, threadId, turns) {
const thread = this.sessions.get(userId)?.threads?.[threadId];
if (!thread)
return 0;
thread.totalTurns = (thread.totalTurns ?? 0) + turns;
this.save();
return thread.totalTurns;
}
getModel(userId, threadId) {
const session = this.sessions.get(userId);
if (threadId) {
const threadModel = session?.threads?.[threadId]?.claudeModel;
if (threadModel)
return threadModel;
}
return session?.claudeModel;
}
setModel(userId, model, threadId) {
if (threadId) {
const thread = this.sessions.get(userId)?.threads?.[threadId];
if (thread) {
thread.claudeModel = model;
this.save();
return;
}
}
const session = this.sessions.get(userId);
if (session) {
session.claudeModel = model;
}
else {
this.sessions.set(userId, { workDir: this.defaultWorkDir, activeConvId: this.generateConvId(), claudeModel: model });
}
this.save();
}
// ─── Thread Session Methods ───

@@ -137,2 +197,7 @@ getThreadSession(userId, threadId) {

setThreadSession(userId, threadId, session) {
// 清除旧的 rootMsgIndex 条目(如果该 threadId 已有旧 session)
const oldThread = this.sessions.get(userId)?.threads?.[threadId];
if (oldThread?.rootMessageId) {
this.rootMsgIndex.delete(oldThread.rootMessageId);
}
const userSession = this.sessions.get(userId);

@@ -151,2 +216,6 @@ if (userSession) {

}
// 维护反向索引
if (session.rootMessageId) {
this.rootMsgIndex.set(session.rootMessageId, { userId, threadId });
}
this.save();

@@ -157,2 +226,6 @@ }

if (threads) {
const thread = threads[threadId];
if (thread?.rootMessageId) {
this.rootMsgIndex.delete(thread.rootMessageId);
}
delete threads[threadId];

@@ -189,12 +262,3 @@ this.flushSync();

}
const resolved = resolve(thread.workDir, workDir);
if (!existsSync(resolved)) {
throw new Error(`目录不存在: ${resolved}`);
}
// 解析符号链接获取真实路径,防止路径遍历攻击
const realPath = await realpath(resolved);
const allowed = this.allowedBaseDirs.some((base) => realPath === base || realPath.startsWith(base + '/'));
if (!allowed) {
throw new Error(`目录不在允许范围内: ${realPath}\n允许的目录: ${this.allowedBaseDirs.join(', ')}`);
}
const realPath = await this.resolveAndValidatePath(thread.workDir, workDir);
thread.workDir = realPath;

@@ -210,2 +274,3 @@ thread.sessionId = undefined; // 切换目录重置会话

thread.sessionId = undefined;
thread.totalTurns = 0;
this.flushSync();

@@ -218,13 +283,14 @@ log.info(`Thread session reset: user=${userId}, thread=${threadId}`);

removeThreadByRootMessageId(rootMessageId) {
for (const [, session] of this.sessions) {
if (!session.threads)
continue;
for (const [threadId, thread] of Object.entries(session.threads)) {
if (thread.rootMessageId === rootMessageId) {
delete session.threads[threadId];
this.save();
return true;
}
}
const loc = this.rootMsgIndex.get(rootMessageId);
if (!loc)
return false;
const threads = this.sessions.get(loc.userId)?.threads;
if (threads && threads[loc.threadId]) {
delete threads[loc.threadId];
this.rootMsgIndex.delete(rootMessageId);
this.save();
return true;
}
// 索引过期,清理
this.rootMsgIndex.delete(rootMessageId);
return false;

@@ -253,2 +319,10 @@ }

this.sessions.set(key, val);
// 重建 rootMsgIndex
if (val.threads) {
for (const [threadId, thread] of Object.entries(val.threads)) {
if (thread.rootMessageId) {
this.rootMsgIndex.set(thread.rootMessageId, { userId: key, threadId });
}
}
}
}

@@ -273,12 +347,3 @@ }

try {
const dir = dirname(SESSIONS_FILE);
if (!existsSync(dir))
mkdirSync(dir, { recursive: true });
const obj = {};
for (const [key, val] of this.sessions) {
obj[key] = val;
}
writeFile(SESSIONS_FILE, JSON.stringify(obj, null, 2)).catch((err) => {
log.error('Failed to save sessions:', err);
});
this.doFlush();
}

@@ -296,10 +361,3 @@ catch (err) {

try {
const dir = dirname(SESSIONS_FILE);
if (!existsSync(dir))
mkdirSync(dir, { recursive: true });
const obj = {};
for (const [key, val] of this.sessions) {
obj[key] = val;
}
writeFileSync(SESSIONS_FILE, JSON.stringify(obj, null, 2), 'utf-8');
this.doFlush();
log.info('Sessions saved synchronously');

@@ -312,2 +370,12 @@ }

}
doFlush() {
const dir = dirname(SESSIONS_FILE);
if (!existsSync(dir))
mkdirSync(dir, { recursive: true });
const obj = {};
for (const [key, val] of this.sessions) {
obj[key] = val;
}
writeFileSync(SESSIONS_FILE, JSON.stringify(obj, null, 2), 'utf-8');
}
}

@@ -7,2 +7,5 @@ /**

import { IMAGE_DIR } from '../constants.js';
import { createLogger } from '../logger.js';
import { mkdir } from 'node:fs/promises';
const log = createLogger('Utils');
/**

@@ -30,2 +33,14 @@ * 工具 emoji 映射

/**
* 截断文本,保留尾部内容,在换行符处截断以避免断行
*/
export function truncateText(text, maxLen) {
if (text.length <= maxLen)
return text;
const keepLen = maxLen - 20;
const tail = text.slice(text.length - keepLen);
const lineBreak = tail.indexOf('\n');
const clean = lineBreak > 0 && lineBreak < 200 ? tail.slice(lineBreak + 1) : tail;
return `...(前文已省略)...\n${clean}`;
}
/**
* 分割长内容为多个片段

@@ -89,3 +104,4 @@ */

.join(' ');
return `${numTurns} 轮 ${totalTools} 次工具(${parts})`;
const turnInfo = numTurns > 0 ? `${numTurns} 轮 ` : '';
return `${turnInfo}${totalTools} 次工具(${parts})`;
}

@@ -95,27 +111,67 @@ /**

*/
function truncate(s, max) {
return s.length > max ? s.slice(0, max - 3) + '...' : s;
}
function countLines(s) {
if (!s)
return 0;
let count = 1;
for (let i = 0; i < s.length; i++) {
if (s[i] === '\n')
count++;
}
return count;
}
export function formatToolCallNotification(toolName, toolInput) {
const emoji = getToolEmoji(toolName);
if (!toolInput)
return `${emoji} ${toolName}`;
let detail = '';
if (toolInput) {
if ((toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') && toolInput.file_path) {
detail = ` → ${toolInput.file_path}`;
switch (toolName) {
case 'Edit': {
const fp = toolInput.file_path ? String(toolInput.file_path) : '';
const oldLines = countLines(String(toolInput.old_string ?? ''));
const newLines = countLines(String(toolInput.new_string ?? ''));
detail = fp ? ` → ${fp} (-${oldLines}/+${newLines} 行)` : '';
break;
}
else if (toolName === 'Bash' && toolInput.command) {
const cmd = String(toolInput.command);
detail = ` → ${cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd}`;
case 'Read': {
const fp = toolInput.file_path ? String(toolInput.file_path) : '';
const parts = [fp];
if (toolInput.offset)
parts.push(`L${toolInput.offset}`);
if (toolInput.limit)
parts.push(`${toolInput.limit}行`);
detail = fp ? ` → ${parts.join(' ')}` : '';
break;
}
else if ((toolName === 'Grep' || toolName === 'Glob') && toolInput.pattern) {
detail = ` → ${toolInput.pattern}`;
case 'Write': {
const fp = toolInput.file_path ? String(toolInput.file_path) : '';
const len = String(toolInput.content ?? '').length;
detail = fp ? ` → ${fp} (${len}字符)` : '';
break;
}
else if (toolName === 'WebFetch' && toolInput.url) {
const url = String(toolInput.url);
detail = ` → ${url.length > 60 ? url.slice(0, 57) + '...' : url}`;
}
else if (toolName === 'WebSearch' && toolInput.query) {
detail = ` → ${toolInput.query}`;
}
else if (toolName === 'Task' && toolInput.description) {
const desc = String(toolInput.description);
detail = ` → ${desc.length > 40 ? desc.slice(0, 37) + '...' : desc}`;
}
case 'Bash':
if (toolInput.command)
detail = ` → ${truncate(String(toolInput.command), 60)}`;
break;
case 'Grep':
case 'Glob':
if (toolInput.pattern)
detail = ` → ${toolInput.pattern}`;
break;
case 'WebFetch':
if (toolInput.url)
detail = ` → ${truncate(String(toolInput.url), 60)}`;
break;
case 'WebSearch':
if (toolInput.query)
detail = ` → ${toolInput.query}`;
break;
case 'Task':
if (toolInput.description)
detail = ` → ${truncate(String(toolInput.description), 40)}`;
break;
default:
break;
}

@@ -135,2 +191,12 @@ return `${emoji} ${toolName}${detail}`;

/**
* 根据累计轮次返回上下文警告(null 表示无需警告)
*/
export function getContextWarning(totalTurns) {
if (totalTurns >= 12)
return '⚠️ 上下文较长,建议 /new 开始新会话或 /compact 压缩';
if (totalTurns >= 8)
return '💡 对话已 ' + totalTurns + ' 轮,可用 /compact 压缩上下文';
return null;
}
/**
* 安全的 JSON 序列化,防止循环引用导致异常

@@ -153,2 +219,4 @@ */

try {
// 确保目录存在
await mkdir(IMAGE_DIR, { recursive: true });
const files = await readdir(IMAGE_DIR);

@@ -165,7 +233,11 @@ const now = Date.now();

}
catch { /* ignore per-file errors */ }
catch (e) {
log.debug(`Failed to clean image ${f}:`, e);
}
}));
}
catch { /* dir may not exist */ }
catch (e) {
log.debug('Image dir not accessible:', e);
}
return cleaned;
}

@@ -6,2 +6,4 @@ import { Telegraf } from 'telegraf';

export function getBot() {
if (!bot)
throw new Error('Telegram bot not initialized. Call initTelegram() first.');
return bot;

@@ -15,8 +17,10 @@ }

try {
// 添加超时保护,避免卡住
const launchTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Telegram bot launch timeout after 30s')), 30000));
await Promise.race([
bot.launch(),
launchTimeout
]);
// 先验证 token 和网络连通性(30 秒超时)
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Telegram bot connect timeout after 30s')), 30000));
await Promise.race([bot.telegram.getMe(), timeout]);
// launch() 的 Promise 在轮询停止时才 resolve,不能 await
bot.launch().catch(err => {
log.error('Telegram polling fatal error:', err);
process.exit(1);
});
log.info('Telegram bot launched successfully');

@@ -26,3 +30,3 @@ }

log.error('Failed to launch Telegram bot:', err);
throw err; // 抛出错误而不是直接退出,让调用方决定如何处理
throw err;
}

@@ -29,0 +33,0 @@ }

@@ -5,36 +5,13 @@ import { join } from 'node:path';

import { AccessControl } from '../access/access-control.js';
import { SessionManager } from '../session/session-manager.js';
import { RequestQueue } from '../queue/request-queue.js';
import { runClaude } from '../claude/cli-runner.js';
import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendPermissionMessage, updatePermissionMessage, startTypingLoop } from './message-sender.js';
import { registerPermissionSender } from '../hook/permission-server.js';
import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
import { CommandHandler } from '../commands/handler.js';
import { trackCost, formatToolStats, formatToolCallNotification } from '../shared/utils.js';
import { DEDUP_TTL_MS, THROTTLE_MS, IMAGE_DIR } from '../constants.js';
import { runClaudeTask } from '../shared/claude-task.js';
import { startTaskCleanup } from '../shared/task-cleanup.js';
import { MessageDedup } from '../shared/message-dedup.js';
import { THROTTLE_MS, IMAGE_DIR } from '../constants.js';
import { setActiveChatId } from '../shared/active-chats.js';
import { createLogger } from '../logger.js';
const log = createLogger('TgHandler');
const userCosts = new Map();
const runningTasks = new Map();
// 定期清理超时任务(30分钟超时,每10分钟检查一次)
const TASK_TIMEOUT_MS = 30 * 60 * 1000;
const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
const taskCleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, task] of runningTasks) {
if (now - task.startedAt > TASK_TIMEOUT_MS) {
log.warn(`Auto-cleaning timeout task: ${key}`);
task.handle.abort();
task.settle();
runningTasks.delete(key);
}
}
}, TASK_CLEANUP_INTERVAL_MS);
taskCleanupTimer.unref();
export function stopTelegramEventHandler() {
clearInterval(taskCleanupTimer);
}
export function getRunningTaskCount() {
return runningTasks.size;
}
async function downloadTelegramPhoto(bot, fileId) {

@@ -50,6 +27,9 @@ await mkdir(IMAGE_DIR, { recursive: true });

}
export function setupTelegramHandlers(bot, config) {
export function setupTelegramHandlers(bot, config, sessionManager) {
const accessControl = new AccessControl(config.allowedUserIds);
const sessionManager = new SessionManager(config.claudeWorkDir, config.allowedBaseDirs);
const requestQueue = new RequestQueue();
const userCosts = new Map();
const runningTasks = new Map();
const stopTaskCleanup = startTaskCleanup(runningTasks);
const dedup = new MessageDedup();
// Create command handler with dependencies

@@ -62,3 +42,3 @@ const commandHandler = new CommandHandler({

userCosts,
runningTasksSize: 0, // Will be updated dynamically
getRunningTasksSize: () => runningTasks.size,
});

@@ -68,17 +48,61 @@ // Register telegram permission sender

sendPermissionCard: sendPermissionMessage,
updatePermissionCard: updatePermissionMessage,
updatePermissionCard: ({ messageId, chatId, toolName, decision }) => updatePermissionMessage(chatId, messageId, toolName, decision),
});
const processedMessages = new Map();
// Dedup helper: returns true if message is duplicate
const isDuplicate = (messageId) => {
const now = Date.now();
if (processedMessages.has(messageId))
return true;
processedMessages.set(messageId, now);
for (const [mid, ts] of processedMessages.entries()) {
if (now - ts > DEDUP_TTL_MS)
processedMessages.delete(mid);
async function handleClaudeRequest(userId, chatId, prompt, workDir, convId) {
const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined;
log.info(`Running Claude for user ${userId}, convId=${convId}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`);
let msgId;
try {
msgId = await sendThinkingMessage(chatId);
}
return false;
};
catch (err) {
log.error('Failed to send thinking message:', err);
return;
}
if (!msgId) {
log.error('No message_id returned for thinking message');
return;
}
const stopTyping = startTypingLoop(chatId);
const taskKey = `${userId}:${msgId}`;
await runClaudeTask(config, sessionManager, {
userId,
chatId,
workDir,
sessionId,
convId,
platform: 'telegram',
taskKey,
}, prompt, {
throttleMs: THROTTLE_MS,
streamUpdate: (content, toolNote) => {
const note = toolNote
? '输出中...\n' + toolNote
: '输出中...';
updateMessage(chatId, msgId, content, 'streaming', note).catch(() => { });
},
sendComplete: async (content, note) => {
try {
await sendFinalMessages(chatId, msgId, content, note);
}
catch (err) {
log.error('Failed to send final messages:', err);
}
},
sendError: async (error) => {
try {
await updateMessage(chatId, msgId, `错误:${error}`, 'error', '执行失败');
}
catch (err) {
log.error('Failed to send error message:', err);
}
},
extraCleanup: () => {
stopTyping();
runningTasks.delete(taskKey);
},
}, userCosts, (state) => {
runningTasks.set(taskKey, state);
});
}
// Handle callback queries (stop button)

@@ -108,2 +132,14 @@ bot.on('callback_query', async (ctx) => {

}
else if (data.startsWith('perm_allow_') || data.startsWith('perm_deny_')) {
const isAllow = data.startsWith('perm_allow_');
const requestId = data.replace(/^perm_(allow|deny)_/, '');
const decision = isAllow ? 'allow' : 'deny';
const resolved = resolvePermissionById(requestId, decision);
if (resolved) {
await ctx.answerCbQuery(isAllow ? '✅ 已允许' : '❌ 已拒绝');
}
else {
await ctx.answerCbQuery('请求已过期或不存在');
}
}
});

@@ -124,3 +160,3 @@ // Handle text messages

// Dedup
if (isDuplicate(messageId)) {
if (dedup.isDuplicate(messageId)) {
log.debug(`Duplicate message ${messageId}, skipping`);

@@ -138,4 +174,2 @@ return;

log.debug(`Processing message from authorized user ${userId}: ${text.slice(0, 100)}${text.length > 100 ? '...' : ''}`);
// Update runningTasksSize for CommandHandler
commandHandler.updateRunningTasksSize(runningTasks.size);
// 统一命令分发

@@ -149,3 +183,3 @@ if (await commandHandler.dispatch(text, chatId, userId, 'telegram', handleClaudeRequest)) {

const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, text, async (prompt) => {
await handleClaudeRequest(config, sessionManager, userId, chatId, prompt, workDirSnapshot, convIdSnapshot);
await handleClaudeRequest(userId, chatId, prompt, workDirSnapshot, convIdSnapshot);
});

@@ -172,3 +206,3 @@ if (enqueueResult === 'rejected') {

// Dedup
if (isDuplicate(messageId))
if (dedup.isDuplicate(messageId))
return;

@@ -201,3 +235,3 @@ // Access control

const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, prompt, async (p) => {
await handleClaudeRequest(config, sessionManager, userId, chatId, p, workDirSnapshot, convIdSnapshot);
await handleClaudeRequest(userId, chatId, p, workDirSnapshot, convIdSnapshot);
});

@@ -211,144 +245,6 @@ if (enqueueResult === 'rejected') {

});
async function handleClaudeRequest(config, sessionManager, userId, chatId, prompt, workDir, convId) {
const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined;
log.info(`Running Claude for user ${userId}, convId=${convId}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`);
let msgId;
try {
msgId = await sendThinkingMessage(chatId);
}
catch (err) {
log.error('Failed to send thinking message:', err);
return;
}
if (!msgId) {
log.error('No message_id returned for thinking message');
return;
}
const startTime = Date.now();
const stopTyping = startTypingLoop(chatId);
return new Promise((resolve) => {
let lastUpdateTime = 0;
let pendingUpdate = null;
let latestContent = '';
let settled = false;
let firstContentLogged = false;
let toolLines = [];
const taskKey = `${userId}:${msgId}`;
const cleanup = () => {
stopTyping();
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
runningTasks.delete(taskKey);
};
const settle = () => {
if (settled)
return;
settled = true;
cleanup();
resolve();
};
const throttledUpdate = (content) => {
latestContent = content;
const taskInfo = runningTasks.get(taskKey);
if (taskInfo)
taskInfo.latestContent = content;
const now = Date.now();
const elapsed = now - lastUpdateTime;
const toolNote = toolLines.length > 0
? '输出中...\n' + toolLines.slice(-3).join('\n')
: '输出中...';
if (elapsed >= THROTTLE_MS) {
lastUpdateTime = now;
if (pendingUpdate) {
clearTimeout(pendingUpdate);
pendingUpdate = null;
}
updateMessage(chatId, msgId, latestContent, 'streaming', toolNote).catch(() => { });
}
else if (!pendingUpdate) {
pendingUpdate = setTimeout(() => {
pendingUpdate = null;
lastUpdateTime = Date.now();
updateMessage(chatId, msgId, latestContent, 'streaming', toolNote).catch(() => { });
}, THROTTLE_MS - elapsed);
}
};
const handle = runClaude(config.claudeCliPath, prompt, sessionId, workDir, {
onSessionId: (id) => {
if (convId) {
sessionManager.setSessionIdForConv(userId, convId, id);
log.info(`Session created for user ${userId}, convId=${convId}: ${id}`);
}
},
onThinking: (thinking) => {
if (!firstContentLogged) {
firstContentLogged = true;
log.debug(`First content (thinking) for user ${userId} after ${Date.now() - startTime}ms`);
}
const display = `💭 **思考中...**\n\n${thinking}`;
throttledUpdate(display);
},
onText: (accumulated) => {
if (!firstContentLogged) {
firstContentLogged = true;
log.debug(`First content (text) for user ${userId} after ${Date.now() - startTime}ms`);
}
throttledUpdate(accumulated);
},
onToolUse: (toolName, toolInput) => {
const notification = formatToolCallNotification(toolName, toolInput);
toolLines.push(notification);
if (toolLines.length > 5)
toolLines = toolLines.slice(-5);
},
onComplete: async (result) => {
if (settled)
return;
const toolInfo = formatToolStats(result.toolStats, result.numTurns);
const noteParts = [];
if (result.cost > 0) {
noteParts.push(`耗时 ${(result.durationMs / 1000).toFixed(1)}s`);
noteParts.push(`费用 $${result.cost.toFixed(4)}`);
}
else {
noteParts.push('完成');
}
if (toolInfo)
noteParts.push(toolInfo);
if (result.model)
noteParts.push(result.model);
const note = noteParts.join(' | ');
trackCost(userCosts, userId, result.cost, result.durationMs);
const finalContent = result.accumulated || result.result || '(无输出)';
try {
await sendFinalMessages(chatId, msgId, finalContent, note);
}
catch (err) {
log.error('Failed to send final messages:', err);
}
settle();
},
onError: async (error) => {
if (settled)
return;
try {
await updateMessage(chatId, msgId, `错误:${error}`, 'error', '执行失败');
}
catch (err) {
log.error('Failed to send error message:', err);
}
settle();
},
}, {
skipPermissions: config.claudeSkipPermissions,
model: config.claudeModel,
chatId,
hookPort: config.hookPort,
platform: 'telegram',
});
runningTasks.set(taskKey, { handle, latestContent: '', settle, startedAt: Date.now() });
});
}
return {
stop: () => stopTaskCleanup(),
getRunningTaskCount: () => runningTasks.size,
};
}
import { getBot } from './client.js';
import { createLogger } from '../logger.js';
import { splitLongContent, buildInputSummary } from '../shared/utils.js';
import { splitLongContent, buildInputSummary, truncateText } from '../shared/utils.js';
const log = createLogger('TgSender');

@@ -12,2 +12,6 @@ const MAX_MESSAGE_LENGTH = 4000; // Telegram limit is 4096, leave room for formatting

const chatCooldownUntil = new Map();
/** @internal Test-only: clear all cooldown entries */
export function _resetCooldowns() {
chatCooldownUntil.clear();
}
// Periodic cleanup of expired cooldown entries to prevent memory leak

@@ -47,2 +51,12 @@ const cooldownCleanupTimer = setInterval(() => {

async function callWithRetry(chatId, label, fn) {
// Wait for any existing cooldown before first attempt (e.g. from streaming 429)
const cooldownUntil = chatCooldownUntil.get(chatId);
if (cooldownUntil) {
const waitMs = cooldownUntil - Date.now();
if (waitMs > 0) {
log.info(`${label}: waiting ${Math.ceil(waitMs / 1000)}s for existing cooldown`);
await new Promise((r) => setTimeout(r, waitMs));
}
chatCooldownUntil.delete(chatId);
}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {

@@ -89,9 +103,3 @@ try {

function truncateForMessage(text) {
if (text.length <= MAX_MESSAGE_LENGTH)
return text;
const keepLen = MAX_MESSAGE_LENGTH - 20;
const tail = text.slice(text.length - keepLen);
const lineBreak = tail.indexOf('\n');
const clean = lineBreak > 0 && lineBreak < 200 ? tail.slice(lineBreak + 1) : tail;
return `...(前文已省略)...\n${clean}`;
return truncateText(text, MAX_MESSAGE_LENGTH);
}

@@ -211,4 +219,10 @@ function buildStopKeyboard(messageId) {

const inputSummary = buildInputSummary(toolName, toolInput);
const text = `🔐 权限确认 - ${toolName}\n\n${truncateForMessage(inputSummary)}\n\n─────────\nID: ${requestId} | 回复 /allow 允许 · /deny 拒绝`;
const msg = await callWithRetry(chatId, 'sendPermissionMessage', () => bot.telegram.sendMessage(Number(chatId), text));
const text = `🔐 权限确认 - ${toolName}\n\n${truncateForMessage(inputSummary)}`;
const reply_markup = {
inline_keyboard: [[
{ text: '✅ 允许', callback_data: `perm_allow_${requestId}` },
{ text: '❌ 拒绝', callback_data: `perm_deny_${requestId}` },
]],
};
const msg = await callWithRetry(chatId, 'sendPermissionMessage', () => bot.telegram.sendMessage(Number(chatId), text, { reply_markup }));
return String(msg.message_id);

@@ -215,0 +229,0 @@ }

{
"name": "cc-im",
"version": "1.0.1",
"version": "1.1.0",
"description": "Multi-platform bot bridge (Feishu & Telegram) for Claude Code CLI",

@@ -40,8 +40,6 @@ "repository": {

"dotenv": "^16.4.7",
"telegraf": "^4.16.3",
"uuid": "^11.1.0"
"telegraf": "^4.16.3"
},
"devDependencies": {
"@types/node": "^22.12.0",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",

@@ -48,0 +46,0 @@ "tsx": "^4.19.0",

+70
-21

@@ -22,3 +22,7 @@ # cc-im

- **工具使用统计**:完成时显示工具调用次数和类型
- **生命周期通知**:服务启动/关闭时通知活跃用户
- **模型切换**:支持按用户和按话题粒度切换模型
- **轮次追踪**:累计对话轮次,上下文过长时自动提醒压缩
- **生命周期通知**:服务启动/关闭时通知活跃用户(含版本信息和运行时长)
- **守护进程模式**:支持 `-d` 后台运行和 `stop` 停止
- **日志等级配置**:支持 DEBUG/INFO/WARN/ERROR 四级日志

@@ -91,5 +95,17 @@ ## 快速开始

pnpm build # 编译
pnpm start # 生产模式
pnpm start # 生产模式(前台)
```
### 守护进程模式
```bash
# 后台启动
cc-im -d
# 停止服务
cc-im stop
```
日志输出到 `~/.cc-im/logs/daemon.log`。
## 命令列表

@@ -107,19 +123,10 @@

| `/status` | 查看当前会话状态 |
| `/model [name]` | 查看或切换模型 |
| `/model [name]` | 查看或切换模型(按用户/话题粒度) |
| `/doctor` | 运行 Claude 诊断 |
| `/compact [topic]` | 压缩当前对话上下文 |
| `/todos` | 查看待办事项 |
| `/history [page]` | 查看当前会话的对话历史 |
| `/threads` | 列出所有话题会话(飞书) |
| `/allow` 或 `/y` | 允许权限请求(按钮不可用时的备选) |
| `/deny` 或 `/n` | 拒绝权限请求(按钮不可用时的备选) |
### 权限相关命令
当 `CLAUDE_SKIP_PERMISSIONS=false`(默认)时,Claude Code 执行敏感操作(如 Bash 命令、写文件)会弹出权限确认卡片:
| 命令 | 说明 |
|------|------|
| `/allow` 或 `/y` | 允许当前待确认的操作 |
| `/deny` 或 `/n` | 拒绝当前待确认的操作 |
| `/allowall` | 允许所有待确认的操作 |
| `/pending` | 查看当前待确认的操作列表 |
## 环境变量

@@ -137,5 +144,7 @@

| `CLAUDE_SKIP_PERMISSIONS` | 跳过权限检查(生产环境建议 `false`) | `false` |
| `CLAUDE_TIMEOUT_MS` | 执行超时(毫秒) | `300000`(5分钟) |
| `CLAUDE_TIMEOUT_MS` | 执行超时(毫秒) | `600000`(10分钟) |
| `CLAUDE_MODEL` | 默认模型(如 `sonnet`、`opus`、`haiku`) | 空(由 Claude Code 决定) |
| `HOOK_SERVER_PORT` | 权限确认 Hook 服务端口 | `18900` |
| `LOG_DIR` | 日志文件存储目录 | `~/.cc-im/logs` |
| `LOG_LEVEL` | 日志等级(`DEBUG`/`INFO`/`WARN`/`ERROR`) | `DEBUG` |

@@ -153,2 +162,4 @@ ### 白名单用户 ID 格式

{
"feishuAppId": "",
"feishuAppSecret": "",
"telegramBotToken": "your_bot_token",

@@ -160,4 +171,7 @@ "allowedUserIds": ["123456789"],

"claudeSkipPermissions": false,
"claudeTimeoutMs": 300000,
"logDir": "/var/log/cc-im"
"claudeTimeoutMs": 600000,
"claudeModel": "sonnet",
"hookPort": 18900,
"logDir": "/var/log/cc-im",
"logLevel": "INFO"
}

@@ -185,2 +199,32 @@ ```

### 配置 Claude CLI Hook
**必须**:在 Claude CLI 配置文件中添加 PreToolUse hook,使权限确认功能正常工作。
编辑 `~/.claude/settings.json`,在 `hooks` 中添加:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit",
"hooks": [
{
"type": "command",
"command": "<your-project-path>/dist/hook/hook-script.js"
}
]
}
]
}
}
```
将 `<your-project-path>` 替换为实际的项目路径(使用绝对路径)。hook 脚本需要执行权限:`chmod +x dist/hook/hook-script.js`
配置修改后需要完全退出 Claude Code 会话(`exit`)并重新启动才能生效。
### 工作流程
当 `CLAUDE_SKIP_PERMISSIONS=false` 时,系统会通过 PreToolUse Hook 拦截敏感操作:

@@ -190,4 +234,4 @@

2. Hook 脚本将请求发送到权限确认服务(端口由 `HOOK_SERVER_PORT` 指定)
3. 服务向用户发送权限确认卡片/消息
4. 用户回复 `/allow` 或 `/deny`
3. 服务向用户发送权限确认卡片
4. 用户点击卡片上的"允许"或"拒绝"按钮
5. 决定结果返回给 Claude Code,继续或中止操作

@@ -207,3 +251,3 @@

├── sanitize.ts # 日志脱敏规则
├── cli.ts # CLI 入口
├── cli.ts # CLI 入口(前台/守护进程/停止)
├── access/

@@ -232,2 +276,7 @@ │ └── access-control.ts # 白名单访问控制

│ ├── active-chats.ts # 活跃聊天记录(生命周期通知)
│ ├── claude-task.ts # 共享 Claude 任务执行层(节流、统计、竞态保护)
│ ├── history.ts # 会话历史读取与分页
│ ├── message-dedup.ts # 消息去重(飞书重复事件过滤)
│ ├── retry.ts # 通用重试工具
│ ├── task-cleanup.ts # 超时任务自动清理
│ ├── types.ts # 共享类型定义

@@ -234,0 +283,0 @@ │ └── utils.ts # 共享工具函数

Sorry, the diff of this file is not supported yet