| #!/usr/bin/env node | ||
| /** | ||
| * Claude Code Hook 脚本 — watch 通知 | ||
| * | ||
| * 适用于 PostToolUse / Stop / SubagentStart / SubagentStop 等事件。 | ||
| * 从 stdin 读取事件数据,POST 到 cc-im 服务端的 /watch-notify 端点。 | ||
| * | ||
| * 关键原则:永远不阻塞 Claude Code,所有错误静默处理,始终 exit 0。 | ||
| * | ||
| * 环境变量: | ||
| * CC_IM_HOOK_PORT - 本地服务端口(默认 18900) | ||
| * | ||
| * stdin: JSON { hook_event_name, cwd, tool_name, tool_input, tool_response, last_assistant_message, agent_type } | ||
| */ | ||
| import { request } from 'node:http'; | ||
| // 定义在本文件而非 constants.ts,因为 watch-script 作为独立 hook 脚本由 Claude Code 直接执行, | ||
| // 需要最小化依赖以保持快速启动。 | ||
| const WATCH_NOTIFY_TIMEOUT_MS = 2000; | ||
| function readStdin() { | ||
| return new Promise((resolve) => { | ||
| let data = ''; | ||
| process.stdin.setEncoding('utf-8'); | ||
| process.stdin.on('data', (chunk) => { data += chunk; }); | ||
| process.stdin.on('end', () => resolve(data)); | ||
| // If stdin is empty/closed immediately | ||
| setTimeout(() => resolve(data), 100); | ||
| }); | ||
| } | ||
| function httpPost(port, path, body) { | ||
| return new Promise((resolve) => { | ||
| const payload = JSON.stringify(body); | ||
| const req = request({ | ||
| hostname: '127.0.0.1', | ||
| port, | ||
| path, | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'Content-Length': Buffer.byteLength(payload), | ||
| }, | ||
| timeout: WATCH_NOTIFY_TIMEOUT_MS, | ||
| }, (res) => { | ||
| // Drain response | ||
| res.on('data', () => { }); | ||
| res.on('end', () => resolve()); | ||
| }); | ||
| req.on('error', () => resolve()); | ||
| req.on('timeout', () => { | ||
| req.destroy(); | ||
| resolve(); | ||
| }); | ||
| req.write(payload); | ||
| req.end(); | ||
| }); | ||
| } | ||
| async function main() { | ||
| const port = parseInt(process.env.CC_IM_HOOK_PORT ?? '18900', 10); | ||
| let input; | ||
| try { | ||
| const raw = await readStdin(); | ||
| input = raw.trim() ? JSON.parse(raw) : {}; | ||
| } | ||
| catch { | ||
| // 无法解析输入,静默退出 | ||
| process.exit(0); | ||
| } | ||
| const eventName = input.hook_event_name; | ||
| if (!eventName) { | ||
| process.exit(0); | ||
| } | ||
| await httpPost(port, '/watch-notify', { | ||
| eventName, | ||
| sessionId: input.session_id, | ||
| cwd: input.cwd ?? process.cwd(), | ||
| toolName: input.tool_name, | ||
| toolInput: input.tool_input, | ||
| toolResponse: input.tool_response, | ||
| lastAssistantMessage: input.last_assistant_message, | ||
| agentType: input.agent_type, | ||
| }); | ||
| process.exit(0); | ||
| } | ||
| /* c8 ignore next 3 */ | ||
| const isDirectRun = process.argv[1]?.endsWith('watch-script.js'); | ||
| if (isDirectRun) | ||
| main(); | ||
| export { main, readStdin, httpPost }; |
| /** | ||
| * Watch 监控管理模块 | ||
| * | ||
| * 管理 watchMap(workDir -> WatchEntry[]),提供注册/注销/查询/格式化功能。 | ||
| * 用于让用户通过聊天平台实时监控终端 Claude Code 的活动。 | ||
| */ | ||
| /** 每个级别订阅的事件集合 */ | ||
| const LEVEL_EVENTS = { | ||
| stop: new Set(['Stop']), | ||
| tool: new Set(['PostToolUse', 'Stop']), | ||
| full: new Set(['PostToolUse', 'Stop', 'SubagentStart', 'SubagentStop']), | ||
| }; | ||
| /** Stop 消息预览最大字符数 */ | ||
| const STOP_PREVIEW_MAX_LENGTH = 200; | ||
| /** workDir -> WatchEntry[] */ | ||
| const watchMap = new Map(); | ||
| /** | ||
| * 注册监控。同一 chatId+threadId 更新 level 而不重复注册。 | ||
| */ | ||
| export function registerWatch(workDir, entry) { | ||
| const entries = watchMap.get(workDir) ?? []; | ||
| const threadId = entry.threadCtx?.threadId; | ||
| const existing = entries.find((e) => e.chatId === entry.chatId && e.threadCtx?.threadId === threadId); | ||
| if (existing) { | ||
| existing.level = entry.level; | ||
| existing.platform = entry.platform; | ||
| existing.threadCtx = entry.threadCtx; | ||
| // 保留已有的 mutedSessions | ||
| } | ||
| else { | ||
| entries.push({ ...entry, mutedSessions: entry.mutedSessions ?? new Set() }); | ||
| } | ||
| watchMap.set(workDir, entries); | ||
| } | ||
| /** | ||
| * 注销监控。按 chatId + threadId 精确匹配移除。 | ||
| */ | ||
| export function unregisterWatch(workDir, chatId, threadId) { | ||
| const entries = watchMap.get(workDir); | ||
| if (!entries) | ||
| return false; | ||
| const idx = entries.findIndex((e) => e.chatId === chatId && e.threadCtx?.threadId === threadId); | ||
| if (idx === -1) | ||
| return false; | ||
| entries.splice(idx, 1); | ||
| if (entries.length === 0) { | ||
| watchMap.delete(workDir); | ||
| } | ||
| return true; | ||
| } | ||
| /** | ||
| * 按 cwd 前缀匹配 + 可选事件级别过滤 + 可选 sessionId mute 过滤。 | ||
| * | ||
| * 匹配规则:cwd === workDir || cwd.startsWith(workDir + '/') | ||
| */ | ||
| export function getWatchEntries(cwd, eventName, sessionId) { | ||
| const results = []; | ||
| for (const [workDir, entries] of watchMap) { | ||
| if (cwd === workDir || cwd.startsWith(workDir + '/')) { | ||
| for (const entry of entries) { | ||
| if (eventName && !LEVEL_EVENTS[entry.level].has(eventName)) { | ||
| continue; | ||
| } | ||
| if (sessionId && entry.mutedSessions?.size && [...entry.mutedSessions].some(s => sessionId.endsWith(s))) { | ||
| continue; | ||
| } | ||
| results.push(entry); | ||
| } | ||
| } | ||
| } | ||
| return results; | ||
| } | ||
| /** | ||
| * 查询某聊天的监控状态。返回匹配的 WatchEntry 或 undefined。 | ||
| */ | ||
| export function getWatchStatus(chatId, threadId) { | ||
| for (const [workDir, entries] of watchMap) { | ||
| const entry = entries.find((e) => e.chatId === chatId && e.threadCtx?.threadId === threadId); | ||
| if (entry) { | ||
| return { ...entry, workDir }; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| /** | ||
| * 屏蔽指定会话的通知。 | ||
| */ | ||
| export function muteSession(workDir, chatId, sessionSuffix, threadId) { | ||
| const entries = watchMap.get(workDir); | ||
| if (!entries) | ||
| return false; | ||
| const entry = entries.find(e => e.chatId === chatId && e.threadCtx?.threadId === threadId); | ||
| if (!entry) | ||
| return false; | ||
| if (!entry.mutedSessions) | ||
| entry.mutedSessions = new Set(); | ||
| entry.mutedSessions.add(sessionSuffix); | ||
| return true; | ||
| } | ||
| /** | ||
| * 取消屏蔽指定会话的通知。 | ||
| */ | ||
| export function unmuteSession(workDir, chatId, sessionSuffix, threadId) { | ||
| const entries = watchMap.get(workDir); | ||
| if (!entries) | ||
| return false; | ||
| const entry = entries.find(e => e.chatId === chatId && e.threadCtx?.threadId === threadId); | ||
| if (!entry || !entry.mutedSessions) | ||
| return false; | ||
| return entry.mutedSessions.delete(sessionSuffix); | ||
| } | ||
| /** | ||
| * 清空所有监控(测试用) | ||
| */ | ||
| export function clearAllWatches() { | ||
| watchMap.clear(); | ||
| } | ||
| /** | ||
| * 格式化通知消息。 | ||
| * | ||
| * - PostToolUse: `🔧 Bash: npm test` / `🔧 Write: src/index.ts` | ||
| * - Stop: `✅ Claude 已完成\n> 前 200 字预览...` | ||
| * - SubagentStart: `🤖 子代理启动: Explore` | ||
| * - SubagentStop: `🤖 子代理完成: Explore` | ||
| */ | ||
| export function formatWatchNotify(data) { | ||
| const sid = data.sessionId ? `[${data.sessionId.slice(-4)}] ` : ''; | ||
| switch (data.eventName) { | ||
| case 'PostToolUse': { | ||
| const tool = data.toolName ?? 'unknown'; | ||
| const summary = getToolSummary(tool, data.toolInput); | ||
| return summary ? `🔧 ${sid}${tool}: ${summary}` : `🔧 ${sid}${tool}`; | ||
| } | ||
| case 'Stop': { | ||
| const preview = data.lastAssistantMessage ?? ''; | ||
| if (!preview) | ||
| return `✅ ${sid}Claude 已完成`; | ||
| const truncated = preview.length > STOP_PREVIEW_MAX_LENGTH | ||
| ? preview.slice(0, STOP_PREVIEW_MAX_LENGTH) + '...' | ||
| : preview; | ||
| return `✅ ${sid}Claude 已完成\n> ${truncated}`; | ||
| } | ||
| case 'SubagentStart': | ||
| return `🤖 ${sid}子代理启动: ${data.agentType ?? 'unknown'}`; | ||
| case 'SubagentStop': | ||
| return `🤖 ${sid}子代理完成: ${data.agentType ?? 'unknown'}`; | ||
| default: | ||
| return `❓ ${sid}未知事件: ${data.eventName}`; | ||
| } | ||
| } | ||
| /** | ||
| * 提取工具参数摘要 | ||
| */ | ||
| function getToolSummary(toolName, toolInput) { | ||
| if (!toolInput) | ||
| return ''; | ||
| switch (toolName) { | ||
| case 'Bash': | ||
| return truncate(String(toolInput.command ?? ''), 100); | ||
| case 'Write': | ||
| case 'Edit': | ||
| case 'Read': | ||
| return truncate(String(toolInput.file_path ?? ''), 100); | ||
| case 'Glob': | ||
| case 'Grep': | ||
| return truncate(String(toolInput.pattern ?? ''), 100); | ||
| default: { | ||
| // 取第一个字符串类型的值作为摘要 | ||
| for (const val of Object.values(toolInput)) { | ||
| if (typeof val === 'string' && val.length > 0) { | ||
| return truncate(val, 100); | ||
| } | ||
| } | ||
| return ''; | ||
| } | ||
| } | ||
| } | ||
| function truncate(str, maxLength) { | ||
| return str.length > maxLength ? str.slice(0, maxLength) + '...' : str; | ||
| } |
@@ -40,2 +40,5 @@ import { spawn } from 'node:child_process'; | ||
| } | ||
| if (options?.skipPermissions) { | ||
| env.CC_IM_SKIP_PERMISSIONS = '1'; | ||
| } | ||
| if (options?.proxyUrl) { | ||
@@ -55,3 +58,3 @@ env.HTTPS_PROXY = options.proxyUrl; | ||
| let model = ''; | ||
| let toolStats = {}; | ||
| const toolStats = {}; | ||
| let timeoutHandle = null; | ||
@@ -58,0 +61,0 @@ const pendingToolInputs = new Map(); |
+126
-4
| #!/usr/bin/env node | ||
| import { join } from 'path'; | ||
| import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync, openSync, closeSync } from 'fs'; | ||
| import { spawn } from 'child_process'; | ||
| import { spawn, execSync } from 'child_process'; | ||
| import { fileURLToPath } from 'url'; | ||
| import { homedir } from 'os'; | ||
| import { createLogger } from './logger.js'; | ||
@@ -10,2 +11,5 @@ import { APP_HOME } from './constants.js'; | ||
| const LOG_DIR = join(APP_HOME, 'logs'); | ||
| const SERVICE_NAME = 'cc-im'; | ||
| const SERVICE_DIR = join(homedir(), '.config', 'systemd', 'user'); | ||
| const SERVICE_FILE = join(SERVICE_DIR, `${SERVICE_NAME}.service`); | ||
| const logger = createLogger('CLI'); | ||
@@ -33,2 +37,9 @@ function getPidFromFile() { | ||
| async function stop() { | ||
| // 优先停 systemd 服务 | ||
| if (isSystemdActive()) { | ||
| systemctl('stop', SERVICE_NAME); | ||
| logger.info('已停止 systemd 服务'); | ||
| return; | ||
| } | ||
| // 其次停守护进程 | ||
| const pid = getPidFromFile(); | ||
@@ -38,3 +49,3 @@ if (pid && isRunning(pid)) { | ||
| process.kill(pid); | ||
| logger.info(`已停止服务 (PID: ${pid})`); | ||
| logger.info(`已停止守护进程 (PID: ${pid})`); | ||
| } | ||
@@ -108,2 +119,104 @@ catch (e) { | ||
| } | ||
| function generateServiceContent() { | ||
| const scriptPath = fileURLToPath(import.meta.url); | ||
| // dist/cli.js -> project root | ||
| const projectDir = join(scriptPath, '..', '..'); | ||
| const nodeBinDir = join(process.execPath, '..'); | ||
| return `[Unit] | ||
| Description=CC-IM Bot Bridge Service | ||
| After=network.target | ||
| [Service] | ||
| Type=simple | ||
| WorkingDirectory=${projectDir} | ||
| ExecStart=${process.execPath} ${join(projectDir, 'dist', 'index.js')} | ||
| Restart=on-failure | ||
| RestartSec=5 | ||
| Environment=NODE_ENV=production | ||
| Environment=PATH=${nodeBinDir}:/usr/local/bin:/usr/bin:/bin | ||
| [Install] | ||
| WantedBy=default.target | ||
| `; | ||
| } | ||
| function systemctl(...args) { | ||
| execSync(`systemctl --user ${args.join(' ')}`, { stdio: 'inherit' }); | ||
| } | ||
| function systemctlQuery(...args) { | ||
| try { | ||
| return execSync(`systemctl --user ${args.join(' ')}`, { encoding: 'utf-8' }).trim(); | ||
| } | ||
| catch { | ||
| return ''; | ||
| } | ||
| } | ||
| function isSystemdActive() { | ||
| return systemctlQuery('is-active', SERVICE_NAME) === 'active'; | ||
| } | ||
| function isSystemdEnabled() { | ||
| return systemctlQuery('is-enabled', SERVICE_NAME) === 'enabled'; | ||
| } | ||
| function install() { | ||
| mkdirSync(SERVICE_DIR, { recursive: true }); | ||
| writeFileSync(SERVICE_FILE, generateServiceContent()); | ||
| logger.info(`服务文件已写入: ${SERVICE_FILE}`); | ||
| systemctl('daemon-reload'); | ||
| systemctl('enable', SERVICE_NAME); | ||
| logger.info('开机自启已启用'); | ||
| // enable-linger 让用户服务在未登录时也能运行 | ||
| try { | ||
| execSync('loginctl enable-linger', { stdio: 'inherit' }); | ||
| logger.info('linger 已启用(服务可在未登录时运行)'); | ||
| } | ||
| catch { | ||
| logger.warn('enable-linger 失败,服务可能需要用户登录后才会启动'); | ||
| } | ||
| systemctl('start', SERVICE_NAME); | ||
| systemctl('status', SERVICE_NAME); | ||
| } | ||
| function uninstall() { | ||
| try { | ||
| systemctl('stop', SERVICE_NAME); | ||
| } | ||
| catch { /* 服务可能没在运行 */ } | ||
| try { | ||
| systemctl('disable', SERVICE_NAME); | ||
| } | ||
| catch { /* 服务可能没启用 */ } | ||
| if (existsSync(SERVICE_FILE)) { | ||
| unlinkSync(SERVICE_FILE); | ||
| logger.info(`服务文件已删除: ${SERVICE_FILE}`); | ||
| } | ||
| systemctl('daemon-reload'); | ||
| logger.info('开机自启已卸载'); | ||
| } | ||
| function status() { | ||
| if (isSystemdActive()) { | ||
| logger.info(`运行方式: systemd(开机自启: ${isSystemdEnabled() ? '已启用' : '未启用'})`); | ||
| systemctl('status', SERVICE_NAME, '--no-pager'); | ||
| return; | ||
| } | ||
| const pid = getPidFromFile(); | ||
| if (pid && isRunning(pid)) { | ||
| let uptime = ''; | ||
| let mem = ''; | ||
| try { | ||
| uptime = execSync(`ps -o etime= -p ${pid}`, { encoding: 'utf-8' }).trim(); | ||
| const rss = parseInt(execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' }).trim(), 10); | ||
| mem = `${(rss / 1024).toFixed(1)} MB`; | ||
| } | ||
| catch { /* ignore */ } | ||
| logger.info('运行方式: 守护进程(cc-im -d)'); | ||
| logger.info(`PID: ${pid}`); | ||
| if (uptime) | ||
| logger.info(`运行时间: ${uptime}`); | ||
| if (mem) | ||
| logger.info(`内存占用: ${mem}`); | ||
| return; | ||
| } | ||
| logger.info('服务未运行'); | ||
| if (isSystemdEnabled()) { | ||
| logger.info('systemd 开机自启已启用,可用 cc-im install 重新启动'); | ||
| } | ||
| } | ||
| function parseArgs() { | ||
@@ -114,4 +227,4 @@ const args = process.argv.slice(2); | ||
| for (const arg of args) { | ||
| if (arg === 'stop') { | ||
| command = 'stop'; | ||
| if (arg === 'stop' || arg === 'install' || arg === 'uninstall' || arg === 'status') { | ||
| command = arg; | ||
| } | ||
@@ -151,2 +264,11 @@ else if (arg === '-d' || arg === '--daemon') { | ||
| } | ||
| else if (command === 'status') { | ||
| status(); | ||
| } | ||
| else if (command === 'install') { | ||
| install(); | ||
| } | ||
| else if (command === 'uninstall') { | ||
| uninstall(); | ||
| } | ||
| else if (daemon) { | ||
@@ -153,0 +275,0 @@ startDaemon(); |
@@ -9,2 +9,3 @@ import { resolveLatestPermission, getPendingCount } from '../hook/permission-server.js'; | ||
| import { getHistory, formatHistoryPage, getSessionList, formatSessionList } from '../shared/history.js'; | ||
| import { registerWatch, unregisterWatch, getWatchStatus, muteSession, unmuteSession } from '../hook/watch.js'; | ||
| /** | ||
@@ -66,2 +67,5 @@ * 共享的命令处理器 | ||
| } | ||
| if (trimmed === '/watch' || trimmed.startsWith('/watch ')) { | ||
| return this.handleWatch(chatId, userId, trimmed.slice(6).trim(), platform, threadCtx); | ||
| } | ||
| // 仅终端可用的命令 | ||
@@ -98,2 +102,3 @@ const cmdName = trimmed.split(/\s+/)[0]; | ||
| '/resume [序号] - 浏览/恢复历史会话', | ||
| '/watch [级别] - 监控终端 Claude Code 状态', | ||
| threadsCmd, | ||
@@ -193,3 +198,3 @@ stopCmd, | ||
| : this.deps.sessionManager.getWorkDir(userId); | ||
| const lines = dirs.map((d, i) => (d === current ? `▶ ${i + 1}. ${d}` : ` ${i + 1}. ${d}`)); | ||
| const lines = dirs.map((d, i) => (d === current ? `${i + 1}. ▶ ${d}` : `${i + 1}. ${d}`)); | ||
| await this.deps.sender.sendTextReply(chatId, `Claude Code 工作区列表:\n${lines.join('\n')}\n\n使用 /cd <序号> 或 /cd <路径> 切换`, threadCtx); | ||
@@ -399,3 +404,3 @@ } | ||
| const index = parseInt(args, 10) - 1; | ||
| if (isNaN(index) || index < 0 || index >= listResult.data.length) { | ||
| if (Number.isNaN(index) || index < 0 || index >= listResult.data.length) { | ||
| await this.deps.sender.sendTextReply(chatId, `无效的序号 ${args},共 ${listResult.data.length} 个会话。`, threadCtx); | ||
@@ -414,2 +419,65 @@ return true; | ||
| /** | ||
| * 处理 /watch 命令 - 监控终端 Claude Code 状态 | ||
| */ | ||
| async handleWatch(chatId, userId, args, platform, threadCtx) { | ||
| const workDir = threadCtx | ||
| ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId) | ||
| : this.deps.sessionManager.getWorkDir(userId); | ||
| const threadId = threadCtx?.threadId; | ||
| if (!args) { | ||
| const status = getWatchStatus(chatId, threadId); | ||
| if (status) { | ||
| const mutedList = status.mutedSessions?.size | ||
| ? `\n屏蔽: ${[...status.mutedSessions].map(s => `[${s}]`).join(' ')}` | ||
| : ''; | ||
| await this.deps.sender.sendTextReply(chatId, `📡 监控中 [${status.level}]\n工作区: ${status.workDir}${mutedList}`, threadCtx); | ||
| } | ||
| else { | ||
| await this.deps.sender.sendTextReply(chatId, '📡 未开启监控\n\n使用 /watch <级别> 开启:\n stop - 仅完成事件\n tool - 工具调用 + 完成\n full - 全量(含子代理)', threadCtx); | ||
| } | ||
| return true; | ||
| } | ||
| if (args === 'off') { | ||
| unregisterWatch(workDir, chatId, threadId); | ||
| await this.deps.sender.sendTextReply(chatId, '📡 已关闭监控', threadCtx); | ||
| return true; | ||
| } | ||
| if (args.startsWith('mute ')) { | ||
| const sid = args.slice(5).trim(); | ||
| if (!sid) { | ||
| await this.deps.sender.sendTextReply(chatId, '请指定要屏蔽的会话 ID 后 4 位,如: /watch mute a1b2', threadCtx); | ||
| return true; | ||
| } | ||
| if (muteSession(workDir, chatId, sid, threadId)) { | ||
| await this.deps.sender.sendTextReply(chatId, `📡 已屏蔽会话 [${sid}]`, threadCtx); | ||
| } | ||
| else { | ||
| await this.deps.sender.sendTextReply(chatId, '未找到活跃的监控,请先 /watch <级别> 开启。', threadCtx); | ||
| } | ||
| return true; | ||
| } | ||
| if (args.startsWith('unmute ')) { | ||
| const sid = args.slice(7).trim(); | ||
| if (!sid) { | ||
| await this.deps.sender.sendTextReply(chatId, '请指定要取消屏蔽的会话 ID,如: /watch unmute a1b2', threadCtx); | ||
| return true; | ||
| } | ||
| if (unmuteSession(workDir, chatId, sid, threadId)) { | ||
| await this.deps.sender.sendTextReply(chatId, `📡 已取消屏蔽会话 [${sid}]`, threadCtx); | ||
| } | ||
| else { | ||
| await this.deps.sender.sendTextReply(chatId, '未找到该屏蔽记录。', threadCtx); | ||
| } | ||
| return true; | ||
| } | ||
| const validLevels = ['stop', 'tool', 'full']; | ||
| if (!validLevels.includes(args)) { | ||
| await this.deps.sender.sendTextReply(chatId, `无效的监控级别: ${args}\n可选: stop / tool / full / off / mute <id> / unmute <id>`, threadCtx); | ||
| return true; | ||
| } | ||
| registerWatch(workDir, { chatId, platform, threadCtx, level: args }); | ||
| await this.deps.sender.sendTextReply(chatId, `📡 已开启监控 [${args}]\n工作区: ${workDir}\n\n在此工作区启动的终端 Claude Code 的事件将推送到此处。\n使用 /watch off 关闭`, threadCtx); | ||
| return true; | ||
| } | ||
| /** | ||
| * 处理 /threads 命令 - 列出所有话题会话 | ||
@@ -416,0 +484,0 @@ */ |
+4
-2
@@ -74,2 +74,3 @@ try { | ||
| const wecomBotSecret = process.env.WECOM_BOT_SECRET ?? file.wecomBotSecret ?? ''; | ||
| const wecomBotName = process.env.WECOM_BOT_NAME ?? file.wecomBotName; | ||
| const allowedUserIds = process.env.ALLOWED_USER_IDS !== undefined | ||
@@ -98,3 +99,3 @@ ? parseCommaSeparated(process.env.ALLOWED_USER_IDS) | ||
| } | ||
| catch (err) { | ||
| catch (_err) { | ||
| throw new Error(`Claude CLI 不可访问或不可执行: ${claudeCliPath}\n` + | ||
@@ -112,3 +113,3 @@ `请检查:\n` + | ||
| } | ||
| catch (err) { | ||
| catch (_err) { | ||
| throw new Error(`Claude CLI 在 PATH 中未找到: ${claudeCliPath}\n` + | ||
@@ -134,2 +135,3 @@ `请检查:\n` + | ||
| wecomBotSecret, | ||
| wecomBotName, | ||
| allowedUserIds, | ||
@@ -136,0 +138,0 @@ claudeCliPath, |
+24
-0
@@ -109,1 +109,25 @@ import { join } from 'node:path'; | ||
| export const MAX_WECOM_MESSAGE_LENGTH = 4000; | ||
| /** | ||
| * 请求队列最大排队数 | ||
| */ | ||
| export const MAX_QUEUE_SIZE = 3; | ||
| /** | ||
| * 消息去重最大容量 | ||
| */ | ||
| export const MAX_DEDUP_SIZE = 1000; | ||
| /** | ||
| * 任务超时时间(毫秒),超时自动清理 | ||
| */ | ||
| export const TASK_TIMEOUT_MS = 30 * 60 * 1000; | ||
| /** | ||
| * 任务清理检查间隔(毫秒) | ||
| */ | ||
| export const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; | ||
| /** | ||
| * Telegram 消息发送最大重试次数 | ||
| */ | ||
| export const TELEGRAM_MAX_RETRIES = 3; | ||
| /** | ||
| * Telegram 限频最大等待秒数 | ||
| */ | ||
| export const TELEGRAM_RATE_LIMIT_MAX_WAIT_SEC = 60; |
@@ -9,2 +9,3 @@ import { join } from 'node:path'; | ||
| import { registerFeishuPermissionSender, handlePermissionAction } from './permission-handler.js'; | ||
| import { registerWatchSender } from '../hook/permission-server.js'; | ||
| import { CommandHandler } from '../commands/handler.js'; | ||
@@ -76,2 +77,6 @@ import { safeStringify } from '../shared/utils.js'; | ||
| registerFeishuPermissionSender(); | ||
| // Register feishu watch sender | ||
| registerWatchSender('feishu', { | ||
| sendWatchNotify: (chatId, text, threadCtx) => sendTextReply(chatId, text, threadCtx), | ||
| }); | ||
| // ─── 内部函数(闭包访问 runningTasks 等) ─── | ||
@@ -78,0 +83,0 @@ async function routeToThread(userId, chatId, threadId, rootMessageId, text, mentionedBot) { |
@@ -9,2 +9,3 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; | ||
| const HOOK_MATCHER = 'Bash|Write|Edit'; | ||
| const WATCH_EVENTS = ['PostToolUse', 'Stop', 'SubagentStart', 'SubagentStop']; | ||
| /** | ||
@@ -20,2 +21,10 @@ * 获取 hook-script.js 的绝对路径。 | ||
| } | ||
| /** | ||
| * 获取 watch-script.js 的绝对路径。 | ||
| */ | ||
| function getWatchScriptPath() { | ||
| const thisFile = fileURLToPath(import.meta.url); | ||
| const projectRoot = dirname(dirname(dirname(thisFile))); | ||
| return join(projectRoot, 'dist', 'hook', 'watch-script.js'); | ||
| } | ||
| /** 判断一个 hook command 是否指向本项目的 hook-script.js */ | ||
@@ -79,2 +88,18 @@ function isOurHook(command, projectHookPath) { | ||
| hooks.PreToolUse = preToolUse; | ||
| // Watch hook 注册(PostToolUse, Stop, SubagentStart, SubagentStop) | ||
| const watchScriptPath = getWatchScriptPath(); | ||
| if (existsSync(watchScriptPath)) { | ||
| for (const eventName of WATCH_EVENTS) { | ||
| const eventHooks = (hooks[eventName] ?? []); | ||
| const hasOurHook = eventHooks.some(entry => entry.hooks?.some(h => isOurHook(h.command, watchScriptPath))); | ||
| if (!hasOurHook) { | ||
| eventHooks.push({ | ||
| matcher: '', | ||
| hooks: [{ type: 'command', command: watchScriptPath }], | ||
| }); | ||
| hooks[eventName] = eventHooks; | ||
| needsWrite = true; | ||
| } | ||
| } | ||
| } | ||
| settings.hooks = hooks; | ||
@@ -81,0 +106,0 @@ try { |
@@ -94,2 +94,9 @@ #!/usr/bin/env node | ||
| } | ||
| // Skip permissions mode - auto-allow all tools | ||
| // 新版 Claude Code 的 --dangerously-skip-permissions 不再跳过 hooks, | ||
| // 需要通过环境变量让 hook 脚本自行放行 | ||
| if (process.env.CC_IM_SKIP_PERMISSIONS === '1') { | ||
| process.stdout.write(JSON.stringify({ permissionDecision: 'allow' })); | ||
| process.exit(HOOK_EXIT_CODES.SUCCESS); | ||
| } | ||
| const threadRootMsgId = process.env.CC_IM_THREAD_ROOT_MSG_ID; | ||
@@ -96,0 +103,0 @@ const threadId = process.env.CC_IM_THREAD_ID; |
| import { createServer } from 'node:http'; | ||
| import { createLogger } from '../logger.js'; | ||
| import { PERMISSION_REQUEST_TIMEOUT_MS, MAX_BODY_SIZE } from '../constants.js'; | ||
| import { getWatchEntries, formatWatchNotify, } from './watch.js'; | ||
| const log = createLogger('PermissionServer'); | ||
@@ -9,2 +10,6 @@ const senders = new Map(); | ||
| } | ||
| const watchSenders = new Map(); | ||
| export function registerWatchSender(platform, s) { | ||
| watchSenders.set(platform, s); | ||
| } | ||
| const pendingRequests = new Map(); | ||
@@ -130,3 +135,2 @@ // 反向索引:chatId → Set<requestId>,O(1) 查询 | ||
| const id = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; | ||
| let savedMessageId = ''; | ||
| const decision = await new Promise((resolve) => { | ||
@@ -146,3 +150,2 @@ if (!platformSender) { | ||
| platformSender.sendPermissionCard(chatId, id, toolName, toolInput ?? {}, threadCtx).then((messageId) => { | ||
| savedMessageId = messageId; | ||
| // 超时定时器在卡片发送成功后才启动,确保用户有完整的决策时间 | ||
@@ -203,2 +206,39 @@ const timeout = setTimeout(() => { | ||
| } | ||
| if (req.method === 'POST' && url.pathname === '/watch-notify') { | ||
| try { | ||
| const body = JSON.parse(await readBody(req)); | ||
| const { cwd, eventName, sessionId } = body; | ||
| if (!cwd || !eventName) { | ||
| sendJson(res, 400, { error: 'cwd and eventName required' }); | ||
| return; | ||
| } | ||
| const validEvents = ['PostToolUse', 'Stop', 'SubagentStart', 'SubagentStop']; | ||
| if (!validEvents.includes(eventName)) { | ||
| sendJson(res, 200, { sent: 0 }); | ||
| return; | ||
| } | ||
| const entries = getWatchEntries(cwd, eventName, sessionId); | ||
| if (entries.length === 0) { | ||
| sendJson(res, 200, { sent: 0 }); | ||
| return; | ||
| } | ||
| const message = formatWatchNotify(body); | ||
| let sent = 0; | ||
| for (const entry of entries) { | ||
| const sender = watchSenders.get(entry.platform); | ||
| if (sender) { | ||
| sender.sendWatchNotify(entry.chatId, message, entry.threadCtx).catch((err) => { | ||
| log.warn(`Failed to send watch notify to ${entry.chatId}:`, err); | ||
| }); | ||
| sent++; | ||
| } | ||
| } | ||
| sendJson(res, 200, { sent }); | ||
| } | ||
| catch (err) { | ||
| log.error('Error handling watch notify:', err); | ||
| sendJson(res, 500, { error: 'Internal error' }); | ||
| } | ||
| return; | ||
| } | ||
| if (req.method === 'GET' && url.pathname === '/health') { | ||
@@ -205,0 +245,0 @@ sendJson(res, 200, { status: 'ok', pending: pendingRequests.size }); |
+15
-10
@@ -49,2 +49,9 @@ import { loadConfig } from './config.js'; | ||
| export async function main() { | ||
| process.on('unhandledRejection', (reason) => { | ||
| log.error('Unhandled rejection:', reason); | ||
| }); | ||
| process.on('uncaughtException', (err) => { | ||
| log.error('Uncaught exception:', err); | ||
| process.exit(1); | ||
| }); | ||
| const config = loadConfig(); | ||
@@ -64,8 +71,6 @@ initLogger(config.logDir, config.logLevel); | ||
| log.info(`Allowed base dirs: ${config.allowedBaseDirs.length} dirs configured`); | ||
| let permissionServer = null; | ||
| if (!config.claudeSkipPermissions) { | ||
| ensureHookConfigured(); | ||
| permissionServer = await startPermissionServer(config.hookPort); | ||
| log.info(`Permission hook server started on port ${permissionServer.port}`); | ||
| } | ||
| // Hook 注册和 HTTP 服务器始终启动(watch 功能不依赖权限设置) | ||
| ensureHookConfigured(); | ||
| const permissionServer = await startPermissionServer(config.hookPort); | ||
| log.info(`Hook server started on port ${permissionServer.port}${config.claudeSkipPermissions ? ' (permissions skipped, watch-only)' : ''}`); | ||
| // 创建共享的 SessionManager 单例 | ||
@@ -154,7 +159,7 @@ const sessionManager = new SessionManager(config.claudeWorkDir, config.allowedBaseDirs); | ||
| ].filter(Boolean).join('\n'); | ||
| sendLifecycleNotification(activeBots, startupMsg).catch(() => { }); | ||
| checkForUpdate(APP_VERSION).catch(() => { }); | ||
| sendLifecycleNotification(activeBots, startupMsg).catch((e) => log.warn('Startup notification failed:', e?.message ?? e)); | ||
| checkForUpdate(APP_VERSION).catch((e) => log.warn('Update check failed:', e?.message ?? e)); | ||
| const imageCleanupTimer = setInterval(() => { | ||
| cleanOldImages().then((n) => { if (n > 0) | ||
| log.info(`Cleaned ${n} old image(s)`); }).catch(() => { }); | ||
| log.info(`Cleaned ${n} old image(s)`); }).catch((e) => log.warn('Image cleanup failed:', e?.message ?? e)); | ||
| }, 10 * 60 * 1000); | ||
@@ -189,3 +194,3 @@ imageCleanupTimer.unref(); | ||
| } | ||
| permissionServer?.close(); | ||
| permissionServer.close(); | ||
| // 持久化会话和活跃聊天数据 | ||
@@ -192,0 +197,0 @@ sessionManager.destroy(); |
| import { createLogger } from '../logger.js'; | ||
| import { MAX_QUEUE_SIZE } from '../constants.js'; | ||
| const log = createLogger('Queue'); | ||
| const MAX_QUEUE_SIZE = 3; | ||
| export class RequestQueue { | ||
@@ -5,0 +5,0 @@ queues = new Map(); |
@@ -1,3 +0,2 @@ | ||
| import { DEDUP_TTL_MS } from '../constants.js'; | ||
| const MAX_DEDUP_SIZE = 1000; | ||
| import { DEDUP_TTL_MS, MAX_DEDUP_SIZE } from '../constants.js'; | ||
| /** | ||
@@ -4,0 +3,0 @@ * 消息去重器 |
@@ -0,5 +1,4 @@ | ||
| import { TASK_TIMEOUT_MS, TASK_CLEANUP_INTERVAL_MS } from '../constants.js'; | ||
| import { createLogger } from '../logger.js'; | ||
| const log = createLogger('TaskCleanup'); | ||
| const TASK_TIMEOUT_MS = 30 * 60 * 1000; | ||
| const TASK_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; | ||
| /** | ||
@@ -6,0 +5,0 @@ * 启动定期任务清理(30分钟超时,每10分钟检查一次) |
@@ -7,6 +7,6 @@ import { join } from 'node:path'; | ||
| import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendPermissionMessage, updatePermissionMessage, startTypingLoop, sendImageReply } from './message-sender.js'; | ||
| import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js'; | ||
| import { registerPermissionSender, registerWatchSender, resolvePermissionById } from '../hook/permission-server.js'; | ||
| import { CommandHandler } from '../commands/handler.js'; | ||
| import { getBotUsername } from './client.js'; | ||
| import { runClaudeTask } from '../shared/claude-task.js'; | ||
| import { runClaudeTask, } from '../shared/claude-task.js'; | ||
| import { startTaskCleanup } from '../shared/task-cleanup.js'; | ||
@@ -49,2 +49,6 @@ import { MessageDedup } from '../shared/message-dedup.js'; | ||
| }); | ||
| // Register telegram watch sender | ||
| registerWatchSender('telegram', { | ||
| sendWatchNotify: (chatId, text) => sendTextReply(chatId, text), | ||
| }); | ||
| async function handleClaudeRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId) { | ||
@@ -51,0 +55,0 @@ const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined; |
@@ -5,7 +5,5 @@ import { getBot } from './client.js'; | ||
| import { splitLongContent, buildInputSummary, truncateText } from '../shared/utils.js'; | ||
| import { MAX_TELEGRAM_MESSAGE_LENGTH } from '../constants.js'; | ||
| import { MAX_TELEGRAM_MESSAGE_LENGTH, TELEGRAM_MAX_RETRIES, TELEGRAM_RATE_LIMIT_MAX_WAIT_SEC } from '../constants.js'; | ||
| import { withRetry } from '../shared/retry.js'; | ||
| const log = createLogger('TgSender'); | ||
| const MAX_RETRIES = 3; | ||
| const RATE_LIMIT_MAX_WAIT_SEC = 60; // Cap retry wait time to avoid excessive blocking | ||
| const COOLDOWN_CLEANUP_INTERVAL_MS = 3600000; // Clean up cooldown map every hour | ||
@@ -64,5 +62,5 @@ const TYPING_INTERVAL_MS = 4000; // Telegram typing status expires after ~5s | ||
| return withRetry(fn, { | ||
| maxRetries: MAX_RETRIES - 1, // withRetry counts retries after first attempt | ||
| maxRetries: TELEGRAM_MAX_RETRIES - 1, // withRetry counts retries after first attempt | ||
| baseDelayMs: 1000, | ||
| maxDelayMs: RATE_LIMIT_MAX_WAIT_SEC * 1000, | ||
| maxDelayMs: TELEGRAM_RATE_LIMIT_MAX_WAIT_SEC * 1000, | ||
| shouldRetry: (err) => { | ||
@@ -160,3 +158,3 @@ const retryAfter = parseRetryAfter(err); | ||
| else { | ||
| log.error(`Failed to deliver final message ${messageId} after ${MAX_RETRIES} retries (rate limited)`); | ||
| log.error(`Failed to deliver final message ${messageId} after ${TELEGRAM_MAX_RETRIES} retries (rate limited)`); | ||
| } | ||
@@ -163,0 +161,0 @@ } |
| import { join } from 'node:path'; | ||
| import { mkdir, writeFile } from 'node:fs/promises'; | ||
| import { createWecomSender } from './message-sender.js'; | ||
| import { createWecomSender, sendTextReply as wecomSendText } from './message-sender.js'; | ||
| import { AccessControl } from '../access/access-control.js'; | ||
| import { RequestQueue } from '../queue/request-queue.js'; | ||
| import { CommandHandler } from '../commands/handler.js'; | ||
| import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js'; | ||
| import { registerPermissionSender, registerWatchSender, resolvePermissionById } from '../hook/permission-server.js'; | ||
| import { runClaudeTask } from '../shared/claude-task.js'; | ||
@@ -27,8 +27,29 @@ import { startTaskCleanup } from '../shared/task-cleanup.js'; | ||
| /** | ||
| * 清理群聊消息文本 | ||
| * 清理群聊消息文本:去掉 @机器人名 标记 | ||
| * 企业微信智能机器人 SDK 在群聊中只会推送 @机器人的消息, | ||
| * 所以只要收到群聊消息,就一定是被 mention 的,无需额外检查。 | ||
| * 但 text.content 中仍包含 @机器人名 文本,需要去掉。 | ||
| */ | ||
| function cleanGroupText(text) { | ||
| return text.trim(); | ||
| export function cleanGroupText(text, botName) { | ||
| let cleaned = text; | ||
| if (botName) { | ||
| // 精确匹配机器人名称(大小写不敏感) | ||
| const escaped = botName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| cleaned = cleaned.replace(new RegExp(`@${escaped}\\s*`, 'gi'), ''); | ||
| } | ||
| else { | ||
| // 启发式去除 @ 提及(降级方案,推荐配置 WECOM_BOT_NAME 以精确匹配) | ||
| // 局限:若命令参数中包含 @(如 /ask 给 @someone 发邮件 @Bot), | ||
| // 正则会从第一个 @ 开始截断,导致参数丢失。 | ||
| const slashIdx = cleaned.indexOf('/'); | ||
| if (slashIdx >= 0) { | ||
| const cmdPart = cleaned.substring(slashIdx); | ||
| cleaned = cmdPart.replace(/\s*@.*$/s, ''); | ||
| } | ||
| else { | ||
| // 非命令文本:去掉开头的 @word(无法完美处理多词名称) | ||
| cleaned = cleaned.replace(/^@\S+\s*/, ''); | ||
| } | ||
| } | ||
| return cleaned.trim(); | ||
| } | ||
@@ -58,2 +79,24 @@ /** | ||
| let taskCounter = 0; | ||
| /** | ||
| * 构建群聊/私聊的会话上下文(threadCtx、workDir、convId、queueKey) | ||
| */ | ||
| function resolveSessionContext(userId, chatId, isGroup) { | ||
| // 企业微信没有话题根消息概念,rootMessageId 留空 | ||
| const threadCtx = isGroup ? { threadId: chatId, rootMessageId: '' } : undefined; | ||
| // 群聊:提前创建 thread session,确保 workDir 独立于单聊 | ||
| // 避免 getWorkDirForThread 回退到用户单聊 workDir 导致两者联动 | ||
| if (threadCtx && !sessionManager.getThreadSession(userId, threadCtx.threadId)) { | ||
| sessionManager.setThreadSession(userId, threadCtx.threadId, { | ||
| workDir: sessionManager.getWorkDir(userId), | ||
| rootMessageId: '', | ||
| threadId: threadCtx.threadId, | ||
| }); | ||
| } | ||
| const workDir = threadCtx | ||
| ? sessionManager.getWorkDirForThread(userId, threadCtx.threadId) | ||
| : sessionManager.getWorkDir(userId); | ||
| const convId = threadCtx ? undefined : sessionManager.getConvId(userId); | ||
| const queueKey = threadCtx ? threadCtx.threadId : (convId ?? userId); | ||
| return { threadCtx, workDir, convId, queueKey }; | ||
| } | ||
| const commandHandler = new CommandHandler({ | ||
@@ -72,7 +115,13 @@ config, | ||
| }); | ||
| // 注册 watch 通知发送器 | ||
| registerWatchSender('wecom', { | ||
| sendWatchNotify: (chatId, text) => wecomSendText(chatId, text), | ||
| }); | ||
| /** | ||
| * 核心请求处理器(frame 可选,有 frame 时使用流式回复,无 frame 时退化为 sendMessage) | ||
| */ | ||
| async function handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, frame) { | ||
| const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined; | ||
| async function handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, frame, threadCtx) { | ||
| const sessionId = threadCtx | ||
| ? sessionManager.getSessionIdForThread(userId, threadCtx.threadId) | ||
| : convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined; | ||
| log.info(`Running Claude for user ${userId}, convId=${convId}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`); | ||
@@ -95,2 +144,3 @@ const taskKey = `${userId}:${++taskCounter}`; | ||
| convId, | ||
| threadId: threadCtx?.threadId, | ||
| platform: 'wecom', | ||
@@ -192,4 +242,4 @@ taskKey, | ||
| // CommandHandler 使用的签名(无 frame) | ||
| async function handleClaudeRequest(userId, chatId, prompt, workDir, convId) { | ||
| await handleClaudeRequestCore(userId, chatId, prompt, workDir, convId); | ||
| async function handleClaudeRequest(userId, chatId, prompt, workDir, convId, threadCtx) { | ||
| await handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, undefined, threadCtx); | ||
| } | ||
@@ -222,5 +272,5 @@ /** | ||
| let cleanText = text.trim(); | ||
| // 群聊文本清理(企业微信 SDK 只推送 @机器人的消息,无需检查 mention) | ||
| // 群聊文本清理:去掉 @机器人名 标记 | ||
| if (isGroup) { | ||
| cleanText = cleanGroupText(cleanText); | ||
| cleanText = cleanGroupText(cleanText, config.wecomBotName); | ||
| } | ||
@@ -257,11 +307,11 @@ if (!cleanText) | ||
| } | ||
| // 构建会话上下文(群聊隔离 workDir/sessionId) | ||
| const { threadCtx, workDir, convId, queueKey } = resolveSessionContext(userId, chatId, isGroup); | ||
| // 统一命令分发 | ||
| if (await commandHandler.dispatch(cleanText, chatId, userId, 'wecom', handleClaudeRequest)) { | ||
| if (await commandHandler.dispatch(cleanText, chatId, userId, 'wecom', handleClaudeRequest, threadCtx)) { | ||
| return; | ||
| } | ||
| // 路由到 Claude | ||
| const workDirSnapshot = sessionManager.getWorkDir(userId); | ||
| const convIdSnapshot = sessionManager.getConvId(userId); | ||
| const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, cleanText, async (prompt) => { | ||
| await handleClaudeRequestCore(userId, chatId, prompt, workDirSnapshot, convIdSnapshot, frame); | ||
| const enqueueResult = requestQueue.enqueue(userId, queueKey, cleanText, async (prompt) => { | ||
| await handleClaudeRequestCore(userId, chatId, prompt, workDir, convId, frame, threadCtx); | ||
| }); | ||
@@ -319,6 +369,5 @@ if (enqueueResult === 'rejected') { | ||
| log.info(`User ${userId} [image]: ${prompt.slice(0, 100)}...`); | ||
| const workDirSnapshot = sessionManager.getWorkDir(userId); | ||
| const convIdSnapshot = sessionManager.getConvId(userId); | ||
| const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, prompt, async (p) => { | ||
| await handleClaudeRequestCore(userId, chatId, p, workDirSnapshot, convIdSnapshot, frame); | ||
| const { threadCtx, workDir, convId, queueKey } = resolveSessionContext(userId, chatId, isGroup); | ||
| const enqueueResult = requestQueue.enqueue(userId, queueKey, prompt, async (p) => { | ||
| await handleClaudeRequestCore(userId, chatId, p, workDir, convId, frame, threadCtx); | ||
| }); | ||
@@ -357,5 +406,5 @@ if (enqueueResult === 'rejected') { | ||
| } | ||
| // 群聊文本清理(企业微信 SDK 只推送 @机器人的消息,无需检查 mention) | ||
| // 群聊文本清理:去掉 @机器人名 标记 | ||
| const textContent = isGroup | ||
| ? cleanGroupText(textParts.join(' ')) | ||
| ? cleanGroupText(textParts.join(' '), config.wecomBotName) | ||
| : textParts.join(' ').trim(); | ||
@@ -374,6 +423,5 @@ let prompt; | ||
| log.info(`User ${userId} [mixed]: ${prompt.slice(0, 100)}...`); | ||
| const workDirSnapshot = sessionManager.getWorkDir(userId); | ||
| const convIdSnapshot = sessionManager.getConvId(userId); | ||
| const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, prompt, async (p) => { | ||
| await handleClaudeRequestCore(userId, chatId, p, workDirSnapshot, convIdSnapshot, frame); | ||
| const { threadCtx, workDir, convId, queueKey } = resolveSessionContext(userId, chatId, isGroup); | ||
| const enqueueResult = requestQueue.enqueue(userId, queueKey, prompt, async (p) => { | ||
| await handleClaudeRequestCore(userId, chatId, p, workDir, convId, frame, threadCtx); | ||
| }); | ||
@@ -380,0 +428,0 @@ if (enqueueResult === 'rejected') { |
+7
-3
| { | ||
| "name": "cc-im", | ||
| "version": "1.5.0", | ||
| "description": "Multi-platform bot bridge (Feishu & Telegram) for Claude Code CLI", | ||
| "version": "1.6.0", | ||
| "description": "Multi-platform bot bridge (Feishu & Telegram & WeCom) for Claude Code CLI", | ||
| "repository": { | ||
@@ -20,6 +20,7 @@ "type": "git", | ||
| "scripts": { | ||
| "build": "tsc", | ||
| "build": "tsc && chmod +x dist/hook/hook-script.js dist/hook/watch-script.js dist/cli.js", | ||
| "dev": "tsx watch src/index.ts", | ||
| "start": "node dist/index.js", | ||
| "prepublishOnly": "npm run build", | ||
| "lint": "biome check src/ tests/", | ||
| "test": "vitest run", | ||
@@ -32,2 +33,4 @@ "test:watch": "vitest" | ||
| "telegram", | ||
| "wecom", | ||
| "wechat-work", | ||
| "claude", | ||
@@ -45,2 +48,3 @@ "claude-code", | ||
| "devDependencies": { | ||
| "@biomejs/biome": "^2.4.9", | ||
| "@types/node": "^25.3.0", | ||
@@ -47,0 +51,0 @@ "@vitest/coverage-v8": "^4.0.18", |
+27
-4
@@ -26,3 +26,4 @@ # cc-im | ||
| - **生命周期通知**:服务启动/关闭时通知活跃用户(含版本信息和运行时长) | ||
| - **守护进程模式**:支持 `-d` 后台运行和 `stop` 停止 | ||
| - **守护进程模式**:支持 `-d` 后台运行和 `stop` 停止,`install` 注册为 systemd 开机自启服务 | ||
| - **终端监控**:通过 `/watch` 命令实时监控终端 Claude Code 的运行状态(工具调用、完成事件) | ||
| - **版本更新检查**:启动时自动检查 npm 最新版本,有更新时提示 | ||
@@ -125,2 +126,5 @@ - **日志等级配置**:支持 DEBUG/INFO/WARN/ERROR 四级日志 | ||
| cc-im stop | ||
| # 查看运行状态 | ||
| cc-im status | ||
| ``` | ||
@@ -130,2 +134,14 @@ | ||
| ### 开机自启(systemd) | ||
| 在 Linux 上可注册为用户级 systemd 服务,开机自动启动: | ||
| ```bash | ||
| # 注册并启动服务 | ||
| cc-im install | ||
| # 卸载服务 | ||
| cc-im uninstall | ||
| ``` | ||
| ## 命令列表 | ||
@@ -147,2 +163,4 @@ | ||
| | `/history [page]` | 查看当前会话的对话历史 | | ||
| | `/resume [n]` | 浏览/恢复历史会话 | | ||
| | `/watch [level]` | 监控终端 Claude Code(stop/tool/full/off) | | ||
| | `/threads` | 列出所有话题会话(飞书) | | ||
@@ -162,2 +180,3 @@ | `/stop` | 停止当前运行的任务(企业微信) | | ||
| | `WECOM_BOT_SECRET` | 企业微信机器人 Secret | 企业微信平台必填 | | ||
| | `WECOM_BOT_NAME` | 企业微信机器人显示名称,用于精确去除群聊 @提及 | 空(启发式匹配) | | ||
| | `ALLOWED_USER_IDS` | 白名单用户 ID,逗号分隔,留空不限制 | 空(不限制) | | ||
@@ -192,2 +211,3 @@ | `CLAUDE_CLI_PATH` | Claude CLI 可执行文件路径 | `claude` | | ||
| "wecomBotSecret": "", | ||
| "wecomBotName": "", | ||
| "allowedUserIds": ["123456789"], | ||
@@ -276,3 +296,3 @@ "claudeCliPath": "/usr/local/bin/claude", | ||
| ├── sanitize.ts # 日志脱敏规则 | ||
| ├── cli.ts # CLI 入口(前台/守护进程/停止) | ||
| ├── cli.ts # CLI 入口(前台/守护进程/systemd 服务管理) | ||
| ├── access/ | ||
@@ -301,4 +321,7 @@ │ └── access-control.ts # 白名单访问控制 | ||
| ├── hook/ | ||
| │ ├── permission-server.ts # 权限确认 HTTP 服务 | ||
| │ └── hook-script.ts # Claude Code PreToolUse Hook | ||
| │ ├── permission-server.ts # 权限确认 HTTP 服务 + 监控通知端点 | ||
| │ ├── hook-script.ts # Claude Code PreToolUse Hook | ||
| │ ├── watch-script.ts # Claude Code 监控 Hook(PostToolUse/Stop 等) | ||
| │ ├── watch.ts # 监控状态管理与消息格式化 | ||
| │ └── ensure-hook.ts # Hook 自动配置 | ||
| ├── shared/ | ||
@@ -305,0 +328,0 @@ │ ├── active-chats.ts # 活跃聊天记录(生命周期通知) |
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
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
276833
9.87%43
4.88%6565
10.32%338
7.3%7
16.67%38
8.57%