From 5b32816ad4223a170048a0bf9ece892bc2caf4cf Mon Sep 17 00:00:00 2001 From: JianMiau Date: Tue, 4 Aug 2026 16:25:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=20telegram-codex-bot?= =?UTF-8?q?=EF=BC=9A=E4=B8=B2=E6=8E=A5=20Codex=20CLI=20=E7=9A=84=20Telegra?= =?UTF-8?q?m=20bot=EF=BC=88npm=20=E5=8C=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: 用 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//,含明文 token 的實例 設定不進版控(.gitignore 已涵蓋 tgcodex.config.json 與 .tgcodex/)。 Co-Authored-By: Claude Fable 5 --- .gitignore | 4 + LICENSE | 21 ++ README.md | 91 ++++++ bin/cli.js | 170 ++++++++++ package.json | 34 ++ src/bot.js | 291 +++++++++++++++++ src/codex.js | 220 +++++++++++++ src/config.js | 109 +++++++ src/doctor.js | 77 +++++ src/index.js | 18 ++ src/pm2util.js | 132 ++++++++ src/registry.js | 193 +++++++++++ src/sessions.js | 37 +++ src/telegram.js | 102 ++++++ src/web/public/index.html | 657 ++++++++++++++++++++++++++++++++++++++ src/web/server.js | 384 ++++++++++++++++++++++ 16 files changed, 2540 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bin/cli.js create mode 100644 package.json create mode 100644 src/bot.js create mode 100644 src/codex.js create mode 100644 src/config.js create mode 100644 src/doctor.js create mode 100644 src/index.js create mode 100644 src/pm2util.js create mode 100644 src/registry.js create mode 100644 src/sessions.js create mode 100644 src/telegram.js create mode 100644 src/web/public/index.html create mode 100644 src/web/server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..642bbfc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.tgcodex/ +tgcodex.config.json +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f791435 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb18c1e --- /dev/null +++ b/README.md @@ -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 ] 跟看 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 diff --git a/bin/cli.js b/bin/cli.js new file mode 100644 index 0000000..276a87c --- /dev/null +++ b/bin/cli.js @@ -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 ] 跟看 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); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..5c2d7ea --- /dev/null +++ b/package.json @@ -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" + ] +} diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..9b5a676 --- /dev/null +++ b/src/bot.js @@ -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 }; diff --git a/src/codex.js b/src/codex.js new file mode 100644 index 0000000..c75aebb --- /dev/null +++ b/src/codex.js @@ -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 }; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..d8800b8 --- /dev/null +++ b/src/config.js @@ -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 檢視工具會叫 -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 }; diff --git a/src/doctor.js b/src/doctor.js new file mode 100644 index 0000000..60d0cf5 --- /dev/null +++ b/src/doctor.js @@ -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 }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..2458ccf --- /dev/null +++ b/src/index.js @@ -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); +}); diff --git a/src/pm2util.js b/src/pm2util.js new file mode 100644 index 0000000..41d0d26 --- /dev/null +++ b/src/pm2util.js @@ -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, +}; diff --git a/src/registry.js b/src/registry.js new file mode 100644 index 0000000..b5e3911 --- /dev/null +++ b/src/registry.js @@ -0,0 +1,193 @@ +'use strict'; +// Bot 註冊表:web 控制台管理的 bot 實例清單。 +// bot 設定放在 ~/.tgcodex/bots//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 }; diff --git a/src/sessions.js b/src/sessions.js new file mode 100644 index 0000000..911bfd5 --- /dev/null +++ b/src/sessions.js @@ -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 }; diff --git a/src/telegram.js b/src/telegram.js new file mode 100644 index 0000000..a397807 --- /dev/null +++ b/src/telegram.js @@ -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, '>'); + const stash = []; + const put = (html) => SENTINEL + (stash.push(html) - 1) + SENTINEL; + let text = md.replace(/```\w*\n?([\s\S]*?)```/g, (_, code) => put(`
${esc(code.replace(/\n$/, ''))}
`)); + text = text.replace(/`([^`\n]+)`/g, (_, code) => put(`${esc(code)}`)); + text = esc(text); + text = text.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '$1'); + text = text.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + text = text.replace(/^#{1,6}\s+(.+)$/gm, '$1'); + 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 }; diff --git a/src/web/public/index.html b/src/web/public/index.html new file mode 100644 index 0000000..55c5ac6 --- /dev/null +++ b/src/web/public/index.html @@ -0,0 +1,657 @@ + + + + + +tgcodex 控制台 + + + +
+
+

tgcodex 控制台

+
+ + +
+
+ +
+ + +
+ + +
+
+
📣
+
+ 要在群組使用的話,記得關閉 Group Privacy,否則 bot 收不到群組訊息(@ 它也沒用):
+ Telegram 開 @BotFather 迷你 APP → 選該機器人 → Bot Settings → 關閉 Group Privacy
+ 改完後把 bot 踢出群組再重新拉回才會生效。只私訊使用的話可以不管這個。 +
+
+
+ +
+
+ + + + + + + + + + +
名稱狀態工作目錄沙盒Token記憶體重啟運行時間操作
載入中...
+
+

+ Bot 設定存於 ~/.tgcodex/bots/<名稱>/;啟動後掛在 pm2 上(同名程序)。 + 私訊直接回應;群組中要 @bot 或回覆 bot 的訊息才會回應。 +

+
+ + +
+
+ + + + + + + + + + +
名稱狀態PortPIDCPU記憶體重啟運行時間Log操作
載入中...
+
+

資料來源:pm2 jlist · 顯示整台機器所有 pm2 程序

+
+ + +
+
+
+
+ + +
+
+

+  
+
+ + + + +
+ + + + diff --git a/src/web/server.js b/src/web/server.js new file mode 100644 index 0000000..5ceb81e --- /dev/null +++ b/src/web/server.js @@ -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); + }); +}