Files
telegram-bot/src/registry.js
T

218 lines
8.3 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 設定放在 ~/.tgbot/bots/<name>/tgbot.config.json0.1.x 建的是 tgcodex.config.json,沿用)。
// pm2 上帶設定檔路徑但不在註冊表裡的 bot 程序會被自動探索回來
//registry.json 遺失時的自我修復)。
const fs = require('fs');
const path = require('path');
const { TGBOT_HOME, BOT_SCRIPT, jlist, botConfigPathOf } = require('./pm2util');
const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME, normalizeUsers } = require('./config');
const REG_FILE = path.join(TGBOT_HOME, 'registry.json');
const BOTS_DIR = path.join(TGBOT_HOME, 'bots');
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(TGBOT_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 且帶設定檔路徑的程序)
function discoverFromPm2(reg, list) {
let changed = false;
for (const p of list) {
const cfgPath = botConfigPathOf(p);
if (!cfgPath) continue;
const script = p.pm2_env?.pm_exec_path || '';
if (path.resolve(script) !== path.resolve(BOT_SCRIPT)) continue;
if (reg.bots[p.name]) continue;
if (!fs.existsSync(cfgPath)) continue;
reg.bots[p.name] = { configPath: cfgPath, 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,
engine: cfg?.engine || 'codex',
workDir: cfg?.workDir || null,
sandbox: cfg?.sandbox || null,
model: cfg?.model || '',
reasoningEffort: cfg?.reasoningEffort || '',
sessionMode: cfg?.sessionMode || 'per-chat',
networkAccess: !!cfg?.networkAccess,
serviceTier: cfg?.serviceTier || '',
promptRules: cfg?.promptRules || '',
writeUsers: cfg?.writeUsers || [],
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, engine }, { 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(' / ')}`);
}
if (engine !== undefined && !ENGINE_IDS.includes(engine)) {
throw new Error(`engine 必須是 ${ENGINE_IDS.join(' / ')}`);
}
}
async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, promptRules, writeUsers, timeoutMinutes }) {
validateBotInput({ name, token, workDir, sandbox, engine }, { 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, CONFIG_BASENAME);
const config = {
telegramToken: token,
engine: engine || 'codex',
workDir: path.resolve(workDir),
sandbox: sandbox || 'workspace-write',
model: model || '',
reasoningEffort: reasoningEffort || '',
sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat',
networkAccess: !!networkAccess,
promptRules: typeof promptRules === 'string' ? promptRules.trim() : '',
writeUsers: normalizeUsers(writeUsers),
serviceTier: 'priority', // codex 速度固定 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'));
const before = loadConfig(entry.configPath);
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.engine !== undefined) {
validateBotInput({ engine: patch.engine }, { isCreate: false });
raw.engine = patch.engine;
}
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 (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; }
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess;
if (patch.promptRules !== undefined) raw.promptRules = String(patch.promptRules || '').trim();
if (patch.writeUsers !== undefined) raw.writeUsers = normalizeUsers(patch.writeUsers);
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
raw.timeoutMinutes = Number(patch.timeoutMinutes);
}
fs.writeFileSync(entry.configPath, JSON.stringify(raw, null, 2) + '\n');
const after = loadConfig(entry.configPath);
// 換引擎:舊會話 id 對新引擎沒意義,清掉
if (after.engine !== before.engine) {
try { fs.unlinkSync(path.join(after.stateDir, 'sessions.json')); } catch { /* 沒有就算了 */ }
}
return after;
}
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) {
// 只允許刪除放在 ~/.tgbot/bots/ 底下的(UI 建的);外部設定檔不動
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 };