Files
telegram-bot/src/engines/claude.js
T

213 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Claude 引擎:spawn `claude -p --output-format stream-json --verbose`,逐行解析事件。
// prompt 走 stdin-p 沒有位置參數時讀 stdin),任意文字都安全。
// 沙盒對應:read-only → 禁用寫檔/執行工具;workspace-write → 自動接受檔案編輯(不執行指令);
// danger-full-access → 跳過所有權限確認。
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
let cachedCommand = null;
// 找 claude 執行檔實體:pm2 daemon 的 PATH 不一定帶到 ~/.local/bin(原生安裝位置),直接補找
function resolveClaudeCommand(config) {
const custom = config && config.claudePath;
if (custom) return { cmd: custom, shell: /\.cmd$/i.test(custom) };
if (cachedCommand) return cachedCommand;
const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude'];
const dirs = [...(process.env.PATH || '').split(path.delimiter), LOCAL_BIN];
for (const dir of dirs) {
if (!dir) continue;
for (const n of names) {
const p = path.join(dir, n);
if (fs.existsSync(p)) {
cachedCommand = { cmd: p, shell: /\.cmd$/i.test(p) };
return cachedCommand;
}
}
}
cachedCommand = { cmd: 'claude', shell: process.platform === 'win32' };
return cachedCommand;
}
// 只有退回 shell(.cmd shim)時才需要引號;參數裡不會有引號,包起來即可
function quoteForShell(s) {
return /[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
}
function killTree(proc) {
if (process.platform === 'win32') {
try { execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' }); } catch { /* ignore */ }
} else {
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
}
}
const TOOL_LABELS = {
Bash: '⚙️ 執行指令',
Read: '📖 讀取檔案',
Write: '✏️ 寫入檔案',
Edit: '✏️ 編輯檔案',
MultiEdit: '✏️ 編輯檔案',
NotebookEdit: '✏️ 編輯筆記本',
Glob: '🔍 搜尋檔案',
Grep: '🔍 搜尋內容',
WebFetch: '🌐 擷取網頁',
WebSearch: '🔎 搜尋網路',
Agent: '🤖 派出子代理',
Task: '🤖 派出子代理',
};
function permissionArgs(sandbox) {
switch (sandbox) {
case 'read-only':
return ['--permission-mode', 'default', '--disallowedTools', 'Write,Edit,MultiEdit,NotebookEdit,Bash'];
case 'danger-full-access':
return ['--dangerously-skip-permissions'];
default: // workspace-write
return ['--permission-mode', 'acceptEdits'];
}
}
/**
* 執行一輪 Claude 對話。介面同 engines/codex.js 的 run。
* @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null, denials: object[]}>}
*/
function run({ config, prompt, threadId, images = [], onProgress }) {
return new Promise((resolve, reject) => {
const { cmd, shell } = resolveClaudeCommand(config);
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
if (config.model) args.push('--model', config.model);
if (config.reasoningEffort) args.push('--effort', config.reasoningEffort);
args.push(...permissionArgs(config.sandbox));
// 附圖:claude -p 沒有圖片參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍)
if (images.length > 0) args.push('--add-dir', path.dirname(images[0]));
if (threadId) args.push('--resume', threadId);
let fullPrompt = prompt;
if (images.length > 0) {
fullPrompt += '\n\n【附圖】請先用 Read 工具讀取下列圖片檔再處理:\n' + images.join('\n');
}
const env = { ...process.env };
// 讓子程序也找得到 claude 與它可能呼叫的工具
env.PATH = `${env.PATH || ''}${path.delimiter}${LOCAL_BIN}`;
const proc = shell
? spawn(quoteForShell(cmd) + ' ' + args.map(quoteForShell).join(' '), { cwd: config.workDir, env, shell: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
: spawn(cmd, args, { cwd: config.workDir, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
proc.stdin.write(fullPrompt);
proc.stdin.end();
let buffer = '';
let stderrTail = '';
let sessionId = threadId || null;
let model = null;
let resultText = null;
let resultError = null;
let usage = null;
let denials = [];
let lastText = '';
const timer = setTimeout(() => {
killTree(proc);
reject(new Error(`Claude 逾時(${config.timeoutMinutes} 分鐘)`));
}, config.timeoutMinutes * 60 * 1000);
const handleEvent = (obj) => {
if (obj.type === 'system') {
if (obj.session_id) sessionId = obj.session_id;
if (obj.subtype === 'init' && obj.model) model = obj.model;
return;
}
if (obj.type === 'assistant') {
if (obj.message?.model) model = obj.message.model;
if (!onProgress) return;
let toolLabel = null;
for (const block of obj.message?.content || []) {
if (block.type === 'text' && block.text?.trim()) {
const t = block.text.trim();
lastText = t.length > 600 ? t.slice(0, 600) + '...' : t;
} else if (block.type === 'tool_use') {
let detail = '';
if (block.name === 'Bash' && block.input?.command) {
const c = String(block.input.command);
detail = c.length > 300 ? c.slice(0, 300) + '...' : c;
} else if (block.input?.file_path) {
detail = String(block.input.file_path);
}
toolLabel = (TOOL_LABELS[block.name] || `🔧 ${block.name}`) + '...' + (detail ? '\n' + detail : '');
}
}
if (toolLabel) onProgress(toolLabel);
else if (lastText) onProgress('💬 ' + lastText);
return;
}
if (obj.type === 'result') {
if (obj.session_id) sessionId = obj.session_id;
denials = Array.isArray(obj.permission_denials) ? obj.permission_denials : [];
const u = obj.usage || null;
if (u) {
// iterations 有的話取最後一輪:那才是目前會話的實際上下文大小
const iters = u.iterations;
const ref = (Array.isArray(iters) && iters.length > 0) ? iters[iters.length - 1] : u;
const mu = obj.modelUsage && (obj.modelUsage[model] || Object.values(obj.modelUsage)[0]);
usage = {
input_tokens: (ref.input_tokens || 0) + (ref.cache_creation_input_tokens || 0) + (ref.cache_read_input_tokens || 0),
cached_input_tokens: ref.cache_read_input_tokens || 0,
output_tokens: ref.output_tokens || 0,
context_window: mu?.contextWindow || 200000,
};
if (mu?.canonicalModel) model = model || mu.canonicalModel;
}
if (obj.is_error) {
const errs = Array.isArray(obj.errors) ? obj.errors.join('; ') : '';
resultError = [obj.result, errs].filter(Boolean).join('\n') || obj.subtype || 'Claude 回報錯誤';
} else {
resultText = obj.result ?? '';
}
}
};
proc.stdout.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
handleEvent(JSON.parse(line));
} catch { /* 非 JSON 行直接忽略 */ }
}
});
proc.stderr.on('data', (chunk) => {
stderrTail = (stderrTail + chunk.toString()).slice(-2000);
if (process.env.DEBUG) console.error('[claude stderr]', chunk.toString());
});
proc.on('close', (code) => {
clearTimeout(timer);
if (resultText !== null) {
return resolve({ text: resultText, threadId: sessionId, usage, model, denials });
}
const detail = resultError || stderrTail.split('\n').filter((l) => l.trim()).slice(-5).join('\n');
const e = new Error(resultError ? `Claude 執行失敗:${resultError}` : `claude 結束(exit code ${code}${detail ? '\n' + detail : ''}`);
e.threadId = sessionId;
e.transient = /ECONNRESET|socket hang up|timed out|stream|overloaded|529|rate limit|connection/i.test(detail || '');
reject(e);
});
proc.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`無法啟動 claude${err.message}(請確認已安裝 Claude Code 並在 PATH 中)`));
});
});
}
module.exports = { run, resolveClaudeCommand };