發佈 0.2.0:改名 telegram-bot,新增 Claude Code 引擎(新增 bot 時可選 Codex / Claude),start 自動從 0.1.x 遷移
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user