摘要: 用 Telegram 操控本機 Codex CLI 的 bot,附 web 控制台,封裝為 npm 包。 內容: - 零依賴(僅 Node 內建模組);bot 以 codex exec --json 驅動,支援會話延續 (exec resume)、workspace-write 沙盒、圖片輸入、進度回報與 ctx%/tokens footer - web 控制台(pm2 託管,預設 127.0.0.1:3799):Bot 管理分頁(新增/編輯/啟停, token 自動驗證、工作目錄用原生視窗選、模型/推理強度/速度下拉,清單來自 ~/.codex/models_cache.json)+ PM2 檢視分頁(狀態/port/log/啟停) - CLI:start(環境檢查後把控制台掛上 pm2)/ stop / restart / delete / status / logs / web / doctor - Windows 相容:解析 codex.cmd shim 直接以 node 執行、taskkill 整樹砍程序、 資料夾選擇視窗以 TopMost 透明 owner 置中 影響: 新專案初始版本;bot 設定存於 ~/.tgcodex/bots/<name>/,含明文 token 的實例 設定不進版控(.gitignore 已涵蓋 tgcodex.config.json 與 .tgcodex/)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
221 lines
8.0 KiB
JavaScript
221 lines
8.0 KiB
JavaScript
'use strict';
|
||
// Codex CLI 執行層:spawn `codex exec --json`,逐行解析 JSONL 事件。
|
||
// Windows 上 codex 是 .cmd shim(node 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 既有會話 id;null 表示開新會話
|
||
* @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 };
|