Files
telegram-codex-bot/src/codex.js
T
JianMiauandClaude Fable 5 5b32816ad4 建立 telegram-codex-bot:串接 Codex CLI 的 Telegram bot(npm 包)
摘要:
用 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>
2026-08-04 16:25:39 +08:00

221 lines
8.0 KiB
JavaScript
Raw Permalink 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';
// 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 };