Files
telegram-codex-bot/src/registry.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

194 lines
7.1 KiB
JavaScript
Raw 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';
// 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 };