'use strict'; // Bot 註冊表:web 控制台管理的 bot 實例清單。 // bot 設定放在 ~/.tgcodex/bots//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 };