建立 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>
This commit is contained in:
2026-08-04 16:25:39 +08:00
co-authored by Claude Fable 5
commit 5b32816ad4
16 changed files with 2540 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
'use strict';
// Bot 主邏輯:長輪詢、指令、佇列、進度回報。
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createTelegram } = require('./telegram');
const { runCodex } = require('./codex');
const { createSessionStore } = require('./sessions');
// codex 的模型快取與全域預設(config.toml),給 footer 顯示 ctx% 與模型/強度用
function loadModelMeta() {
try {
const cache = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.codex', 'models_cache.json'), 'utf-8'));
const map = {};
for (const m of cache.models || []) map[m.slug] = m;
return map;
} catch {
return {};
}
}
function loadCodexDefaults() {
try {
const toml = fs.readFileSync(path.join(os.homedir(), '.codex', 'config.toml'), 'utf-8');
return {
model: toml.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] || '',
effort: toml.match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] || '',
tier: toml.match(/^\s*service_tier\s*=\s*"([^"]+)"/m)?.[1] || '',
};
} catch {
return { model: '', effort: '', tier: '' };
}
}
// — ✅ 16:06:32 | ctx 24% +1.2k | gpt-5.6-sol / high / Fast
function buildFooter(config, usage, modelMeta, codexDefaults) {
const time = new Date().toLocaleTimeString('zh-TW', { hour12: false });
const parts = [`— ✅ ${time}`];
const model = config.model || codexDefaults.model;
const meta = modelMeta[model];
if (usage) {
const used = (usage.input_tokens || 0) + (usage.output_tokens || 0);
// 本輪新消耗(扣掉 cache 命中的部分)
const fresh = Math.max(0, (usage.input_tokens || 0) - (usage.cached_input_tokens || 0)) + (usage.output_tokens || 0);
const freshStr = fresh >= 1000 ? `+${(fresh / 1000).toFixed(1)}k` : `+${fresh}`;
const cw = meta?.context_window;
parts.push(cw ? `ctx ${Math.round((used / cw) * 100)}% ${freshStr}` : freshStr);
}
if (model) {
const effort = config.reasoningEffort || codexDefaults.effort || meta?.default_reasoning_level || '';
const tier = config.serviceTier || codexDefaults.tier || '';
// 顯示速度層級的名稱(priority → Fast),對照模型快取
const tierName = tier
? (meta?.service_tiers?.find((t) => t.id === tier)?.name || tier)
: '';
parts.push([model, effort, tierName].filter(Boolean).join(' / '));
}
return `\n\n${parts.join(' | ')}`;
}
const NEW_COMMANDS = ['/new', '!clear', '!reset', '!new', '!清除', '!重置', '!新會話'];
const STATUS_COMMANDS = ['/status', '!status', '!狀態'];
const HELP_COMMANDS = ['/start', '/help', '!help', '!幫助'];
function startBot(config) {
const t = createTelegram(config.telegramToken);
const sessions = createSessionStore(config.stateDir);
const modelMeta = loadModelMeta();
const codexDefaults = loadCodexDefaults();
let botId = null;
let botUsername = null;
// 同一個工作目錄不能同時跑兩個 codex(會互相踩檔案),全域串行
let queue = Promise.resolve();
function shouldRespond(message) {
if (message.chat.type === 'private') return true;
if (message.reply_to_message?.from?.id === botId) return true;
const text = message.text || message.caption || '';
const entities = message.entities || message.caption_entities || [];
return entities.some((e) =>
(e.type === 'mention' &&
text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername.toLowerCase()}`) ||
(e.type === 'text_mention' && e.user?.id === botId)
);
}
function stripMention(text) {
if (!botUsername) return text.trim();
return text.replace(new RegExp(`@${botUsername}`, 'gi'), '').trim();
}
// 群組指令會帶 @BotName 後綴(例如 /new@MyBot
function matchCommand(text, commands) {
const head = text.split(/\s/, 1)[0].replace(new RegExp(`@${botUsername}$`, 'i'), '');
return commands.includes(head.toLowerCase());
}
function cleanTmp() {
const ttl = 24 * 60 * 60 * 1000;
try {
for (const f of fs.readdirSync(config.tmpDir)) {
const p = path.join(config.tmpDir, f);
try {
if (Date.now() - fs.statSync(p).mtimeMs > ttl) fs.unlinkSync(p);
} catch { /* ignore */ }
}
} catch { /* ignore */ }
}
// 取出訊息附圖(photo 取最大尺寸;image/* 的 document 也支援)
function pickPhoto(message) {
if (message.photo?.length) {
const largest = message.photo[message.photo.length - 1];
return { fileId: largest.file_id, uniqueId: largest.file_unique_id, ext: '.jpg' };
}
const doc = message.document;
if (doc && /^image\//.test(doc.mime_type || '')) {
const ext = path.extname(doc.file_name || '') || '.' + (doc.mime_type.split('/')[1] || 'png');
return { fileId: doc.file_id, uniqueId: doc.file_unique_id, ext };
}
return null;
}
function buildPrompt(message, content) {
const from = message.from || {};
const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者';
const sender = from.username ? `${name}@${from.username}` : name;
const parts = [];
const replied = message.reply_to_message;
if (replied && replied.from?.id !== botId && (replied.text || replied.caption)) {
parts.push(`【被回覆的訊息】\n${replied.text || replied.caption}`);
}
if (message.quote?.text) {
parts.push(`【使用者圈選引用的段落,請聚焦於此】\n${message.quote.text}`);
}
parts.push(`【來自 Telegram 的 ${sender}\n${content}`);
return parts.join('\n\n');
}
function statusText(chatId) {
const s = sessions.get(chatId);
return [
'📋 目前狀態',
`工作目錄:${config.workDir}`,
`沙盒模式:${config.sandbox}`,
`模型:${config.model || 'codex 預設)'}`,
`推理強度:${config.reasoningEffort || '(預設)'}`,
`速度:${config.serviceTier || '(預設)'}`,
s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
].join('\n');
}
const HELP_TEXT = [
'🤖 我是 Codex bot,訊息直接丟給我就會在專案目錄裡動工。',
'',
'指令:',
'/new — 開新會話(清除目前對話記憶)',
'/status — 查看會話與設定',
'/help — 顯示這則說明',
'',
'群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片(附文字說明)。',
].join('\n');
async function handleMessage(message) {
if (!message || message.from?.id === botId) return;
const chatId = message.chat.id;
const text = message.text || message.caption || '';
const photo = pickPhoto(message);
if (!text && !photo) return;
if (!shouldRespond(message)) return;
const content = stripMention(text);
if (matchCommand(content, HELP_COMMANDS)) {
await t.sendMessage(chatId, HELP_TEXT, { replyTo: message.message_id });
return;
}
if (matchCommand(content, NEW_COMMANDS)) {
sessions.clear(chatId);
await t.sendMessage(chatId, '🆕 已開新會話,之前的對話記憶已清除。', { replyTo: message.message_id });
return;
}
if (matchCommand(content, STATUS_COMMANDS)) {
await t.sendMessage(chatId, statusText(chatId), { replyTo: message.message_id });
return;
}
if (!content && !photo) {
await t.sendMessage(chatId, HELP_TEXT, { replyTo: message.message_id });
return;
}
await t.react(chatId, message.message_id, '👀');
const statusMsg = await t.sendMessage(chatId, '🤔 Codex 處理中...', { replyTo: message.message_id });
// Telegram 對 editMessageText 限流很兇:至少隔 3 秒、且不重疊
let lastEdit = 0;
let editing = false;
const onProgress = (progressText) => {
const now = Date.now();
if (editing || now - lastEdit < 3000) return;
editing = true;
lastEdit = now;
const body = `🚧 Codex 處理中...\n\n${progressText}`;
t.tg('editMessageText', {
chat_id: chatId,
message_id: statusMsg.message_id,
text: body.length > 3900 ? body.slice(0, 3900) + '...' : body,
}).catch(() => {}).finally(() => { editing = false; });
};
try {
const images = [];
if (photo) {
cleanTmp();
const dest = path.join(config.tmpDir, `${Date.now()}-${photo.uniqueId}${photo.ext}`);
await t.downloadFile(photo.fileId, dest);
images.push(dest);
}
const prompt = buildPrompt(message, content || '(使用者只傳了圖片,請描述並依上下文處理)');
const existing = sessions.get(chatId);
let result;
try {
result = await runCodex({ config, prompt, threadId: existing?.threadId || null, images, onProgress });
} catch (err) {
// 舊會話可能已被 codex 清掉,找不到就自動開新會話重試一次
if (existing && /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread)/i.test(err.message)) {
sessions.clear(chatId);
result = await runCodex({ config, prompt, threadId: null, images, onProgress });
} else {
throw err;
}
}
if (result.threadId) sessions.set(chatId, result.threadId);
const footer = buildFooter(config, result.usage, modelMeta, codexDefaults);
const html = t.mdToTgHtml((result.text || 'Codex 沒有回覆文字)') + footer);
await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true });
await t.react(chatId, message.message_id, '👍');
} catch (error) {
console.error('處理訊息失敗:', error);
await t.editOrSplit(chatId, statusMsg.message_id, `❌ 發生錯誤:${error.message}`).catch(() => {});
await t.react(chatId, message.message_id, '');
}
}
async function pollLoop() {
let offset = 0;
for (;;) {
try {
const updates = await t.tg('getUpdates', { offset, timeout: 50, allowed_updates: ['message'] });
for (const update of updates) {
offset = update.update_id + 1;
queue = queue
.then(() => handleMessage(update.message))
.catch((err) => console.error('處理訊息錯誤:', err));
}
} catch (err) {
console.error('輪詢錯誤:', err.message);
await new Promise((r) => setTimeout(r, 5000));
}
}
}
async function start() {
const me = await t.tg('getMe', {});
botId = me.id;
botUsername = me.username;
await t.tg('setMyCommands', {
commands: [
{ command: 'new', description: '開新會話(清除對話記憶)' },
{ command: 'status', description: '查看會話與設定' },
{ command: 'help', description: '使用說明' },
],
}).catch(() => {});
console.log(`✅ @${botUsername} 已啟動`);
console.log(` 工作目錄:${config.workDir}`);
console.log(` 沙盒模式:${config.sandbox}`);
await pollLoop();
}
return { start };
}
module.exports = { startBot, buildFooter, loadModelMeta, loadCodexDefaults };
+220
View File
@@ -0,0 +1,220 @@
'use strict';
// Codex CLI 執行層:spawn `codex exec --json`,逐行解析 JSONL 事件。
// Windows 上 codex 是 .cmd shimnode codex.js 的包裝),直接解析出 codex.js
// 用 node 執行,避開 shell:true 的引號問題與 DEP0190 警告。
const { spawn, execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
let cachedCommand = null;
// 在 PATH 中尋找 codex shim,回推 @openai/codex/bin/codex.js 的實際位置
function findCodexJs() {
const dirs = (process.env.PATH || '').split(path.delimiter);
for (const dir of dirs) {
if (!dir) continue;
const shim = path.join(dir, process.platform === 'win32' ? 'codex.cmd' : 'codex');
if (!fs.existsSync(shim)) continue;
const candidates = [
path.join(dir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
// unix 下 shim 是 symlink,指向 lib/node_modules 下的實體
path.join(dir, '..', 'lib', 'node_modules', '@openai', 'codex', 'bin', 'codex.js'),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
}
// 最後手段:問 npm 全域 root
try {
const root = execSync('npm root -g', { encoding: 'utf-8', windowsHide: true }).trim();
const c = path.join(root, '@openai', 'codex', 'bin', 'codex.js');
if (fs.existsSync(c)) return c;
} catch { /* ignore */ }
return null;
}
// 回傳 { cmd, baseArgs, shell }:實際 spawn 的指令與前置參數
function resolveCodexCommand(config) {
if (cachedCommand) return cachedCommand;
const custom = config && config.codexPath;
if (custom) {
cachedCommand = custom.endsWith('.js')
? { cmd: process.execPath, baseArgs: [custom], shell: false }
: { cmd: custom, baseArgs: [], shell: false };
return cachedCommand;
}
if (process.platform === 'win32') {
const js = findCodexJs();
if (js) {
cachedCommand = { cmd: process.execPath, baseArgs: [js], shell: false };
return cachedCommand;
}
// 找不到實體時退回 shell(.cmd 需要 shell 才能執行)
cachedCommand = { cmd: 'codex', baseArgs: [], shell: true };
return cachedCommand;
}
cachedCommand = { cmd: 'codex', baseArgs: [], shell: false };
return cachedCommand;
}
function killTree(proc) {
if (process.platform === 'win32') {
// node shim 的子程序(codex.exe)不會隨父程序死掉,要整棵砍
try { execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' }); } catch { /* ignore */ }
} else {
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
}
}
const PROGRESS_LABELS = {
command_execution: '⚙️ 執行指令',
file_change: '✏️ 修改檔案',
mcp_tool_call: '🔧 呼叫工具',
web_search: '🔎 搜尋網路',
reasoning: '🤔 思考中',
};
/**
* 執行一輪 Codex 對話。
* @param {object} opts
* @param {object} opts.config 載入後的設定
* @param {string} opts.prompt 使用者 prompt(走 stdin,任意內容都安全)
* @param {string|null} opts.threadId 既有會話 idnull 表示開新會話
* @param {string[]} [opts.images] 附圖檔案路徑
* @param {(text: string) => void} [opts.onProgress] 進度回呼
* @returns {Promise<{text: string, threadId: string|null, usage: object|null}>}
*/
function runCodex({ config, prompt, threadId, images = [], onProgress }) {
return new Promise((resolve, reject) => {
const { cmd, baseArgs, shell } = resolveCodexCommand(config);
const args = [...baseArgs, 'exec'];
if (threadId) args.push('resume', threadId);
args.push('--json', '--skip-git-repo-check');
if (threadId) {
// resume 子指令不吃 -s / -m / --color,改走 -c 設定覆寫
args.push('-c', `sandbox_mode="${config.sandbox}"`);
if (config.model) args.push('-c', `model="${config.model}"`);
} else {
args.push('--color', 'never', '-s', config.sandbox);
if (config.model) args.push('-m', config.model);
}
if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`);
if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`);
for (const img of images) args.push('-i', img);
args.push('-'); // prompt 從 stdin 讀
const proc = spawn(cmd, args, {
cwd: config.workDir,
env: { ...process.env },
shell,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
proc.stdin.write(prompt);
proc.stdin.end();
let buffer = '';
let stderrTail = '';
let resultThreadId = threadId || null;
let usage = null;
let turnCompleted = false;
let turnFailedMessage = null;
const messages = [];
const timer = setTimeout(() => {
killTree(proc);
reject(new Error(`Codex 逾時(${config.timeoutMinutes} 分鐘)`));
}, config.timeoutMinutes * 60 * 1000);
const handleEvent = (event) => {
const item = event.item;
switch (event.type) {
case 'thread.started':
if (event.thread_id) resultThreadId = event.thread_id;
break;
case 'turn.completed':
turnCompleted = true;
usage = event.usage || null;
break;
case 'turn.failed':
turnFailedMessage = event.error?.message || 'turn.failed(未提供原因)';
break;
case 'error':
turnFailedMessage = event.message || event.error?.message || 'Codex 回報錯誤';
break;
case 'item.started':
case 'item.updated': {
if (!onProgress || !item) break;
const label = PROGRESS_LABELS[item.type];
if (!label) break;
let detail = '';
if (item.type === 'command_execution' && item.command) {
detail = item.command.length > 300 ? item.command.slice(0, 300) + '...' : item.command;
}
onProgress(`${label}...${detail ? '\n' + detail : ''}`);
break;
}
case 'item.completed': {
if (!item) break;
if (item.type === 'agent_message' && item.text) {
messages.push(item.text);
if (onProgress) {
const t = item.text.trim();
onProgress('💬 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t));
}
} else if (item.type === 'error' && item.message) {
turnFailedMessage = item.message;
} else if (item.type === 'reasoning' && item.text && onProgress) {
const t = item.text.trim();
onProgress('🤔 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t));
}
break;
}
default:
break;
}
};
proc.stdout.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop(); // 最後一段可能被截斷,留到下一批
for (const line of lines) {
if (!line.trim()) continue;
try {
handleEvent(JSON.parse(line));
} catch { /* 非 JSON 行(log 雜訊)直接忽略 */ }
}
});
proc.stderr.on('data', (chunk) => {
stderrTail = (stderrTail + chunk.toString()).slice(-2000);
if (process.env.DEBUG) console.error('[codex stderr]', chunk.toString());
});
proc.on('close', (code) => {
clearTimeout(timer);
if (turnFailedMessage) {
return reject(new Error(`Codex 執行失敗:${turnFailedMessage}`));
}
if (turnCompleted || messages.length > 0) {
return resolve({ text: messages.join('\n\n'), threadId: resultThreadId, usage });
}
const hint = stderrTail
.split('\n')
.filter((l) => l.trim() && !/^\d{4}-\d{2}-\d{2}T.*(ERROR|WARN) codex_models_manager/.test(l))
.slice(-5)
.join('\n');
reject(new Error(`codex 結束(exit code ${code}${hint ? '\n' + hint : ''}`));
});
proc.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`無法啟動 codex${err.message}(請確認已安裝 @openai/codex 並在 PATH 中)`));
});
});
}
module.exports = { runCodex, resolveCodexCommand, findCodexJs };
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// 設定載入與驗證。優先序:env 變數 > 設定檔 > 預設值。
const fs = require('fs');
const path = require('path');
const CONFIG_BASENAME = 'tgcodex.config.json';
const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'];
const DEFAULTS = {
// Telegram Bot tokenBotFather 取得)
telegramToken: '',
// Codex 可讀寫的專案目錄(相對路徑以設定檔所在目錄為基準)
workDir: '.',
// Codex 沙盒模式,預設可讀寫專案目錄
sandbox: 'workspace-write',
// 指定模型;空字串 = 使用 codex 預設
model: '',
// 推理強度(low/medium/high/xhigh/max/ultra…依模型而定);空字串 = codex 預設
reasoningEffort: '',
// 速度(service tier,如 priority = Fast 1.5x);空字串 = codex 預設
serviceTier: '',
// 單次回應逾時(分鐘)
timeoutMinutes: 30,
// pm2 程序名稱(web 檢視工具會叫 <pm2Name>-web
pm2Name: 'tgcodex-bot',
// 內建 PM2 web 檢視工具
web: { enabled: true, port: 3799, host: '127.0.0.1' },
// 手動指定 codex 入口(codex.js 或執行檔路徑);空字串 = 自動偵測
codexPath: '',
};
function findConfigPath(explicit) {
if (explicit) return path.resolve(explicit);
if (process.env.TGCODEX_CONFIG) return path.resolve(process.env.TGCODEX_CONFIG);
return path.resolve(process.cwd(), CONFIG_BASENAME);
}
function loadConfig(explicitPath) {
const file = findConfigPath(explicitPath);
let raw = {};
if (fs.existsSync(file)) {
try {
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch (err) {
throw new Error(`設定檔 ${file} 不是合法 JSON${err.message}`);
}
}
const cfg = {
...DEFAULTS,
...raw,
web: { ...DEFAULTS.web, ...(raw.web || {}) },
};
if (process.env.TGCODEX_TOKEN) cfg.telegramToken = process.env.TGCODEX_TOKEN;
if (process.env.TGCODEX_WORKDIR) cfg.workDir = process.env.TGCODEX_WORKDIR;
if (process.env.TGCODEX_SANDBOX) cfg.sandbox = process.env.TGCODEX_SANDBOX;
if (process.env.TGCODEX_MODEL) cfg.model = process.env.TGCODEX_MODEL;
if (process.env.TGCODEX_WEB_PORT) cfg.web.port = Number(process.env.TGCODEX_WEB_PORT);
if (process.env.TGCODEX_WEB_HOST) cfg.web.host = process.env.TGCODEX_WEB_HOST;
cfg.configPath = file;
cfg.configDir = path.dirname(file);
cfg.workDir = path.resolve(cfg.configDir, cfg.workDir);
cfg.stateDir = path.join(cfg.configDir, '.tgcodex');
cfg.logsDir = path.join(cfg.stateDir, 'logs');
cfg.tmpDir = path.join(cfg.stateDir, 'tmp');
if (!SANDBOX_MODES.includes(cfg.sandbox)) {
throw new Error(`sandbox 必須是 ${SANDBOX_MODES.join(' / ')},收到:${cfg.sandbox}`);
}
if (!/^[A-Za-z0-9._-]+$/.test(cfg.pm2Name)) {
throw new Error(`pm2Name 只能包含英數字、點、底線、連字號:${cfg.pm2Name}`);
}
if (cfg.web.enabled) {
const port = Number(cfg.web.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`web.port 必須是 1-65535 的整數:${cfg.web.port}`);
}
cfg.web.port = port;
}
const minutes = Number(cfg.timeoutMinutes);
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
cfg.timeoutMinutes = minutes;
return cfg;
}
// bot 執行前的額外檢查(web 檢視工具不需要 token,所以拆開)
function assertBotConfig(cfg) {
if (!cfg.telegramToken) {
throw new Error(
`缺少 Telegram token。請在 ${cfg.configPath} 設定 telegramToken` +
'或設環境變數 TGCODEX_TOKEN。(先跑 `telegram-codex-bot init` 產生設定檔)'
);
}
if (!fs.existsSync(cfg.workDir)) {
throw new Error(`workDir 不存在:${cfg.workDir}`);
}
}
function ensureStateDirs(cfg) {
for (const dir of [cfg.stateDir, cfg.logsDir, cfg.tmpDir]) {
fs.mkdirSync(dir, { recursive: true });
}
}
module.exports = { CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, loadConfig, assertBotConfig, ensureStateDirs, findConfigPath };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
// 環境檢查:start 前確認 codex CLI、pm2、git 等工具是否就緒。
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
function tryExec(cmd) {
try {
return execSync(cmd, { encoding: 'utf-8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 }).trim();
} catch {
return null;
}
}
// 回傳 [{ name, ok, required, message }]
function runChecks() {
const checks = [];
const nodeMajor = Number(process.versions.node.split('.')[0]);
checks.push({
name: 'Node.js',
ok: nodeMajor >= 18,
required: true,
message: nodeMajor >= 18 ? process.version : `${process.version}(需要 ≥ 18`,
});
const codexVer = tryExec('codex --version');
checks.push({
name: 'Codex CLI',
ok: !!codexVer,
required: true,
message: codexVer || '找不到 codex,請安裝:npm install -g @openai/codex',
});
if (codexVer) {
const authFile = path.join(os.homedir(), '.codex', 'auth.json');
const loggedIn = fs.existsSync(authFile);
checks.push({
name: 'Codex 登入',
ok: loggedIn,
required: false,
message: loggedIn ? '已登入' : '尚未登入,請先執行:codex loginbot 啟動後才需要)',
});
}
const pm2Ver = tryExec('pm2 -v');
checks.push({
name: 'pm2',
ok: !!pm2Ver,
required: true,
message: pm2Ver ? `v${pm2Ver.split('\n').pop()}` : '找不到 pm2,請安裝:npm install -g pm2',
});
const gitVer = tryExec('git --version');
checks.push({
name: 'git',
ok: !!gitVer,
required: false,
message: gitVer || '找不到 git(非必要:bot 以 --skip-git-repo-check 執行,但 Codex 要做版控操作時會需要)',
});
return checks;
}
function printChecks(checks) {
for (const c of checks) {
const icon = c.ok ? '✅' : c.required ? '❌' : '⚠️';
console.log(`${icon} ${c.name}${c.message}`);
}
}
function hasBlocker(checks) {
return checks.some((c) => c.required && !c.ok);
}
module.exports = { runChecks, printChecks, hasBlocker };
+18
View File
@@ -0,0 +1,18 @@
'use strict';
// Bot 進程進入點(pm2 跑的就是這支)。
// 設定檔位置:環境變數 TGCODEX_CONFIG,或目前目錄的 tgcodex.config.json。
const { loadConfig, assertBotConfig, ensureStateDirs } = require('./config');
const { startBot } = require('./bot');
process.on('unhandledRejection', (err) => console.error('Unhandled rejection', err));
process.on('uncaughtException', (err) => console.error('Uncaught exception', err));
(async () => {
const config = loadConfig(process.argv[2]);
assertBotConfig(config);
ensureStateDirs(config);
await startBot(config).start();
})().catch((err) => {
console.error('啟動失敗:', err.message);
process.exit(1);
});
+132
View File
@@ -0,0 +1,132 @@
'use strict';
// pm2 操作共用層:CLI 與 web 控制台都用這裡的函式。
const { exec } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const TGCODEX_HOME = path.join(os.homedir(), '.tgcodex');
const CONSOLE_NAME = 'tgcodex-console';
const CONSOLE_SETTINGS_FILE = path.join(TGCODEX_HOME, 'console.json');
const WEB_SCRIPT = path.join(__dirname, 'web', 'server.js');
const BOT_SCRIPT = path.join(__dirname, 'index.js');
function pm2Exec(cmd) {
return new Promise((resolve, reject) => {
exec(`pm2 ${cmd}`, { windowsHide: true, maxBuffer: 20 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) return reject(new Error(((stdout || '') + (stderr || '')).trim() || err.message));
resolve(stdout);
});
});
}
async function jlist() {
const out = await pm2Exec('jlist');
const i = out.indexOf('[');
return JSON.parse(i >= 0 ? out.slice(i) : '[]');
}
async function hasApp(name) {
return (await jlist()).some((p) => p.name === name);
}
function saveList() {
return pm2Exec('save').catch(() => {});
}
// 對存在於 jlist 的程序執行操作(名稱先驗證存在,杜絕注入)
async function actionByName(action, name) {
const list = await jlist();
if (!list.some((p) => p.name === name)) throw new Error(`找不到 PM2 程序:${name}`);
await pm2Exec(`${action} ${JSON.stringify(name)}`);
if (action === 'delete') await saveList();
}
// 為單一 bot 產生 ecosystem 檔並掛上 pm2
async function startBotOnPm2(cfg) {
const eco = path.join(cfg.stateDir, 'ecosystem.config.js');
const app = {
name: cfg.pm2Name,
script: BOT_SCRIPT,
cwd: cfg.configDir,
env: { TGCODEX_CONFIG: cfg.configPath },
out_file: path.join(cfg.logsDir, 'bot-out.log'),
error_file: path.join(cfg.logsDir, 'bot-error.log'),
restart_delay: 5000,
max_restarts: 20,
min_uptime: '10s',
merge_logs: true,
time: true,
};
fs.writeFileSync(eco, `module.exports = ${JSON.stringify({ apps: [app] }, null, 2)};\n`);
if (await hasApp(cfg.pm2Name)) {
// 已在 pm2 上(可能 stopped):restart 會套用既有設定,先刪再啟才吃新 ecosystem
await pm2Exec(`delete ${JSON.stringify(cfg.pm2Name)}`).catch(() => {});
}
await pm2Exec(`start ${JSON.stringify(eco)}`);
await saveList();
}
// ---------- 控制台(web UI)本體 ----------
function loadConsoleSettings() {
const defaults = { port: 3799, host: '127.0.0.1' };
try {
return { ...defaults, ...JSON.parse(fs.readFileSync(CONSOLE_SETTINGS_FILE, 'utf-8')) };
} catch {
return defaults;
}
}
function saveConsoleSettings(settings) {
fs.mkdirSync(TGCODEX_HOME, { recursive: true });
fs.writeFileSync(CONSOLE_SETTINGS_FILE, JSON.stringify(settings, null, 2) + '\n');
}
function consoleUrl(settings) {
return `http://${settings.host === '0.0.0.0' ? 'localhost' : settings.host}:${settings.port}`;
}
// 確保控制台掛在 pm2 上;已存在就不動它
async function ensureConsoleOnPm2({ restart = false } = {}) {
const settings = loadConsoleSettings();
fs.mkdirSync(path.join(TGCODEX_HOME, 'logs'), { recursive: true });
const eco = path.join(TGCODEX_HOME, 'console.ecosystem.config.js');
const app = {
name: CONSOLE_NAME,
script: WEB_SCRIPT,
cwd: TGCODEX_HOME,
env: {
TGCODEX_WEB_PORT: String(settings.port),
TGCODEX_WEB_HOST: settings.host,
},
out_file: path.join(TGCODEX_HOME, 'logs', 'console-out.log'),
error_file: path.join(TGCODEX_HOME, 'logs', 'console-error.log'),
restart_delay: 5000,
merge_logs: true,
time: true,
};
fs.writeFileSync(eco, `module.exports = ${JSON.stringify({ apps: [app] }, null, 2)};\n`);
const exists = await hasApp(CONSOLE_NAME);
if (exists && restart) {
await pm2Exec(`delete ${JSON.stringify(CONSOLE_NAME)}`).catch(() => {});
}
if (!exists || restart) {
await pm2Exec(`start ${JSON.stringify(eco)}`);
await saveList();
}
return { settings, url: consoleUrl(settings), alreadyRunning: exists && !restart };
}
module.exports = {
TGCODEX_HOME,
CONSOLE_NAME,
pm2Exec,
jlist,
hasApp,
actionByName,
startBotOnPm2,
loadConsoleSettings,
saveConsoleSettings,
consoleUrl,
ensureConsoleOnPm2,
};
+193
View File
@@ -0,0 +1,193 @@
'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 };
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// 每個 chat 一個 Codex 會話(thread),存在 .tgcodex/sessions.json。
const fs = require('fs');
const path = require('path');
function createSessionStore(stateDir) {
const file = path.join(stateDir, 'sessions.json');
let data = { chats: {} };
try {
data = JSON.parse(fs.readFileSync(file, 'utf-8'));
if (!data.chats) data.chats = {};
} catch { /* 首次啟動沒有檔案 */ }
function save() {
try {
fs.writeFileSync(file, JSON.stringify(data, null, 2));
} catch (err) {
console.error('儲存 sessions.json 失敗:', err.message);
}
}
return {
get(chatId) {
return data.chats[String(chatId)] || null;
},
set(chatId, threadId) {
data.chats[String(chatId)] = { threadId, updatedAt: new Date().toISOString() };
save();
},
clear(chatId) {
delete data.chats[String(chatId)];
save();
},
};
}
module.exports = { createSessionStore };
+102
View File
@@ -0,0 +1,102 @@
'use strict';
// Telegram Bot API 薄封裝:零依賴,直接用內建 fetch。
// 含 Markdown→Telegram HTML 轉換(安全子集)、4000 字切分、HTML 解析失敗自動退回純文字。
function createTelegram(token) {
const API = `https://api.telegram.org/bot${token}`;
async function tg(method, params) {
const res = await fetch(`${API}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await res.json();
if (!data.ok) throw new Error(`${method} 失敗:${data.description}`);
return data.result;
}
// Markdown → Telegram HTML(只轉安全子集:code block、inline code、連結、粗體、標題)。
// 單星號斜體與底線刻意不轉:會誤傷清單符號 * 與 username 的底線。
const SENTINEL = String.fromCharCode(0);
function mdToTgHtml(md) {
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const stash = [];
const put = (html) => SENTINEL + (stash.push(html) - 1) + SENTINEL;
let text = md.replace(/```\w*\n?([\s\S]*?)```/g, (_, code) => put(`<pre>${esc(code.replace(/\n$/, ''))}</pre>`));
text = text.replace(/`([^`\n]+)`/g, (_, code) => put(`<code>${esc(code)}</code>`));
text = esc(text);
text = text.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2">$1</a>');
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
text = text.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
return text.replace(new RegExp(`${SENTINEL}(\\d+)${SENTINEL}`, 'g'), (_, i) => stash[+i]);
}
// 送出訊息;HTML 解析失敗(切分把標籤切壞等)自動退回純文字。
async function sendMessage(chatId, text, { replyTo, html = false } = {}) {
const params = { chat_id: chatId, text };
if (replyTo) params.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
if (html) {
try {
return await tg('sendMessage', { ...params, parse_mode: 'HTML' });
} catch { /* fallback to plain */ }
}
return tg('sendMessage', params);
}
// 超過 4096 上限就切段(留餘裕切 4000);只有第一段帶 reply。
async function sendSplit(chatId, text, { replyTo, html = false } = {}) {
const chunks = text.match(/[\s\S]{1,4000}/g) || ['(空回應)'];
let first = null;
for (const chunk of chunks) {
const msg = await sendMessage(chatId, chunk, { replyTo, html });
if (!first) first = msg;
replyTo = undefined;
}
return first;
}
// 編輯既有訊息;過長則第一段 edit、其餘續傳新訊息。
async function editOrSplit(chatId, messageId, text, { html = false } = {}) {
const edit = async (t) => {
const params = { chat_id: chatId, message_id: messageId, text: t };
if (html) {
try {
return await tg('editMessageText', { ...params, parse_mode: 'HTML' });
} catch (err) {
// 「訊息沒變」不算錯;其他 HTML 失敗退回純文字
if (String(err.message).includes('message is not modified')) return;
return tg('editMessageText', params);
}
}
return tg('editMessageText', params);
};
if (text.length <= 4000) return edit(text);
const chunks = text.match(/[\s\S]{1,4000}/g) || [];
await edit(chunks[0]);
for (let i = 1; i < chunks.length; i++) await sendMessage(chatId, chunks[i], { html });
}
// 表情回應當狀態指示(👀 收到、👍 完成);很多聊天型別不支援,失敗直接吞。
function react(chatId, messageId, emoji) {
return tg('setMessageReaction', {
chat_id: chatId,
message_id: messageId,
reaction: emoji ? [{ type: 'emoji', emoji }] : [],
}).catch(() => {});
}
async function downloadFile(fileId, destPath) {
const fs = require('fs');
const info = await tg('getFile', { file_id: fileId });
const res = await fetch(`https://api.telegram.org/file/bot${token}/${info.file_path}`);
if (!res.ok) throw new Error(`下載檔案失敗:HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(destPath, buf);
return destPath;
}
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile };
}
module.exports = { createTelegram };
+657
View File
@@ -0,0 +1,657 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>tgcodex 控制台</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', 'Microsoft JhengHei', system-ui, sans-serif;
background: #0f1117; color: #e2e8f0; min-height: 100vh; padding: 24px;
}
.container { max-width: 1150px; margin: 0 auto; }
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; flex-wrap: wrap; gap: 10px; }
h1 { font-size: 20px; font-weight: 700; }
h1 span { color: #5865f2; }
.meta { font-size: 12px; color: #64748b; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid #262a38; }
.tab { padding: 9px 18px; font-size: 14px; cursor: pointer; color: #94a3b8; border: none; background: none;
border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tab.active { color: #e2e8f0; border-bottom-color: #5865f2; font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.card { background: #1a1d27; border: 1px solid #262a38; border-radius: 12px; overflow: hidden; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 14px; color: #94a3b8; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid #262a38; white-space: nowrap; }
td { padding: 12px 14px; border-bottom: 1px solid #20242f; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(88,101,242,0.04); }
.badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px;
border-radius: 20px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.badge::before { content: '●'; font-size: 8px; }
.badge-online { background: rgba(59,165,93,0.15); color: #3ba55d; }
.badge-stopped, .badge-not_started { background: rgba(100,116,139,0.15); color: #64748b; }
.badge-error, .badge-errored { background: rgba(237,66,69,0.15); color: #ed4245; }
.badge-launching, .badge-waiting.restart { background: rgba(88,101,242,0.15); color: #5865f2; }
.badge-unknown { background: rgba(148,163,184,0.15); color: #94a3b8; }
.script-name { font-size: 11px; color: #475569; cursor: help; }
.port-link { display: inline-block; background: rgba(88,101,242,0.15); color: #5865f2;
padding: 2px 8px; border-radius: 4px; font-size: 12px; font-family: monospace;
margin: 1px; text-decoration: none; }
.port-link:hover { background: rgba(88,101,242,0.3); }
.mono { font-family: monospace; color: #94a3b8; font-size: 12px; }
.btn { border: none; border-radius: 6px; padding: 5px 10px; font-size: 12px; cursor: pointer;
background: #262a38; color: #cbd5e1; transition: filter 0.15s; white-space: nowrap; }
.btn:hover { filter: brightness(1.25); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #5865f2; color: #fff; padding: 8px 16px; font-size: 13px; }
.btn-success { background: rgba(59,165,93,0.2); color: #3ba55d; }
.btn-warning { background: rgba(250,166,26,0.15); color: #faa61a; }
.btn-danger { background: rgba(237,66,69,0.15); color: #ed4245; }
.btn-ghost { background: rgba(100,116,139,0.15); color: #94a3b8; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.empty { text-align: center; color: #475569; padding: 34px; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
label.auto { font-size: 12px; color: #94a3b8; display: flex; align-items: center; gap: 5px; cursor: pointer; }
/* modal 表單 */
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 50;
align-items: flex-start; justify-content: center; padding: 40px 16px; overflow-y: auto; }
.modal-backdrop.show { display: flex; }
.modal { background: #1a1d27; border: 1px solid #2e3345; border-radius: 14px; width: 100%; max-width: 520px;
padding: 22px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.modal h2 { font-size: 16px; margin-bottom: 16px; }
.field { margin-bottom: 13px; }
.field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; }
.field label b { color: #ed4245; }
.field input, .field select {
width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px;
color: #e2e8f0; padding: 9px 11px; font-size: 13px; font-family: inherit;
}
.field input:focus, .field select:focus { outline: none; border-color: #5865f2; }
.field .hint { font-size: 11px; color: #475569; margin-top: 4px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.check { display: flex; align-items: center; gap: 7px; font-size: 13px; color: #cbd5e1; cursor: pointer; }
/* log 面板 */
#log-panel { display: none; margin-top: 18px; }
#log-panel .log-header { display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; border-bottom: 1px solid #262a38; flex-wrap: wrap; gap: 8px; }
#log-title { font-size: 13px; font-weight: 600; }
#log-title small { color: #64748b; font-weight: 400; margin-left: 8px; }
#log-content { padding: 14px; font-family: Consolas, monospace; font-size: 12px; line-height: 1.55;
white-space: pre-wrap; word-break: break-all; max-height: 460px; overflow-y: auto;
color: #a8b3c5; background: #12141c; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
background: #262a38; color: #e2e8f0; padding: 10px 18px; border-radius: 8px; font-size: 13px;
opacity: 0; transition: all 0.25s; pointer-events: none; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 99; max-width: 90vw; }
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
.toast.success { border-left: 3px solid #3ba55d; }
.toast.error { border-left: 3px solid #ed4245; }
.toast.warn { border-left: 3px solid #faa61a; }
.notice { display: flex; gap: 12px; align-items: flex-start; background: rgba(250,166,26,0.08);
border: 1px solid rgba(250,166,26,0.35); border-radius: 10px; padding: 12px 14px;
font-size: 13px; line-height: 1.7; color: #cbd5e1; margin-bottom: 14px; }
.notice-icon { font-size: 18px; line-height: 1.4; }
.notice b { color: #faa61a; }
.notice-steps { color: #e2e8f0; }
.notice-steps b { color: #e2e8f0; background: rgba(88,101,242,0.18); padding: 1px 7px;
border-radius: 5px; font-weight: 600; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><span></span> tgcodex 控制台</h1>
<div class="toolbar">
<label class="auto"><input type="checkbox" id="auto-refresh" checked> 自動更新(5s</label>
<button class="btn" onclick="refreshActive()">🔄 重新整理</button>
</div>
</header>
<div class="tabs">
<button class="tab active" id="tabbtn-bots" onclick="switchTab('bots')">🤖 Bot 管理</button>
<button class="tab" id="tabbtn-pm2" onclick="switchTab('pm2')">📊 PM2</button>
</div>
<!-- ============ Bot 管理 ============ -->
<div class="tab-panel active" id="tab-bots">
<div class="notice">
<div class="notice-icon">📣</div>
<div>
<b>要在群組使用的話,記得關閉 Group Privacy</b>,否則 bot 收不到群組訊息(@ 它也沒用):<br>
<span class="notice-steps">Telegram 開 <b>@BotFather</b> 迷你 APP → 選該機器人 → <b>Bot Settings</b> → 關閉 <b>Group Privacy</b></span><br>
<span style="color:#64748b">改完後把 bot 踢出群組再重新拉回才會生效。只私訊使用的話可以不管這個。</span>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
<button class="btn btn-primary" onclick="openCreate()"> 新增 Bot</button>
</div>
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>名稱</th><th>狀態</th><th>工作目錄</th><th>沙盒</th><th>Token</th>
<th>記憶體</th><th>重啟</th><th>運行時間</th><th>操作</th>
</tr>
</thead>
<tbody id="bots-tbody">
<tr><td colspan="9" class="empty">載入中...</td></tr>
</tbody>
</table>
</div>
<p class="meta" style="margin-top:12px">
Bot 設定存於 <code>~/.tgcodex/bots/&lt;名稱&gt;/</code>;啟動後掛在 pm2 上(同名程序)。
私訊直接回應;群組中要 @bot 或回覆 bot 的訊息才會回應。
</p>
</div>
<!-- ============ PM2 ============ -->
<div class="tab-panel" id="tab-pm2">
<div class="card table-wrap">
<table>
<thead>
<tr>
<th>名稱</th><th>狀態</th><th>Port</th><th>PID</th><th>CPU</th>
<th>記憶體</th><th>重啟</th><th>運行時間</th><th>Log</th><th>操作</th>
</tr>
</thead>
<tbody id="pm2-tbody">
<tr><td colspan="10" class="empty">載入中...</td></tr>
</tbody>
</table>
</div>
<p class="meta" style="margin-top:12px">資料來源:<code>pm2 jlist</code> · 顯示整台機器所有 pm2 程序</p>
</div>
<!-- log 面板(兩個分頁共用) -->
<div class="card" id="log-panel">
<div class="log-header">
<div id="log-title"></div>
<div class="toolbar">
<label class="auto"><input type="checkbox" id="log-auto-refresh"> 自動更新(5s</label>
<button class="btn btn-ghost" onclick="closeLog()">✖ 關閉</button>
</div>
</div>
<pre id="log-content"></pre>
</div>
</div>
<!-- 新增 / 編輯 Bot -->
<div class="modal-backdrop" id="bot-modal">
<div class="modal">
<h2 id="bot-modal-title">新增 Bot</h2>
<div class="field">
<label>名稱 <b>*</b></label>
<input id="f-name" placeholder="my-project-bot" autocomplete="off">
<div class="hint">英數字、點、底線、連字號;也是 pm2 程序名稱,建立後不可改</div>
</div>
<div class="field">
<label>Telegram Bot Token <b id="f-token-req">*</b></label>
<input id="f-token" placeholder="123456:ABC-DEF..." autocomplete="off">
<div class="hint" id="f-token-hint">跟 @BotFather 申請;儲存時會自動驗證</div>
</div>
<div class="field">
<label>工作目錄(Codex 可讀寫)<b>*</b></label>
<div style="display:flex;gap:8px">
<input id="f-workdir" placeholder="按「瀏覽」選擇資料夾" autocomplete="off" style="flex:1">
<button type="button" class="btn" id="f-workdir-btn" onclick="pickFolder()" style="padding:0 14px">📂 瀏覽…</button>
</div>
<div class="hint">Codex 預設能讀寫這個目錄(workspace-write);選擇視窗會開在這台機器的桌面上</div>
</div>
<div class="field">
<label>沙盒模式</label>
<select id="f-sandbox">
<option value="workspace-write" selected>workspace-write(可讀寫工作目錄,預設)</option>
<option value="read-only">read-only(唯讀)</option>
<option value="danger-full-access">danger-full-access(不設限,危險)</option>
</select>
</div>
<div class="field">
<label>模型</label>
<select id="f-model" onchange="syncEffortTier()"></select>
<div class="hint">清單來自本機 codex 的模型快取</div>
</div>
<div class="field">
<label>推理強度</label>
<select id="f-effort"></select>
</div>
<div class="field">
<label>速度</label>
<select id="f-tier"></select>
</div>
<div class="field">
<label>逾時(分鐘)</label>
<input id="f-timeout" type="number" value="30" min="1">
</div>
<label class="check" id="f-autostart-wrap"><input type="checkbox" id="f-autostart" checked> 建立後立即啟動</label>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeModal()">取消</button>
<button class="btn btn-primary" id="bot-modal-save" onclick="saveBot()">儲存</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let activeTab = 'bots';
let editingName = null; // null = 新增模式
let currentLog = null;
let logTimer = null;
function esc(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtUptime(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
const h = Math.floor(s/3600);
if (h < 24) return h + 'h ' + Math.floor((s%3600)/60) + 'm';
return Math.floor(h/24) + 'd ' + (h%24) + 'h';
}
function fmtBytes(b) {
if (!b) return '-';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
return (b/1073741824).toFixed(1) + ' GB';
}
let toastTimer;
function showToast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show ' + type;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 4000);
}
async function fetchJson(url, options) {
const res = await fetch(url, options);
const text = await res.text();
if (!(res.headers.get('content-type') || '').includes('application/json')) {
throw new Error('API 沒有回 JSON' + (text.trim().slice(0,160) || ('HTTP ' + res.status)));
}
const data = JSON.parse(text);
if (!res.ok || data.error) throw new Error(data.error || ('HTTP ' + res.status));
return data;
}
function postJson(url, body) {
return fetchJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
}
// ---------- tabs ----------
function switchTab(tab) {
activeTab = tab;
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(el => el.classList.remove('active'));
document.getElementById('tabbtn-' + tab).classList.add('active');
document.getElementById('tab-' + tab).classList.add('active');
refreshActive();
}
function refreshActive() {
if (activeTab === 'bots') loadBots(); else loadPM2();
}
// ---------- 模型清單(來自 /api/models,讀 codex 的 models cache ----------
let modelsCache = [];
let modelDefaults = { model: '', effort: '', tier: '' }; // codex 全域預設(config.toml
const EFFORT_LABELS = {
low: 'low(快速、輕度推理)',
medium: 'medium(平衡,一般預設)',
high: 'high(深入推理)',
xhigh: 'xhigh(更深入)',
max: 'max(最深推理)',
ultra: 'ultra(最深+自動分派子任務)',
};
async function loadModels() {
try {
const d = await fetchJson('/api/models');
modelsCache = d.models || [];
modelDefaults = d.defaults || modelDefaults;
} catch { modelsCache = []; }
}
// 下拉沒有「預設」空選項:直接把真實預設值選起來
function populateModelSelects(selModel, selEffort, selTier) {
const mSel = document.getElementById('f-model');
let opts = modelsCache.map(m => '<option value="' + esc(m.slug) + '">' + esc(m.label) + '</option>').join('');
// 設定檔裡有但快取沒有的模型(手填過的)也要能顯示
if (selModel && !modelsCache.some(m => m.slug === selModel)) {
opts += '<option value="' + esc(selModel) + '">' + esc(selModel) + '</option>';
}
mSel.innerHTML = opts;
// 優先序:bot 既有設定 > codex 全域預設 > 清單第一個
const wanted = selModel || modelDefaults.model;
mSel.value = wanted;
if (mSel.value !== wanted || !mSel.value) mSel.selectedIndex = 0;
syncEffortTier(selEffort, selTier);
}
function syncEffortTier(selEffort, selTier) {
const slug = document.getElementById('f-model').value;
const m = modelsCache.find(x => x.slug === slug);
let efforts = m ? m.efforts.slice() : ['low', 'medium', 'high', 'xhigh', 'max'];
if (!efforts.length) efforts = ['medium'];
const eSel = document.getElementById('f-effort');
const prevE = (selEffort !== undefined && selEffort !== '') ? selEffort : eSel.value;
eSel.innerHTML = efforts.map(e => '<option value="' + esc(e) + '">' + esc(EFFORT_LABELS[e] || e) + '</option>').join('');
// 優先序:既有值 > codex 全域預設 > 模型自己的預設 > 第一個
eSel.value = efforts.includes(prevE) ? prevE
: efforts.includes(modelDefaults.effort) ? modelDefaults.effort
: (m && efforts.includes(m.defaultEffort)) ? m.defaultEffort
: efforts[0];
const tiers = m ? m.tiers.slice() : [];
const tSel = document.getElementById('f-tier');
const prevT = (selTier !== undefined && selTier !== '') ? selTier : tSel.value;
if (!tiers.length) {
tSel.innerHTML = '<option value="">標準</option>';
tSel.value = '';
} else {
// 模型快取只列「額外」的速度層級,補一個標準選項在前面
tSel.innerHTML = '<option value="">標準</option>' +
tiers.map(t => '<option value="' + esc(t.id) + '">' + esc(t.name) + (t.description ? '' + esc(t.description) + '' : '') + '</option>').join('');
tSel.value = tiers.some(t => t.id === prevT) ? prevT
: tiers.some(t => t.id === modelDefaults.tier) ? modelDefaults.tier
: tiers.some(t => t.id === (m && m.defaultTier)) ? m.defaultTier
: '';
}
}
// ---------- Bot 管理 ----------
let botsCache = [];
async function loadBots() {
try {
const data = await fetchJson('/api/bots');
botsCache = data.bots || [];
renderBots(botsCache);
} catch (e) {
document.getElementById('bots-tbody').innerHTML =
'<tr><td colspan="9" class="empty">❌ ' + esc(e.message) + '</td></tr>';
}
}
function statusBadge(status) {
const cls = ['online','stopped','error','errored','launching','not_started'].includes(status) ? status : 'unknown';
const label = status === 'not_started' ? '未啟動' : status;
return '<span class="badge badge-' + cls + '">' + esc(label) + '</span>';
}
function renderBots(bots) {
const tbody = document.getElementById('bots-tbody');
if (!bots.length) {
tbody.innerHTML = '<tr><td colspan="9" class="empty">還沒有 bot,按右上角「➕ 新增 Bot」建立第一個</td></tr>';
return;
}
tbody.innerHTML = bots.map(b => {
const uptime = (b.status === 'online' && b.uptime) ? fmtUptime(Date.now() - b.uptime) : '-';
const online = b.status === 'online';
return '<tr>' +
'<td><strong>' + esc(b.name) + '</strong>' +
(b.error ? '<br><span style="font-size:11px;color:#ed4245">' + esc(b.error) + '</span>' : '') + '</td>' +
'<td>' + statusBadge(b.status) + '</td>' +
'<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' +
'<td class="mono">' + esc(b.sandbox || '-') +
((b.model || b.reasoningEffort || b.serviceTier)
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.serviceTier].filter(Boolean).join(' / ')) + '</span>'
: '') + '</td>' +
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
'<td>' + fmtBytes(b.memory) + '</td>' +
'<td>' + (b.restarts ?? '-') + '</td>' +
'<td style="font-size:12px;color:#64748b">' + uptime + '</td>' +
'<td><div class="actions">' +
(online
? '<button class="btn btn-ghost" onclick="botAction(\'stop\',\'' + esc(b.name) + '\')">⏹ 停止</button>'
: '<button class="btn btn-success" onclick="botAction(\'start\',\'' + esc(b.name) + '\')"' + (b.error ? ' disabled' : '') + '>▶ 啟動</button>') +
'<button class="btn btn-warning" onclick="botAction(\'restart\',\'' + esc(b.name) + '\')"' + (online ? '' : ' disabled') + '>🔄</button>' +
'<button class="btn btn-ghost" onclick="openBotLog(\'' + esc(b.name) + '\')"' + (b.pmId === null ? ' disabled' : '') + '>📄 log</button>' +
'<button class="btn" onclick="openEdit(\'' + esc(b.name) + '\')">✏️ 編輯</button>' +
'<button class="btn btn-danger" onclick="deleteBot(\'' + esc(b.name) + '\')">🗑</button>' +
'</div></td>' +
'</tr>';
}).join('');
}
function shortPath(p) {
if (!p) return '-';
return p.length > 34 ? '…' + p.slice(-32) : p;
}
async function botAction(action, name) {
try {
await postJson('/api/bots/action', { action, name });
showToast('✅ 已' + ({start:'啟動',stop:'停止',restart:'重啟'}[action]) + '' + name, 'success');
loadBots();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
async function deleteBot(name) {
const b = botsCache.find(x => x.name === name);
if (!confirm('確定要刪除 bot「' + name + '」嗎?(會從 pm2 移除)')) return;
let deleteFiles = false;
if (b && b.source === 'ui') {
deleteFiles = confirm('連同設定檔(含 token 與會話記錄)一起刪除嗎?\n「取消」= 只從清單移除,保留檔案');
}
try {
await postJson('/api/bots/action', { action: 'delete', name, deleteFiles });
showToast('✅ 已刪除:' + name, 'success');
loadBots();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
function openBotLog(name) {
const b = botsCache.find(x => x.name === name);
if (!b || b.pmId === null) return;
openLog(b.pmId, 'out', name);
}
// ---------- 新增 / 編輯表單 ----------
function openCreate() {
editingName = null;
document.getElementById('bot-modal-title').textContent = '新增 Bot';
document.getElementById('f-name').value = '';
document.getElementById('f-name').disabled = false;
document.getElementById('f-token').value = '';
document.getElementById('f-token').placeholder = '123456:ABC-DEF...';
document.getElementById('f-token-req').style.display = '';
document.getElementById('f-token-hint').textContent = '跟 @BotFather 申請;儲存時會自動驗證';
document.getElementById('f-workdir').value = '';
document.getElementById('f-sandbox').value = 'workspace-write';
populateModelSelects('', '', '');
document.getElementById('f-timeout').value = '30';
document.getElementById('f-autostart-wrap').style.display = '';
document.getElementById('f-autostart').checked = true;
document.getElementById('bot-modal').classList.add('show');
document.getElementById('f-name').focus();
}
function openEdit(name) {
const b = botsCache.find(x => x.name === name);
if (!b) return;
editingName = name;
document.getElementById('bot-modal-title').textContent = '編輯 Bot' + name;
document.getElementById('f-name').value = name;
document.getElementById('f-name').disabled = true;
document.getElementById('f-token').value = '';
document.getElementById('f-token').placeholder = b.tokenMasked + '(留空 = 不變更)';
document.getElementById('f-token-req').style.display = 'none';
document.getElementById('f-token-hint').textContent = '留空表示沿用現有 token';
document.getElementById('f-workdir').value = b.workDir || '';
document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write';
populateModelSelects(b.model || '', b.reasoningEffort || '', b.serviceTier || '');
document.getElementById('f-timeout').value = b.timeoutMinutes || 30;
document.getElementById('f-autostart-wrap').style.display = 'none';
document.getElementById('bot-modal').classList.add('show');
}
function closeModal() {
document.getElementById('bot-modal').classList.remove('show');
}
async function saveBot() {
const btn = document.getElementById('bot-modal-save');
btn.disabled = true;
btn.textContent = '儲存中…';
const payload = {
name: document.getElementById('f-name').value.trim(),
token: document.getElementById('f-token').value.trim(),
workDir: document.getElementById('f-workdir').value.trim(),
sandbox: document.getElementById('f-sandbox').value,
model: document.getElementById('f-model').value,
reasoningEffort: document.getElementById('f-effort').value,
serviceTier: document.getElementById('f-tier').value,
timeoutMinutes: document.getElementById('f-timeout').value,
};
try {
let d;
if (editingName) {
d = await postJson('/api/bots/update', { ...payload, name: editingName });
showToast(d.warning ? '⚠ ' + d.warning : ('✅ 已更新' + (d.restarted ? '並重啟' : '') + (d.botUsername ? '@' + d.botUsername : '')), d.warning ? 'warn' : 'success');
} else {
payload.autoStart = document.getElementById('f-autostart').checked;
d = await postJson('/api/bots', payload);
showToast(d.warning ? '⚠ ' + d.warning : ('✅ 已建立' + (d.started ? '並啟動' : '') + (d.botUsername ? '@' + d.botUsername : '')), d.warning ? 'warn' : 'success');
}
closeModal();
loadBots();
} catch (e) {
showToast('❌ ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '儲存';
}
}
// 開本機的資料夾選擇視窗(由後端叫出原生對話框),選完回填。
// 按鈕不鎖:再點一次會把上一個視窗砍掉重開(後端接手制),不會卡死。
let pickSeq = 0;
async function pickFolder() {
const btn = document.getElementById('f-workdir-btn');
const mySeq = ++pickSeq;
btn.textContent = '選擇中…(再點一次可重開)';
showToast('📂 資料夾選擇視窗已開啟', '');
try {
const d = await postJson('/api/pick-folder', {});
if (mySeq === pickSeq && d.path) document.getElementById('f-workdir').value = d.path;
} catch (e) {
if (mySeq === pickSeq) showToast('❌ ' + e.message, 'error');
} finally {
if (mySeq === pickSeq) btn.textContent = '📂 瀏覽…';
}
}
// ---------- PM2 分頁 ----------
async function loadPM2() {
try {
const data = await fetchJson('/api/pm2');
renderPM2(data.processes || []);
} catch (e) {
document.getElementById('pm2-tbody').innerHTML =
'<tr><td colspan="10" class="empty">❌ ' + esc(e.message) + '</td></tr>';
}
}
function renderPM2(processes) {
const tbody = document.getElementById('pm2-tbody');
if (!processes.length) {
tbody.innerHTML = '<tr><td colspan="10" class="empty">沒有 PM2 程序</td></tr>';
return;
}
tbody.innerHTML = processes.map(p => {
const uptime = (p.status === 'online' && p.uptime) ? fmtUptime(Date.now() - p.uptime) : '-';
const status = p.status || 'unknown';
const ports = (p.ports && p.ports.length)
? p.ports.map(port => '<a class="port-link" target="_blank" href="http://' + location.hostname + ':' + port + '">' + port + '</a>').join(' ')
: '<span style="color:#475569">-</span>';
return '<tr>' +
'<td><strong>' + esc(p.name) + '</strong><br><span class="script-name" title="' + esc(p.script) + '">' + esc((p.script || '').split(/[\\/]/).pop()) + '</span></td>' +
'<td>' + statusBadge(status) + '</td>' +
'<td>' + ports + '</td>' +
'<td class="mono">' + (p.pid || '-') + '</td>' +
'<td>' + p.cpu + '%</td>' +
'<td>' + fmtBytes(p.memory) + '</td>' +
'<td>' + p.restarts + '</td>' +
'<td style="font-size:12px;color:#64748b">' + uptime + '</td>' +
'<td><div class="actions">' +
(p.hasOutLog ? '<button class="btn btn-ghost" onclick="openLog(' + p.id + ',\'out\',\'' + esc(p.name) + '\')">📄 out</button>' : '') +
(p.hasErrLog ? '<button class="btn btn-ghost" onclick="openLog(' + p.id + ',\'err\',\'' + esc(p.name) + '\')">⚠ err</button>' : '') +
'</div></td>' +
'<td><div class="actions">' +
(status === 'online'
? '<button class="btn btn-ghost" onclick="pm2Action(\'stop\',\'' + esc(p.name) + '\')">⏹ 停止</button>'
: '<button class="btn btn-success" onclick="pm2Action(\'start\',\'' + esc(p.name) + '\')">▶ 啟動</button>') +
'<button class="btn btn-warning" onclick="pm2Action(\'restart\',\'' + esc(p.name) + '\')">🔄</button>' +
'<button class="btn btn-danger" onclick="pm2Action(\'delete\',\'' + esc(p.name) + '\')">🗑</button>' +
'</div></td>' +
'</tr>';
}).join('');
}
async function pm2Action(action, name) {
const labels = { stop: '停止', start: '啟動', restart: '重啟', delete: '刪除' };
if (action === 'delete' && !confirm('確定要刪除 PM2 程序「' + name + '」嗎?')) return;
try {
await postJson('/api/pm2/action', { action, name });
showToast('✅ 已' + labels[action] + '' + name, 'success');
loadPM2();
} catch (e) {
showToast('❌ ' + e.message, 'error');
}
}
// ---------- log 面板(共用) ----------
async function openLog(id, type, name) {
currentLog = { id, type, name };
document.getElementById('log-panel').style.display = 'block';
await refreshLog();
document.getElementById('log-panel').scrollIntoView({ behavior: 'smooth' });
}
async function refreshLog() {
if (!currentLog) return;
try {
const d = await fetchJson('/api/pm2/log?id=' + currentLog.id + '&type=' + currentLog.type + '&lines=200');
document.getElementById('log-title').innerHTML =
esc(d.name) + ' · ' + (currentLog.type === 'err' ? '錯誤' : '輸出') + ' log' +
'<small>' + esc(d.path) + '(最後 200 行' + (d.truncated ? ',大檔已截尾' : '') + '</small>';
const pre = document.getElementById('log-content');
pre.textContent = d.content || '(空白)';
pre.scrollTop = pre.scrollHeight;
} catch (e) {
document.getElementById('log-content').textContent = '❌ ' + e.message;
}
}
function closeLog() {
currentLog = null;
document.getElementById('log-panel').style.display = 'none';
document.getElementById('log-auto-refresh').checked = false;
syncLogTimer();
}
function syncLogTimer() {
if (logTimer) { clearInterval(logTimer); logTimer = null; }
if (document.getElementById('log-auto-refresh').checked && currentLog) {
logTimer = setInterval(refreshLog, 5000);
}
}
document.getElementById('log-auto-refresh').addEventListener('change', syncLogTimer);
// modal 以外點擊關閉
document.getElementById('bot-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
let listTimer = setInterval(refreshActive, 5000);
document.getElementById('auto-refresh').addEventListener('change', (e) => {
clearInterval(listTimer);
if (e.target.checked) listTimer = setInterval(refreshActive, 5000);
});
loadModels();
loadBots();
</script>
</body>
</html>
+384
View File
@@ -0,0 +1,384 @@
'use strict';
// tgcodex 控制台:管理 TG bot(新增/編輯/啟停,token 等設定都在網頁填)+ PM2 檢視分頁。
// 零依賴(內建 http)。預設只綁 127.0.0.1 —— 這個介面能控制 pm2 與 bot token,不要裸露到外網。
const http = require('http');
const fs = require('fs');
const path = require('path');
const { jlist, actionByName, startBotOnPm2 } = require('../pm2util');
const { listBots, createBot, updateBot, removeBot, getBotConfig } = require('../registry');
const { assertBotConfig, ensureStateDirs } = require('../config');
const INDEX_HTML = path.join(__dirname, 'public', 'index.html');
const PM2_ACTIONS = ['start', 'stop', 'restart', 'delete'];
const BOT_ACTIONS = ['start', 'stop', 'restart', 'delete'];
// ---------- PM2 分頁 ----------
function listeningPorts(pids) {
return new Promise((resolve) => {
const { exec } = require('child_process');
const portMap = {};
if (pids.size === 0) return resolve(portMap);
const isWin = process.platform === 'win32';
const cmd = isWin ? 'netstat -ano' : 'lsof -nP -iTCP -sTCP:LISTEN';
exec(cmd, { windowsHide: true, maxBuffer: 20 * 1024 * 1024 }, (err, out) => {
if (err || !out) return resolve(portMap);
for (const line of out.split('\n')) {
let pid = null;
let port = null;
if (isWin) {
const m = line.trim().match(/^TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/);
if (m) { port = m[1]; pid = m[2]; }
} else {
const cols = line.trim().split(/\s+/);
const m = cols.length > 8 && cols[8].match(/:(\d+)$/);
if (m) { port = m[1]; pid = cols[1]; }
}
if (pid && port && pids.has(pid)) {
if (!portMap[pid]) portMap[pid] = [];
if (!portMap[pid].includes(port)) portMap[pid].push(port);
}
}
resolve(portMap);
});
});
}
async function apiPm2List() {
const list = await jlist();
const processes = list.map((p) => ({
id: p.pm_id,
name: p.name,
status: p.pm2_env?.status || 'unknown',
pid: p.pid,
cpu: p.monit?.cpu ?? '-',
memory: p.monit?.memory ?? 0,
restarts: p.pm2_env?.restart_time ?? 0,
uptime: p.pm2_env?.pm_uptime ?? null,
mode: p.pm2_env?.exec_mode || 'fork',
script: p.pm2_env?.pm_exec_path || p.pm2_env?.script || '',
hasOutLog: !!p.pm2_env?.pm_out_log_path,
hasErrLog: !!p.pm2_env?.pm_err_log_path,
ports: [],
}));
const pids = new Set(processes.filter((p) => p.pid).map((p) => String(p.pid)));
const portMap = await listeningPorts(pids);
processes.forEach((p) => { if (portMap[String(p.pid)]) p.ports = portMap[String(p.pid)]; });
return { processes };
}
// tail:大檔只讀尾端 512KB,避免整檔進記憶體
function tailFile(filePath, lines) {
const READ_MAX = 512 * 1024;
const stat = fs.statSync(filePath);
const start = Math.max(0, stat.size - READ_MAX);
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(stat.size - start);
fs.readSync(fd, buf, 0, buf.length, start);
const all = buf.toString('utf-8').split('\n');
return { totalApprox: all.length, content: all.slice(-lines).join('\n'), truncated: start > 0 };
} finally {
fs.closeSync(fd);
}
}
async function apiPm2Log(pmId, type, lines) {
const list = await jlist();
const proc = list.find((p) => String(p.pm_id) === String(pmId));
if (!proc) throw new Error(`找不到 PM2 程序 id=${pmId}`);
// 路徑一律取自 jlist 回傳值,前端不能指定任意檔案
const logPath = type === 'err' ? proc.pm2_env?.pm_err_log_path : proc.pm2_env?.pm_out_log_path;
if (!logPath || !fs.existsSync(logPath)) throw new Error('沒有 log 檔');
return { name: proc.name, type, path: logPath, ...tailFile(logPath, lines) };
}
// ---------- 模型清單 ----------
// 直接讀 codex 自己的 models cache~/.codex/models_cache.json),
// 新模型上線後下拉選單自動跟上;讀不到就退回內建清單。
const FALLBACK_MODELS = [
{ slug: 'gpt-5.6-sol', label: 'GPT-5.6-Sol', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium', tiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }] },
{ slug: 'gpt-5.6-terra', label: 'GPT-5.6-Terra', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium', tiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }] },
{ slug: 'gpt-5.6-luna', label: 'GPT-5.6-Luna', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium', tiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }] },
];
// codex 全域預設(~/.codex/config.toml),前端用來當下拉選單的預選值
function codexGlobalDefaults() {
try {
const os = require('os');
const toml = fs.readFileSync(path.join(os.homedir(), '.codex', 'config.toml'), 'utf-8');
return {
model: toml.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] || '',
effort: toml.match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] || '',
tier: toml.match(/^\s*service_tier\s*=\s*"([^"]+)"/m)?.[1] || '',
};
} catch {
return { model: '', effort: '', tier: '' };
}
}
function apiModels() {
const defaults = codexGlobalDefaults();
try {
const os = require('os');
const cachePath = path.join(os.homedir(), '.codex', 'models_cache.json');
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
const models = (cache.models || [])
.filter((m) => m.visibility !== 'hide' && m.slug)
.map((m) => ({
slug: m.slug,
label: m.display_name || m.slug,
efforts: (m.supported_reasoning_levels || []).map((e) => e.effort).filter(Boolean),
defaultEffort: m.default_reasoning_level || '',
tiers: (m.service_tiers || []).map((t) => ({ id: t.id, name: t.name || t.id, description: t.description || '' })),
defaultTier: m.default_service_tier || '',
}));
if (models.length > 0) return { models, defaults, source: 'codex-cache' };
} catch { /* fall through */ }
return { models: FALLBACK_MODELS, defaults, source: 'fallback' };
}
// ---------- Bot 管理分頁 ----------
// 用 getMe 驗證 token;失敗不擋存檔,回傳警告讓使用者自行判斷
async function checkToken(token) {
try {
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal: AbortSignal.timeout(8000) });
const data = await res.json();
if (data.ok) return { valid: true, botUsername: data.result.username };
return { valid: false, warning: `token 驗證失敗:${data.description}` };
} catch (err) {
return { valid: false, warning: `無法連線 Telegram 驗證 token${err.message}` };
}
}
async function apiBotCreate(body) {
const tokenCheck = await checkToken(body.token);
const cfg = await createBot(body);
let started = false;
if (body.autoStart && tokenCheck.valid) {
ensureStateDirs(cfg);
assertBotConfig(cfg);
await startBotOnPm2(cfg);
started = true;
}
return {
success: true,
started,
botUsername: tokenCheck.botUsername || null,
warning: tokenCheck.warning || (body.autoStart && !tokenCheck.valid ? 'token 沒過驗證,已建立但未啟動' : null),
};
}
async function apiBotUpdate(body) {
let tokenCheck = null;
if (body.token) tokenCheck = await checkToken(body.token);
const cfg = await updateBot(body.name, body);
// 執行中的 bot 改完設定要重啟才生效
let restarted = false;
const list = await jlist().catch(() => []);
const proc = list.find((p) => p.name === cfg.pm2Name);
if (proc && proc.pm2_env?.status === 'online') {
await actionByName('restart', cfg.pm2Name);
restarted = true;
}
return {
success: true,
restarted,
botUsername: tokenCheck?.botUsername || null,
warning: tokenCheck?.warning || null,
};
}
async function apiBotAction(body) {
const { name, action } = body;
if (!BOT_ACTIONS.includes(action)) throw new Error(`不支援的操作:${action}`);
const cfg = getBotConfig(name);
if (action === 'start') {
ensureStateDirs(cfg);
assertBotConfig(cfg);
await startBotOnPm2(cfg);
return { success: true };
}
if (action === 'delete') {
await actionByName('delete', cfg.pm2Name).catch(() => {}); // 不在 pm2 上也要能刪
removeBot(name, { deleteFiles: !!body.deleteFiles });
return { success: true };
}
await actionByName(action, cfg.pm2Name);
return { success: true };
}
// ---------- 資料夾選擇(原生對話框) ----------
// 控制台跑在本機,直接叫作業系統的資料夾選擇視窗,選完把絕對路徑回給前端。
// 重複點「瀏覽」採接手制:把上一個還開著(或卡住)的視窗砍掉,直接開新的,
// 不會被殘留狀態鎖死。
let pickerProc = null;
function killPicker(proc) {
try {
if (process.platform === 'win32' && proc.pid) {
require('child_process').execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' });
} else {
proc.kill('SIGKILL');
}
} catch { /* 早就死了也沒關係 */ }
}
function pickFolderNative() {
if (pickerProc) {
pickerProc.takenOver = true;
killPicker(pickerProc);
pickerProc = null;
}
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
const opts = { timeout: 5 * 60 * 1000, windowsHide: true };
const settle = (child, err, out) => {
if (pickerProc === child) pickerProc = null;
if (child.takenOver) return resolve(null); // 被新的請求接手,視同取消
if (err) return reject(err);
resolve(out);
};
let child;
if (process.platform === 'win32') {
const ps = [
'Add-Type -AssemblyName System.Windows.Forms | Out-Null',
'Add-Type -AssemblyName System.Drawing | Out-Null',
// owner 是 1x1、全透明、放在主螢幕正中央的 TopMost 視窗:
// FolderBrowserDialog 會以 owner 為中心定位,所以 owner 必須在可見區域內
//(放螢幕外會連對話框一起開到螢幕外)
'$o = New-Object System.Windows.Forms.Form',
'$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.Opacity = 0',
"$o.FormBorderStyle = 'None'",
'$o.Size = [System.Drawing.Size]::new(1,1)',
"$o.StartPosition = 'Manual'",
'$wa = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea',
'$o.Location = [System.Drawing.Point]::new($wa.X + [int]($wa.Width/2), $wa.Y + [int]($wa.Height/2))',
'$o.Show(); $o.Activate()',
'$f = New-Object System.Windows.Forms.FolderBrowserDialog',
"$f.Description = '選擇 Codex 工作目錄'",
'$f.ShowNewFolderButton = $true',
'$r = $f.ShowDialog($o)',
'$o.Close()',
"if ($r -eq 'OK') { Write-Output $f.SelectedPath }",
].join('; ');
child = execFile('powershell', ['-NoProfile', '-STA', '-Command', ps], opts, (err, stdout) => {
settle(child, err ? new Error('無法開啟資料夾選擇視窗:' + err.message) : null, err ? null : (stdout.trim() || null));
});
} else if (process.platform === 'darwin') {
child = execFile('osascript', ['-e', 'POSIX path of (choose folder with prompt "選擇 Codex 工作目錄")'], opts, (err, stdout) => {
settle(child, null, err ? null : (stdout.trim().replace(/\/$/, '') || null)); // 取消會回非零,視同 null
});
} else {
child = execFile('zenity', ['--file-selection', '--directory', '--title=選擇 Codex 工作目錄'], opts, (err, stdout) => {
settle(child, null, err ? null : (stdout.trim() || null));
});
}
pickerProc = child;
});
}
// ---------- HTTP server ----------
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (c) => {
body += c;
if (body.length > 64 * 1024) { reject(new Error('body 過大')); req.destroy(); }
});
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
function createWebServer({ port = 3799, host = '127.0.0.1' } = {}) {
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const json = (code, obj) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(obj));
};
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(INDEX_HTML));
return;
}
if (url.pathname === '/api/models' && req.method === 'GET') {
return json(200, apiModels());
}
if (url.pathname === '/api/pick-folder' && req.method === 'POST') {
const picked = await pickFolderNative();
return json(200, picked ? { path: picked } : { canceled: true });
}
// --- bots ---
if (url.pathname === '/api/bots' && req.method === 'GET') {
return json(200, { bots: await listBots() });
}
if (url.pathname === '/api/bots' && req.method === 'POST') {
return json(200, await apiBotCreate(JSON.parse((await readBody(req)) || '{}')));
}
if (url.pathname === '/api/bots/update' && req.method === 'POST') {
return json(200, await apiBotUpdate(JSON.parse((await readBody(req)) || '{}')));
}
if (url.pathname === '/api/bots/action' && req.method === 'POST') {
return json(200, await apiBotAction(JSON.parse((await readBody(req)) || '{}')));
}
// --- pm2 ---
if (url.pathname === '/api/pm2' && req.method === 'GET') {
return json(200, await apiPm2List());
}
if (url.pathname === '/api/pm2/action' && req.method === 'POST') {
const { action, name } = JSON.parse((await readBody(req)) || '{}');
if (!name) return json(400, { error: 'name required' });
if (!PM2_ACTIONS.includes(action)) return json(400, { error: `不支援的操作:${action}` });
await actionByName(action, name);
return json(200, { success: true });
}
if (url.pathname === '/api/pm2/log' && req.method === 'GET') {
const pmId = url.searchParams.get('id');
const type = url.searchParams.get('type') === 'err' ? 'err' : 'out';
const lines = Math.min(2000, parseInt(url.searchParams.get('lines'), 10) || 200);
return json(200, await apiPm2Log(pmId, type, lines));
}
if (url.pathname.startsWith('/api/')) {
return json(404, { error: `API not found: ${req.method} ${url.pathname}` });
}
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
} catch (err) {
json(500, { error: err.message });
}
});
return {
listen() {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => {
console.log(`✅ tgcodex 控制台已啟動:http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`);
resolve(server);
});
});
},
server,
};
}
module.exports = { createWebServer };
// 直接執行:node src/web/server.jspm2 的 tgcodex-console 跑的就是這個)
if (require.main === module) {
const { loadConsoleSettings } = require('../pm2util');
const settings = loadConsoleSettings();
const port = Number(process.env.TGCODEX_WEB_PORT) || settings.port;
const host = process.env.TGCODEX_WEB_HOST || settings.host;
createWebServer({ port, host }).listen().catch((err) => {
console.error('啟動失敗:', err.message);
process.exit(1);
});
}