建立 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>
This commit is contained in:
2026-08-04 16:25:39 +08:00
co-authored by Claude Fable 5
commit 5b32816ad4
16 changed files with 2540 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// 設定載入與驗證。優先序:env 變數 > 設定檔 > 預設值。
const fs = require('fs');
const path = require('path');
const CONFIG_BASENAME = 'tgcodex.config.json';
const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
const DEFAULTS = {
// Telegram Bot tokenBotFather 取得)
telegramToken: '',
// Codex 可讀寫的專案目錄(相對路徑以設定檔所在目錄為基準)
workDir: '.',
// Codex 沙盒模式,預設可讀寫專案目錄
sandbox: 'workspace-write',
// 指定模型;空字串 = 使用 codex 預設
model: '',
// 推理強度(low/medium/high/xhigh/max/ultra…依模型而定);空字串 = codex 預設
reasoningEffort: '',
// 速度(service tier,如 priority = Fast 1.5x);空字串 = codex 預設
serviceTier: '',
// 單次回應逾時(分鐘)
timeoutMinutes: 30,
// pm2 程序名稱(web 檢視工具會叫 <pm2Name>-web
pm2Name: 'tgcodex-bot',
// 內建 PM2 web 檢視工具
web: { enabled: true, port: 3799, host: '127.0.0.1' },
// 手動指定 codex 入口(codex.js 或執行檔路徑);空字串 = 自動偵測
codexPath: '',
};
function findConfigPath(explicit) {
if (explicit) return path.resolve(explicit);
if (process.env.TGCODEX_CONFIG) return path.resolve(process.env.TGCODEX_CONFIG);
return path.resolve(process.cwd(), CONFIG_BASENAME);
}
function loadConfig(explicitPath) {
const file = findConfigPath(explicitPath);
let raw = {};
if (fs.existsSync(file)) {
try {
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch (err) {
throw new Error(`設定檔 ${file} 不是合法 JSON${err.message}`);
}
}
const cfg = {
...DEFAULTS,
...raw,
web: { ...DEFAULTS.web, ...(raw.web || {}) },
};
if (process.env.TGCODEX_TOKEN) cfg.telegramToken = process.env.TGCODEX_TOKEN;
if (process.env.TGCODEX_WORKDIR) cfg.workDir = process.env.TGCODEX_WORKDIR;
if (process.env.TGCODEX_SANDBOX) cfg.sandbox = process.env.TGCODEX_SANDBOX;
if (process.env.TGCODEX_MODEL) cfg.model = process.env.TGCODEX_MODEL;
if (process.env.TGCODEX_WEB_PORT) cfg.web.port = Number(process.env.TGCODEX_WEB_PORT);
if (process.env.TGCODEX_WEB_HOST) cfg.web.host = process.env.TGCODEX_WEB_HOST;
cfg.configPath = file;
cfg.configDir = path.dirname(file);
cfg.workDir = path.resolve(cfg.configDir, cfg.workDir);
cfg.stateDir = path.join(cfg.configDir, '.tgcodex');
cfg.logsDir = path.join(cfg.stateDir, 'logs');
cfg.tmpDir = path.join(cfg.stateDir, 'tmp');
if (!SANDBOX_MODES.includes(cfg.sandbox)) {
throw new Error(`sandbox 必須是 ${SANDBOX_MODES.join(' / ')},收到:${cfg.sandbox}`);
}
if (!/^[A-Za-z0-9._-]+$/.test(cfg.pm2Name)) {
throw new Error(`pm2Name 只能包含英數字、點、底線、連字號:${cfg.pm2Name}`);
}
if (cfg.web.enabled) {
const port = Number(cfg.web.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`web.port 必須是 1-65535 的整數:${cfg.web.port}`);
}
cfg.web.port = port;
}
const minutes = Number(cfg.timeoutMinutes);
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
cfg.timeoutMinutes = minutes;
return cfg;
}
// bot 執行前的額外檢查(web 檢視工具不需要 token,所以拆開)
function assertBotConfig(cfg) {
if (!cfg.telegramToken) {
throw new Error(
`缺少 Telegram token。請在 ${cfg.configPath} 設定 telegramToken` +
'或設環境變數 TGCODEX_TOKEN。(先跑 `telegram-codex-bot init` 產生設定檔)'
);
}
if (!fs.existsSync(cfg.workDir)) {
throw new Error(`workDir 不存在:${cfg.workDir}`);
}
}
function ensureStateDirs(cfg) {
for (const dir of [cfg.stateDir, cfg.logsDir, cfg.tmpDir]) {
fs.mkdirSync(dir, { recursive: true });
}
}
module.exports = { CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, loadConfig, assertBotConfig, ensureStateDirs, findConfigPath };