Files
telegram-codex-bot/src/codex.js
T

221 lines
8.0 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// Codex CLI 執行層:spawn `codex exec --json`,逐行解析 JSONL 事件。
// Windows 上 codex 是 .cmd shimnode codex.js 的包裝),直接解析出 codex.js
// 用 node 執行,避開 shell:true 的引號問題與 DEP0190 警告。
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
let cachedCommand = null;
// 在 PATH 中尋找 codex shim,回推 @openai/codex/bin/codex.js 的實際位置
function findCodexJs() {
const dirs = (process.env.PATH || '').split(path.delimiter);
for (const dir of dirs) {
if (!dir) continue;
const shim = path.join(dir, process.platform === 'win32' ? 'codex.cmd' : 'codex');
if (!fs.existsSync(shim)) continue;
const candidates = [
path.join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
// unix 下 shim 是 symlink,指向 lib/node_modules 下的實體
path.join(dir, '..', 'lib', 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
}
// 最後手段:問 npm 全域 root
try {
const root = execSync('npm root -g', { encoding: 'utf-8', windowsHide: true }).trim();
const c = path.join(root, '@openai', 'codex', 'bin', 'codex.js');
if (fs.existsSync(c)) return c;
} catch { /* ignore */ }
return null;
}
// 回傳 { cmd, baseArgs, shell }:實際 spawn 的指令與前置參數
function resolveCodexCommand(config) {
if (cachedCommand) return cachedCommand;
const custom = config && config.codexPath;
if (custom) {
cachedCommand = custom.endsWith('.js')
? { cmd: process.execPath, baseArgs: [custom], shell: false }
: { cmd: custom, baseArgs: [], shell: false };
return cachedCommand;
}
if (process.platform === 'win32') {
const js = findCodexJs();
if (js) {
cachedCommand = { cmd: process.execPath, baseArgs: [js], shell: false };
return cachedCommand;
}
// 找不到實體時退回 shell(.cmd 需要 shell 才能執行)
cachedCommand = { cmd: 'codex', baseArgs: [], shell: true };
return cachedCommand;
}
cachedCommand = { cmd: 'codex', baseArgs: [], shell: false };
return cachedCommand;
}
function killTree(proc) {
if (process.platform === 'win32') {
// node shim 的子程序(codex.exe)不會隨父程序死掉,要整棵砍
try { execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' }); } catch { /* ignore */ }
} else {
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
}
}
const PROGRESS_LABELS = {
command_execution: '⚙️ 執行指令',
file_change: '✏️ 修改檔案',
mcp_tool_call: '🔧 呼叫工具',
web_search: '🔎 搜尋網路',
reasoning: '🤔 思考中',
};
/**
* 執行一輪 Codex 對話。
* @param {object} opts
* @param {object} opts.config 載入後的設定
* @param {string} opts.prompt 使用者 prompt(走 stdin,任意內容都安全)
* @param {string|null} opts.threadId 既有會話 idnull 表示開新會話
* @param {string[]} [opts.images] 附圖檔案路徑
* @param {(text: string) => void} [opts.onProgress] 進度回呼
* @returns {Promise<{text: string, threadId: string|null, usage: object|null}>}
*/
function runCodex({ config, prompt, threadId, images = [], onProgress }) {
return new Promise((resolve, reject) => {
const { cmd, baseArgs, shell } = resolveCodexCommand(config);
const args = [...baseArgs, 'exec'];
if (threadId) args.push('resume', threadId);
args.push('--json', '--skip-git-repo-check');
if (threadId) {
// resume 子指令不吃 -s / -m / --color,改走 -c 設定覆寫
args.push('-c', `sandbox_mode="${config.sandbox}"`);
if (config.model) args.push('-c', `model="${config.model}"`);
} else {
args.push('--color', 'never', '-s', config.sandbox);
if (config.model) args.push('-m', config.model);
}
if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`);
if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`);
for (const img of images) args.push('-i', img);
args.push('-'); // prompt 從 stdin 讀
const proc = spawn(cmd, args, {
cwd: config.workDir,
env: { ...process.env },
shell,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
proc.stdin.write(prompt);
proc.stdin.end();
let buffer = '';
let stderrTail = '';
let resultThreadId = threadId || null;
let usage = null;
let turnCompleted = false;
let turnFailedMessage = null;
const messages = [];
const timer = setTimeout(() => {
killTree(proc);
reject(new Error(`Codex 逾時(${config.timeoutMinutes} 分鐘)`));
}, config.timeoutMinutes * 60 * 1000);
const handleEvent = (event) => {
const item = event.item;
switch (event.type) {
case 'thread.started':
if (event.thread_id) resultThreadId = event.thread_id;
break;
case 'turn.completed':
turnCompleted = true;
usage = event.usage || null;
break;
case 'turn.failed':
turnFailedMessage = event.error?.message || 'turn.failed(未提供原因)';
break;
case 'error':
turnFailedMessage = event.message || event.error?.message || 'Codex 回報錯誤';
break;
case 'item.started':
case 'item.updated': {
if (!onProgress || !item) break;
const label = PROGRESS_LABELS[item.type];
if (!label) break;
let detail = '';
if (item.type === 'command_execution' && item.command) {
detail = item.command.length > 300 ? item.command.slice(0, 300) + '...' : item.command;
}
onProgress(`${label}...${detail ? '\n' + detail : ''}`);
break;
}
case 'item.completed': {
if (!item) break;
if (item.type === 'agent_message' && item.text) {
messages.push(item.text);
if (onProgress) {
const t = item.text.trim();
onProgress('💬 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t));
}
} else if (item.type === 'error' && item.message) {
turnFailedMessage = item.message;
} else if (item.type === 'reasoning' && item.text && onProgress) {
const t = item.text.trim();
onProgress('🤔 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t));
}
break;
}
default:
break;
}
};
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 行(log 雜訊)直接忽略 */ }
}
});
proc.stderr.on('data', (chunk) => {
stderrTail = (stderrTail + chunk.toString()).slice(-2000);
if (process.env.DEBUG) console.error('[codex stderr]', chunk.toString());
});
proc.on('close', (code) => {
clearTimeout(timer);
if (turnFailedMessage) {
return reject(new Error(`Codex 執行失敗:${turnFailedMessage}`));
}
if (turnCompleted || messages.length > 0) {
return resolve({ text: messages.join('\n\n'), threadId: resultThreadId, usage });
}
const hint = stderrTail
.split('\n')
.filter((l) => l.trim() && !/^\d{4}-\d{2}-\d{2}T.*(ERROR|WARN) codex_models_manager/.test(l))
.slice(-5)
.join('\n');
reject(new Error(`codex 結束(exit code ${code}${hint ? '\n' + hint : ''}`));
});
proc.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`無法啟動 codex${err.message}(請確認已安裝 @openai/codex 並在 PATH 中)`));
});
});
}
module.exports = { runCodex, resolveCodexCommand, findCodexJs };