建立 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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.tgcodex/
tgcodex.config.json
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 telegram-codex-bot contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+91
View File
@@ -0,0 +1,91 @@
# telegram-codex-bot
用 Telegram 操控本機 [Codex CLI](https://github.com/openai/codex) 的 bot,**附 web 控制台**。
在網頁上新增 bot(填 token、指定 Codex 可讀寫的目錄),訊息丟給 bot 就會在專案目錄裡動工。
- **web 控制台**:新增/編輯/啟停 bot 都在網頁上,token 等設定不用碰檔案;PM2 檢視是另一個分頁
- **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組
- **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟
- **多 bot**:一頁管理多個 bot,各自不同 token、不同專案目錄
- **會話延續**:每個聊天室一個 Codex 會話,`/new` 開新會話;支援傳圖
## 前置需求
| 需求 | 說明 |
|---|---|
| Node.js ≥ 18 | 內建 fetch |
| Codex CLI | `npm i -g @openai/codex`,並先 `codex login` 完成登入 |
| pm2 | `npm i -g pm2` |
| Telegram Bot token | 跟 [@BotFather](https://t.me/BotFather) 申請 |
## 快速開始
```bash
npm install -g telegram-codex-bot
telegram-codex-bot start # 先做環境檢查(codex/登入/pm2/git),再把控制台掛上 pm2
```
打開 **http://localhost:3799** →「🤖 Bot 管理」→「➕ 新增 Bot」:
1. 填名稱、Telegram token(儲存時自動驗證)
2. 工作目錄按「📂 瀏覽」用原生視窗選資料夾(Codex 可讀寫的專案路徑)
3. 模型/推理強度/速度用下拉選(清單來自本機 codex 的模型快取,如 GPT-5.6 Sol/Terra/Luna、low~ultra、Fast 1.5x)
4. 沙盒模式預設 `workspace-write` → 勾「建立後立即啟動」→ 儲存,完成
之後對 bot 私訊即可;群組中要 `@bot` 或回覆 bot 的訊息。
再開第二、第三個 bot?再按一次「新增 Bot」,各用各的 token 和目錄。
> **要在群組使用的話,記得關閉 Group Privacy**,否則 bot 收不到群組訊息:
> Telegram 開 @BotFather 迷你 APP → 選該機器人 → Bot Settings → 關閉 Group Privacy(改完把 bot 踢出群再拉回才生效)。
## web 控制台
| 分頁 | 功能 |
|---|---|
| 🤖 Bot 管理 | 新增/編輯(token、目錄用原生視窗選、沙盒、模型/推理強度/速度下拉、逾時)、啟動/停止/重啟、log、刪除 |
| 📊 PM2 | 整台機器所有 pm2 程序:狀態、監聽 port(可點)、CPU、記憶體、out/err log、啟停/重啟/刪除 |
- 兩個分頁每 5 秒自動更新
- Bot 設定存於 `~/.tgcodex/bots/<名稱>/tgcodex.config.json`,bot 掛 pm2 的程序名 = bot 名稱
- 預設只綁 `127.0.0.1`。這個介面能控制 pm2 且存有 token,**不要裸露到外網**;
區網存取請自行評估(改 `~/.tgcodex/console.json``host`,無帳密保護)
## CLI 指令
```
telegram-codex-bot start 環境檢查 + 啟動 web 控制台(掛 pm2;--port 換 port 會記住;--skip-checks 跳過檢查)
telegram-codex-bot doctor 只做環境檢查(codex CLI、codex 登入、pm2、git)
telegram-codex-bot stop / restart 停止 / 重啟控制台(bot 不受影響)
telegram-codex-bot delete 從 pm2 移除控制台
telegram-codex-bot status 終端機看控制台與所有 bot 狀態
telegram-codex-bot logs [--name <bot>] 跟看 log(不帶 --name 看控制台)
telegram-codex-bot web 前景執行控制台(除錯用)
```
短別名:`tgcodex`。CLI 只管控制台;bot 的一切(含啟停)都在網頁上操作。
## Telegram 指令
| 指令 | 說明 |
|---|---|
| `/new`(或 `!clear``!重置`) | 開新會話,清除對話記憶 |
| `/status` | 查看會話、工作目錄、沙盒模式 |
| `/help` | 使用說明 |
## 回應規則與安全性
- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況
- 沒有使用者白名單:**任何找得到 bot 的人都能叫它在工作目錄跑 Codex**,請不要把 bot 加進不信任的群組、bot username 不要外流
- 預設沙盒 `workspace-write`:Codex 只能寫工作目錄;`danger-full-access` 只在完全理解風險時使用
- token 以明文存在 `~/.tgcodex/` 下的設定檔,請顧好這台機器的檔案權限
## 疑難排解
- **找不到 codex**:確認 `codex --version` 能跑;裝在非標準位置時,在 bot 設定檔加 `"codexPath": "C:/path/to/codex.js"`
- **409 Conflict: terminated by other getUpdates**:同一個 token 跑了兩個實例(每個 bot 要用自己的 token)
- **群組不理人**:見上方 BotFather privacy 設定
- **開機自啟**:`pm2 startup` 照指示設定(Windows 可用 `pm2-installer`),控制台與 bot 啟動時都會自動 `pm2 save`
## License
MIT
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
'use strict';
// telegram-codex-bot CLIstart 把 web 控制台掛上 pm2
// bot 的新增(token、工作目錄、權限)與啟停全部在網頁控制台操作。
const { execSync, spawn } = require('child_process');
const pm2 = require('../src/pm2util');
const HELP = `telegram-codex-bot — 用 Telegram 操控本機 Codex CLI 的 bot
用法:
telegram-codex-bot start 檢查環境(codex/pm2/git)並啟動 web 控制台(掛 pm2)
telegram-codex-bot start --port 3799 指定控制台 port(會記住)
telegram-codex-bot doctor 只做環境檢查
telegram-codex-bot stop 停止控制台(bot 不受影響)
telegram-codex-bot restart 重啟控制台
telegram-codex-bot delete 從 pm2 移除控制台
telegram-codex-bot status 顯示控制台與所有 bot 狀態
telegram-codex-bot logs [--name <bot>] 跟看 log(不帶 --name 看控制台)
telegram-codex-bot web 前景執行控制台(不經 pm2,除錯用)
bot 的新增 / token / 工作目錄 / 允許名單 / 啟停,都在控制台網頁的「🤖 Bot 管理」分頁設定。
`;
function parseArgs(argv) {
const args = { _: [], flags: {} };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
args.flags[key] = next;
i++;
} else {
args.flags[key] = true;
}
} else {
args._.push(a);
}
}
return args;
}
function requirePm2() {
try {
execSync('pm2 -v', { stdio: 'ignore', windowsHide: true });
} catch {
console.error('找不到 pm2。請先安裝:npm install -g pm2');
process.exit(1);
}
}
async function cmdStart(args) {
// 環境檢查:codex CLI / 登入 / pm2 / git
if (!args.flags['skip-checks']) {
const { runChecks, printChecks, hasBlocker } = require('../src/doctor');
const checks = runChecks();
printChecks(checks);
console.log('');
if (hasBlocker(checks)) {
console.error('必要工具缺失,請先安裝上面標 ❌ 的項目再啟動。(確定要硬跑可加 --skip-checks');
process.exit(1);
}
}
if (args.flags.port) {
const settings = pm2.loadConsoleSettings();
settings.port = Number(args.flags.port);
if (!Number.isInteger(settings.port) || settings.port < 1 || settings.port > 65535) {
console.error('--port 必須是 1-65535 的整數');
process.exit(1);
}
if (args.flags.host) settings.host = String(args.flags.host);
pm2.saveConsoleSettings(settings);
}
const { url, alreadyRunning } = await pm2.ensureConsoleOnPm2({ restart: !!args.flags.port });
console.log(`✅ 控制台${alreadyRunning ? '已在執行' : '已掛上 pm2'}${pm2.CONSOLE_NAME}`);
console.log('');
console.log(`📊 控制台:${url}`);
console.log(' 在「🤖 Bot 管理」分頁新增 bot(token、工作目錄、權限都在裡面設定)。');
}
async function cmdConsoleAction(action) {
requirePm2();
try {
await pm2.actionByName(action, pm2.CONSOLE_NAME);
console.log(`✅ pm2 ${action} ${pm2.CONSOLE_NAME}`);
} catch (err) {
console.warn(`pm2 ${action} ${pm2.CONSOLE_NAME} 失敗:${err.message}`);
}
}
async function cmdStatus() {
requirePm2();
const { listBots } = require('../src/registry');
const list = await pm2.jlist();
const settings = pm2.loadConsoleSettings();
const consoleProc = list.find((p) => p.name === pm2.CONSOLE_NAME);
console.log(`控制台(${pm2.CONSOLE_NAME}):${consoleProc ? consoleProc.pm2_env?.status : '未啟動'} ${pm2.consoleUrl(settings)}`);
const bots = await listBots();
if (bots.length === 0) {
console.log('還沒有 bot。打開控制台網頁新增。');
return;
}
console.table(bots.map((b) => ({
name: b.name,
status: b.status,
workDir: b.workDir,
sandbox: b.sandbox,
restarts: b.restarts,
})));
}
async function cmdLogs(args) {
requirePm2();
let name = pm2.CONSOLE_NAME;
if (args.flags.name) {
name = String(args.flags.name);
const list = await pm2.jlist();
if (!list.some((p) => p.name === name)) {
console.error(`pm2 上找不到:${name}`);
process.exit(1);
}
}
const extra = args.flags.err ? ' --err' : '';
const child = spawn(`pm2 logs ${JSON.stringify(name)} --lines 50${extra}`, { shell: true, stdio: 'inherit' });
child.on('exit', (code) => process.exit(code ?? 0));
}
async function cmdWeb(args) {
const { createWebServer } = require('../src/web/server');
const settings = pm2.loadConsoleSettings();
const port = args.flags.port ? Number(args.flags.port) : settings.port;
const host = args.flags.host ? String(args.flags.host) : settings.host;
await createWebServer({ port, host }).listen();
}
(async () => {
const args = parseArgs(process.argv.slice(2));
const cmd = args._[0];
switch (cmd) {
case 'start': return cmdStart(args);
case 'doctor': {
const { runChecks, printChecks, hasBlocker } = require('../src/doctor');
const checks = runChecks();
printChecks(checks);
process.exit(hasBlocker(checks) ? 1 : 0);
return;
}
case 'stop': return cmdConsoleAction('stop');
case 'restart': return cmdConsoleAction('restart');
case 'delete': return cmdConsoleAction('delete');
case 'status': return cmdStatus(args);
case 'logs': return cmdLogs(args);
case 'web': return cmdWeb(args);
case 'help':
case undefined:
case '--help':
case '-h':
console.log(HELP);
return;
default:
console.error(`未知指令:${cmd}\n`);
console.log(HELP);
process.exit(1);
}
})().catch((err) => {
console.error('❌', err.message);
process.exit(1);
});
+34
View File
@@ -0,0 +1,34 @@
{
"name": "telegram-codex-bot",
"version": "0.1.0",
"description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer",
"license": "MIT",
"type": "commonjs",
"main": "src/index.js",
"bin": {
"telegram-codex-bot": "bin/cli.js",
"tgcodex": "bin/cli.js"
},
"files": [
"bin/",
"src/",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node bin/cli.js web",
"dev": "node --watch src/web/server.js"
},
"keywords": [
"telegram",
"bot",
"codex",
"codex-cli",
"pm2",
"ai",
"agent"
]
}
+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);
});
}