建立 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:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.tgcodex/
|
||||||
|
tgcodex.config.json
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
// telegram-codex-bot CLI:start 把 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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -0,0 +1,220 @@
|
|||||||
|
'use strict';
|
||||||
|
// Codex CLI 執行層:spawn `codex exec --json`,逐行解析 JSONL 事件。
|
||||||
|
// Windows 上 codex 是 .cmd shim(node 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 既有會話 id;null 表示開新會話
|
||||||
|
* @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
@@ -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 token(BotFather 取得)
|
||||||
|
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 };
|
||||||
@@ -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 login(bot 啟動後才需要)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
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 };
|
||||||
@@ -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/<名稱>/</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
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>
|
||||||
@@ -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.js(pm2 的 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user