| 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); | ||
| } |
@@ -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 @@ } |
+58
-9
| #!/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(); | ||
| } |
+58
-73
@@ -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 @@ */ |
+7
-32
| 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, |
+241
-338
@@ -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() }); | ||
| }); | ||
| }); | ||
| } |
+40
-35
| 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) { |
+8
-1
@@ -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'); | ||
| } | ||
| } |
+94
-22
@@ -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 @@ } |
+2
-4
| { | ||
| "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
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
187630
10.16%3
-25%5
-16.67%35
20.69%4482
11.8%283
20.94%44
18.92%8
33.33%- Removed
- Removed