摘要: 用 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>
194 lines
7.1 KiB
JavaScript
194 lines
7.1 KiB
JavaScript
'use strict';
|
||
// Bot 註冊表:web 控制台管理的 bot 實例清單。
|
||
// bot 設定放在 ~/.tgcodex/bots/<name>/tgcodex.config.json。
|
||
// pm2 上帶 TGCODEX_CONFIG 但不在註冊表裡的 bot 程序會被自動探索回來
|
||
//(registry.json 遺失時的自我修復)。
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { TGCODEX_HOME, jlist } = require('./pm2util');
|
||
const { loadConfig, SANDBOX_MODES } = require('./config');
|
||
|
||
const REG_FILE = path.join(TGCODEX_HOME, 'registry.json');
|
||
const BOTS_DIR = path.join(TGCODEX_HOME, 'bots');
|
||
const BOT_SCRIPT = path.join(__dirname, 'index.js');
|
||
|
||
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/;
|
||
|
||
function loadRegistry() {
|
||
try {
|
||
const data = JSON.parse(fs.readFileSync(REG_FILE, 'utf-8'));
|
||
return { bots: data.bots || {} };
|
||
} catch {
|
||
return { bots: {} };
|
||
}
|
||
}
|
||
|
||
function saveRegistry(reg) {
|
||
fs.mkdirSync(TGCODEX_HOME, { recursive: true });
|
||
fs.writeFileSync(REG_FILE, JSON.stringify(reg, null, 2) + '\n');
|
||
}
|
||
|
||
function maskToken(token) {
|
||
if (!token) return '';
|
||
return token.length <= 10 ? '***' : token.slice(0, 6) + '…' + token.slice(-4);
|
||
}
|
||
|
||
// 從 pm2 探索不在註冊表裡的 bot(跑著我們 src/index.js 且帶 TGCODEX_CONFIG 的程序)
|
||
function discoverFromPm2(reg, list) {
|
||
let changed = false;
|
||
for (const p of list) {
|
||
// pm2 版本不同,自訂 env 可能在 pm2_env.env 或直接攤平在 pm2_env 上
|
||
const env = { ...(p.pm2_env || {}), ...(p.pm2_env?.env || {}) };
|
||
const script = p.pm2_env?.pm_exec_path || '';
|
||
if (!env.TGCODEX_CONFIG || typeof env.TGCODEX_CONFIG !== 'string') continue;
|
||
if (path.resolve(script) !== path.resolve(BOT_SCRIPT)) continue;
|
||
if (reg.bots[p.name]) continue;
|
||
if (!fs.existsSync(env.TGCODEX_CONFIG)) continue;
|
||
reg.bots[p.name] = { configPath: env.TGCODEX_CONFIG, source: 'external' };
|
||
changed = true;
|
||
}
|
||
if (changed) saveRegistry(reg);
|
||
}
|
||
|
||
async function listBots() {
|
||
const reg = loadRegistry();
|
||
let list = [];
|
||
try {
|
||
list = await jlist();
|
||
} catch { /* pm2 不在也要能列出設定 */ }
|
||
discoverFromPm2(reg, list);
|
||
|
||
const bots = [];
|
||
for (const [name, entry] of Object.entries(reg.bots)) {
|
||
let cfg = null;
|
||
let error = null;
|
||
try {
|
||
cfg = loadConfig(entry.configPath);
|
||
} catch (err) {
|
||
error = err.message;
|
||
}
|
||
if (!fs.existsSync(entry.configPath)) error = '設定檔已不存在';
|
||
const proc = cfg ? list.find((p) => p.name === cfg.pm2Name) : list.find((p) => p.name === name);
|
||
bots.push({
|
||
name,
|
||
source: entry.source || 'ui',
|
||
configPath: entry.configPath,
|
||
error,
|
||
workDir: cfg?.workDir || null,
|
||
sandbox: cfg?.sandbox || null,
|
||
model: cfg?.model || '',
|
||
reasoningEffort: cfg?.reasoningEffort || '',
|
||
serviceTier: cfg?.serviceTier || '',
|
||
timeoutMinutes: cfg?.timeoutMinutes || null,
|
||
tokenMasked: maskToken(cfg?.telegramToken),
|
||
hasToken: !!cfg?.telegramToken,
|
||
pm2Name: cfg?.pm2Name || name,
|
||
pmId: proc?.pm_id ?? null,
|
||
status: proc?.pm2_env?.status || 'not_started',
|
||
pid: proc?.pid || null,
|
||
memory: proc?.monit?.memory ?? 0,
|
||
cpu: proc?.monit?.cpu ?? '-',
|
||
restarts: proc?.pm2_env?.restart_time ?? 0,
|
||
uptime: proc?.pm2_env?.pm_uptime ?? null,
|
||
});
|
||
}
|
||
bots.sort((a, b) => a.name.localeCompare(b.name));
|
||
return bots;
|
||
}
|
||
|
||
function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) {
|
||
if (isCreate) {
|
||
if (!name || !NAME_RE.test(name)) {
|
||
throw new Error('名稱只能是英數字開頭,含英數字、點、底線、連字號,最長 40 字');
|
||
}
|
||
if (!token) throw new Error('token 不能是空的');
|
||
}
|
||
if (workDir !== undefined) {
|
||
if (!workDir || !fs.existsSync(workDir)) throw new Error(`工作目錄不存在:${workDir}`);
|
||
if (!fs.statSync(workDir).isDirectory()) throw new Error(`工作目錄不是資料夾:${workDir}`);
|
||
}
|
||
if (sandbox !== undefined && !SANDBOX_MODES.includes(sandbox)) {
|
||
throw new Error(`sandbox 必須是 ${SANDBOX_MODES.join(' / ')}`);
|
||
}
|
||
}
|
||
|
||
async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, serviceTier, timeoutMinutes }) {
|
||
validateBotInput({ name, token, workDir, sandbox }, { isCreate: true });
|
||
const reg = loadRegistry();
|
||
if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`);
|
||
const list = await jlist().catch(() => []);
|
||
if (list.some((p) => p.name === name)) throw new Error(`pm2 上已有同名程序:${name}`);
|
||
|
||
const dir = path.join(BOTS_DIR, name);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
const configPath = path.join(dir, 'tgcodex.config.json');
|
||
const config = {
|
||
telegramToken: token,
|
||
workDir: path.resolve(workDir),
|
||
sandbox: sandbox || 'workspace-write',
|
||
model: model || '',
|
||
reasoningEffort: reasoningEffort || '',
|
||
serviceTier: serviceTier || '',
|
||
timeoutMinutes: Number(timeoutMinutes) || 30,
|
||
pm2Name: name,
|
||
// UI 建的 bot 不各自開 web,統一由控制台管理
|
||
web: { enabled: false, port: 3799, host: '127.0.0.1' },
|
||
};
|
||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||
|
||
reg.bots[name] = { configPath, source: 'ui', createdAt: new Date().toISOString() };
|
||
saveRegistry(reg);
|
||
return loadConfig(configPath);
|
||
}
|
||
|
||
async function updateBot(name, patch) {
|
||
const reg = loadRegistry();
|
||
const entry = reg.bots[name];
|
||
if (!entry) throw new Error(`找不到 bot:${name}`);
|
||
const raw = JSON.parse(fs.readFileSync(entry.configPath, 'utf-8'));
|
||
|
||
if (patch.workDir !== undefined && patch.workDir !== '') {
|
||
validateBotInput({ workDir: patch.workDir }, { isCreate: false });
|
||
raw.workDir = path.resolve(patch.workDir);
|
||
}
|
||
if (patch.token) raw.telegramToken = patch.token; // 空字串 = 不變更
|
||
if (patch.sandbox !== undefined) {
|
||
validateBotInput({ sandbox: patch.sandbox }, { isCreate: false });
|
||
raw.sandbox = patch.sandbox;
|
||
}
|
||
if (patch.model !== undefined) raw.model = patch.model;
|
||
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
|
||
if (patch.serviceTier !== undefined) raw.serviceTier = patch.serviceTier;
|
||
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
|
||
raw.timeoutMinutes = Number(patch.timeoutMinutes);
|
||
}
|
||
|
||
fs.writeFileSync(entry.configPath, JSON.stringify(raw, null, 2) + '\n');
|
||
return loadConfig(entry.configPath);
|
||
}
|
||
|
||
function getBotConfig(name) {
|
||
const reg = loadRegistry();
|
||
const entry = reg.bots[name];
|
||
if (!entry) throw new Error(`找不到 bot:${name}`);
|
||
return loadConfig(entry.configPath);
|
||
}
|
||
|
||
function removeBot(name, { deleteFiles = false } = {}) {
|
||
const reg = loadRegistry();
|
||
const entry = reg.bots[name];
|
||
if (!entry) throw new Error(`找不到 bot:${name}`);
|
||
delete reg.bots[name];
|
||
saveRegistry(reg);
|
||
if (deleteFiles) {
|
||
// 只允許刪除放在 ~/.tgcodex/bots/ 底下的(UI 建的);folder 模式的設定檔不動
|
||
const dir = path.dirname(entry.configPath);
|
||
if (dir.startsWith(BOTS_DIR + path.sep)) {
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
module.exports = { listBots, createBot, updateBot, removeBot, getBotConfig, maskToken, BOTS_DIR };
|