Files
telegram-bot/src/registry.js
T
JianMiauandClaude Fable 5 6e39a8084f 發佈 0.1.6:速度固定 Fast,移除選項與 footer 顯示
摘要:
速度(service tier)固定為 priority(Fast 1.5x):表單移除速度下拉、
footer 不再顯示 Fast 字樣;舊設定檔存空值也會矯正回 priority。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:02:07 +08:00

193 lines
7.0 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, 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: 'priority', // 速度固定 Fast 1.5x
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.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 };