diff --git a/README.md b/README.md index 44f5727..ce27430 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ -# telegram-codex-bot +# telegram-bot -用 Telegram 操控本機 [Codex CLI](https://github.com/openai/codex) 的 bot,**附 web 控制台**。 -在網頁上新增 bot(填 token、指定 Codex 可讀寫的目錄),訊息丟給 bot 就會在專案目錄裡動工。 +用 Telegram 操控本機 AI CLI 的 bot,**每個 bot 可選 [Codex CLI](https://github.com/openai/codex) 或 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) 引擎**,附 web 控制台。 +在網頁上新增 bot(選引擎、填 token、指定 AI 可讀寫的目錄),訊息丟給 bot 就會在專案目錄裡動工。 +- **雙引擎**:同一個控制台同時管 Codex bot 與 Claude bot,各自的模型/推理強度下拉選 - **web 控制台**:新增/編輯/啟停 bot 都在網頁上,token 等設定不用碰檔案;PM2 檢視是另一個分頁 - **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組 - **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟 -- **多 bot**:一頁管理多個 bot,各自不同 token、不同專案目錄 -- **會話延續**:每個聊天室一個 Codex 會話,`/new` 開新會話;支援傳圖 +- **多 bot**:一頁管理多個 bot,各自不同引擎、token、專案目錄 +- **會話延續**:每個聊天室一個會話,`/new` 開新會話;支援傳圖、引用回覆;bot 之間可互相接力(引用另一隻 bot 的回覆再 tag) ## 前置需求 | 需求 | 說明 | |---|---| | Node.js ≥ 18 | 內建 fetch | -| Codex CLI | `npm i -g @openai/codex`,並先 `codex login` 完成登入 | +| Codex CLI **或** Claude Code(至少一個) | Codex:`npm i -g @openai/codex` 並 `codex login`;Claude:`npm i -g @anthropic-ai/claude-code` 並執行一次 `claude` 完成登入 | | pm2 | `npm i -g pm2` | | Telegram Bot token | 跟 [@BotFather](https://t.me/BotFather) 申請 | @@ -23,40 +24,59 @@ 套件不在官方 npmjs 上,安裝要指定 `--registry`(不用登入): ```bash -npm install -g telegram-codex-bot --registry=https://jianmiau.ddns.net/api/packages/AI/npm/ +npm install -g telegram-bot --registry=https://jianmiau.ddns.net/api/packages/AI/npm/ -telegram-codex-bot start # 先做環境檢查(codex/登入/pm2/git),再把控制台掛上 pm2 +telegram-bot start # 先做環境檢查(codex/claude/登入/pm2/git),再把控制台掛上 pm2 ``` 之後升級同樣帶 `--registry`: ```bash -npm update -g telegram-codex-bot --registry=https://jianmiau.ddns.net/api/packages/AI/npm/ +npm update -g telegram-bot --registry=https://jianmiau.ddns.net/api/packages/AI/npm/ +telegram-bot start # 重新 start 一次,bot 程序會自動切到新版 ``` 打開 **http://localhost:3799** →「🤖 Bot 管理」→「➕ 新增 Bot」: -1. 填名稱、Telegram token(儲存時自動驗證) -2. 工作目錄按「📂 瀏覽」用原生視窗選資料夾(Codex 可讀寫的專案路徑) -3. 模型/推理強度/速度用下拉選(清單來自本機 codex 的模型快取,如 GPT-5.6 Sol/Terra/Luna、low~ultra、Fast 1.5x) +1. 填名稱、**選 AI 引擎(Codex 或 Claude)**、Telegram token(儲存時自動驗證) +2. 工作目錄按「📂 瀏覽」用原生視窗選資料夾(AI 可讀寫的專案路徑;Codex 讀裡面的 `AGENTS.md`、Claude 讀 `CLAUDE.md` 當專案規範) +3. 模型/推理強度用下拉選(Codex 清單來自本機 codex 的模型快取;Claude 用 opus/sonnet/haiku 別名永遠指向最新版) 4. 沙盒模式預設 `workspace-write` → 勾「建立後立即啟動」→ 儲存,完成 之後對 bot 私訊即可;群組中要 `@bot` 或回覆 bot 的訊息。 -再開第二、第三個 bot?再按一次「新增 Bot」,各用各的 token 和目錄。 +再開第二、第三個 bot?再按一次「新增 Bot」,各用各的引擎、token 和目錄。 > **要在群組使用的話,記得關閉 Group Privacy**,否則 bot 收不到群組訊息: > Telegram 開 @BotFather 迷你 APP → 選該機器人 → Bot Settings → 關閉 Group Privacy(改完把 bot 踢出群再拉回才生效)。 +## 引擎差異 + +| | Codex | Claude | +|---|---|---| +| 底層指令 | `codex exec --json` | `claude -p --output-format stream-json` | +| 專案規範檔 | `AGENTS.md` | `CLAUDE.md` | +| 模型 | 本機 codex 模型快取(GPT-5.6 Sol/Terra/Luna…) | `opus` / `sonnet` / `haiku` 別名或任何模型 id | +| 推理強度 | low ~ ultra(依模型) | low / medium / high / max | +| 速度 | 固定 Fast(priority 1.5x) | — | +| `read-only` | codex 唯讀沙盒 | 禁用 Write/Edit/Bash 等工具 | +| `workspace-write` | 可讀寫工作目錄、沙盒內執行指令(可另開連網) | 自動接受檔案編輯;**不執行指令**(非互動模式下會被拒,回覆會標示) | +| `danger-full-access` | 不設限 | `--dangerously-skip-permissions`,不設限 | +| 生圖 | 內建 imagegen 技能,產圖自動傳回 | 無(可自行在 CLAUDE.md 接其他工具) | +| 傳圖給 bot | `-i` 直接餵給模型 | 存暫存檔後由 Read 工具讀取 | + +兩種引擎的回覆尾端都會附 `— ✅ 時間 | ctx 用量% +本輪 token | 模型 / 強度`。 +AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動傳到 Telegram 並清空。 + ## web 控制台 | 分頁 | 功能 | |---|---| -| 🤖 Bot 管理 | 新增/編輯(token、目錄用原生視窗選、沙盒、模型/推理強度/速度下拉、逾時)、啟動/停止/重啟、log、刪除 | +| 🤖 Bot 管理 | 新增/編輯(引擎、token、目錄用原生視窗選、沙盒、模型/推理強度下拉、會話模式、逾時)、啟動/停止/重啟、log、刪除 | | 📊 PM2 | 整台機器所有 pm2 程序:狀態、監聽 port(可點)、CPU、記憶體、out/err log、啟停/重啟/刪除 | ### 圖文使用說明 -**① Bot 管理首頁** — 每個 bot 一列:狀態、工作目錄、沙盒與模型設定、遮罩後的 token。 +**① Bot 管理首頁** — 每個 bot 一列:名稱旁標示引擎、狀態、工作目錄、沙盒與模型設定、遮罩後的 token。 右側按鈕:▶ 啟動 / ⏹ 停止、🔄 重啟、📄 log、✏️ 編輯、🗑 刪除。上方橫幅提醒群組使用要先關 Group Privacy。 ![Bot 管理首頁](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/01-bots.png) @@ -64,13 +84,19 @@ npm update -g telegram-codex-bot --registry=https://jianmiau.ddns.net/api/packag **② 新增 Bot** — 按右上「➕ 新增 Bot」: 1. **名稱**:也是 pm2 程序名,建立後不可改 -2. **Token**:貼上 BotFather 給的 token,儲存時自動打 Telegram 驗證,無效會警告 -3. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入 -4. **模型 / 推理強度 / 速度**:下拉選單,預設值自動帶你機器上 codex 的全域設定;清單讀自本機 codex 模型快取,換模型時強度選項會連動(如 ultra 只有支援的模型才出現) -5. 勾「建立後立即啟動」→ 儲存,bot 就上線了 +2. **AI 引擎**:Codex 或 Claude;沒裝的引擎會標「未安裝」。切換引擎時,下方的模型/強度/沙盒說明會跟著換 +3. **Token**:貼上 BotFather 給的 token,儲存時自動打 Telegram 驗證,無效會警告 +4. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入 +5. **模型 / 推理強度**:下拉選單,預設值自動帶你機器上該引擎的全域設定;換模型時強度選項會連動 +6. **會話模式**:各聊天室獨立 / 所有聊天室共用 / 每則訊息獨立(不保留記憶,最快) +7. 勾「建立後立即啟動」→ 儲存,bot 就上線了 ![新增 Bot 表單](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/02-create.png) +引擎切到 Claude 時,模型/強度/沙盒說明會跟著換: + +![新增 Claude Bot](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/02b-create-claude.png) + **③ PM2 分頁** — 整台機器所有 pm2 程序。Port 可直接點開對應網頁,右側可啟停/重啟/刪除任何程序。 ![PM2 分頁](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/03-pm2.png) @@ -80,42 +106,57 @@ npm update -g telegram-codex-bot --registry=https://jianmiau.ddns.net/api/packag ![Log 檢視](https://jianmiau.ddns.net/AI/telegram-codex-bot/media/branch/master/docs/04-log.png) - 兩個分頁每 5 秒自動更新 -- Bot 設定存於 `~/.tgcodex/bots/<名稱>/tgcodex.config.json`,bot 掛 pm2 的程序名 = bot 名稱 +- Bot 設定存於 `~/.tgbot/bots/<名稱>/tgbot.config.json`,bot 掛 pm2 的程序名 = bot 名稱 - 預設只綁 `127.0.0.1`。這個介面能控制 pm2 且存有 token,**不要裸露到外網**; - 區網存取請自行評估(改 `~/.tgcodex/console.json` 的 `host`,無帳密保護) + 區網存取請自行評估(改 `~/.tgbot/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 前景執行控制台(除錯用) +telegram-bot start 環境檢查 + 啟動 web 控制台(掛 pm2;--port 換 port 會記住;--skip-checks 跳過檢查) +telegram-bot doctor 只做環境檢查(codex / claude CLI 與登入、pm2、git) +telegram-bot stop / restart 停止 / 重啟控制台(bot 不受影響) +telegram-bot delete 從 pm2 移除控制台 +telegram-bot status 終端機看控制台與所有 bot 狀態 +telegram-bot logs [--name ] 跟看 log(不帶 --name 看控制台) +telegram-bot web 前景執行控制台(除錯用) ``` -短別名:`tgcodex`。CLI 只管控制台;bot 的一切(含啟停)都在網頁上操作。 +短別名:`tgbot`。CLI 只管控制台;bot 的一切(含啟停)都在網頁上操作。 -## Telegram 指令 +## Telegram 指令與回應規則 | 指令 | 說明 | |---|---| | `/new`(或 `!clear`、`!重置`) | 開新會話,清除對話記憶 | -| `/status` | 查看會話、工作目錄、沙盒模式 | +| `/status` | 查看引擎、會話、工作目錄、沙盒模式 | | `/help` | 使用說明 | -## 回應規則與安全性 +- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應 +- **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入 +- **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內) +- 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢 -- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況 -- 沒有使用者白名單:**任何找得到 bot 的人都能叫它在工作目錄跑 Codex**,請不要把 bot 加進不信任的群組、bot username 不要外流 -- 預設沙盒 `workspace-write`:Codex 只能寫工作目錄;`danger-full-access` 只在完全理解風險時使用 -- token 以明文存在 `~/.tgcodex/` 下的設定檔,請顧好這台機器的檔案權限 +## 安全性 + +- 沒有使用者白名單:**任何找得到 bot 的人都能叫它在工作目錄跑 AI**,請不要把 bot 加進不信任的群組、bot username 不要外流 +- 預設沙盒 `workspace-write`:AI 只能寫工作目錄;`danger-full-access` 只在完全理解風險時使用(Claude 引擎下等於 `--dangerously-skip-permissions`) +- token 以明文存在 `~/.tgbot/` 下的設定檔,請顧好這台機器的檔案權限 + +## 從 telegram-codex-bot(0.1.x)升級 + +```bash +npm install -g telegram-bot --registry=https://jianmiau.ddns.net/api/packages/AI/npm/ +npm uninstall -g telegram-codex-bot +telegram-bot start +``` + +`start` 會自動:把 `~/.tgcodex` 搬到 `~/.tgbot`(registry 路徑一併改寫)、把跑在舊套件上的 bot 重新掛到新套件、移除舊的 `tgcodex-console`。既有 bot 全部視為 Codex 引擎,設定與會話都保留;交件匣舊名 `.tgcodex-outbox/` 仍會收。 ## 疑難排解 -- **找不到 codex**:確認 `codex --version` 能跑;裝在非標準位置時,在 bot 設定檔加 `"codexPath": "C:/path/to/codex.js"` +- **找不到 codex / claude**:確認 `codex --version` / `claude --version` 能跑;裝在非標準位置時,在 bot 設定檔加 `"codexPath"` / `"claudePath"` +- **Claude bot 回「有 N 個操作因沙盒權限被拒」**:`workspace-write` 下 Claude 不能執行指令(Bash);需要跑指令請改 `danger-full-access` - **409 Conflict: terminated by other getUpdates**:同一個 token 跑了兩個實例(每個 bot 要用自己的 token) - **群組不理人**:見上方 BotFather privacy 設定 - **開機自啟**:`pm2 startup` 照指示設定(Windows 可用 `pm2-installer`),控制台與 bot 啟動時都會自動 `pm2 save` diff --git a/bin/cli.js b/bin/cli.js index 276a87c..1a1d9b7 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,24 +1,25 @@ #!/usr/bin/env node 'use strict'; -// telegram-codex-bot CLI:start 把 web 控制台掛上 pm2; -// bot 的新增(token、工作目錄、權限)與啟停全部在網頁控制台操作。 +// telegram-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 +const HELP = `telegram-bot — 用 Telegram 操控本機 AI CLI(Codex / Claude Code)的 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,除錯用) + telegram-bot start 檢查環境(codex/claude/pm2/git)並啟動 web 控制台(掛 pm2) + telegram-bot start --port 3799 指定控制台 port(會記住) + telegram-bot doctor 只做環境檢查 + telegram-bot stop 停止控制台(bot 不受影響) + telegram-bot restart 重啟控制台 + telegram-bot delete 從 pm2 移除控制台 + telegram-bot status 顯示控制台與所有 bot 狀態 + telegram-bot logs [--name ] 跟看 log(不帶 --name 看控制台) + telegram-bot web 前景執行控制台(不經 pm2,除錯用) -bot 的新增 / token / 工作目錄 / 允許名單 / 啟停,都在控制台網頁的「🤖 Bot 管理」分頁設定。 +bot 的新增 / 引擎(Codex 或 Claude)/ token / 工作目錄 / 啟停,都在控制台網頁的「🤖 Bot 管理」分頁設定。 +短別名:tgbot `; function parseArgs(argv) { @@ -51,7 +52,7 @@ function requirePm2() { } async function cmdStart(args) { - // 環境檢查:codex CLI / 登入 / pm2 / git + // 環境檢查:codex / claude(至少一個)/ 登入 / pm2 / git if (!args.flags['skip-checks']) { const { runChecks, printChecks, hasBlocker } = require('../src/doctor'); const checks = runChecks(); @@ -62,6 +63,13 @@ async function cmdStart(args) { process.exit(1); } } + requirePm2(); + // 從 telegram-codex-bot(0.1.x)升級:搬家目錄、把 bot 程序切到新套件 + const { migrateLegacy } = require('../src/migrate'); + const notes = await migrateLegacy(); + for (const n of notes) console.log(n); + if (notes.length) console.log(''); + if (args.flags.port) { const settings = pm2.loadConsoleSettings(); settings.port = Number(args.flags.port); @@ -76,7 +84,7 @@ async function cmdStart(args) { console.log(`✅ 控制台${alreadyRunning ? '已在執行' : '已掛上 pm2'}(${pm2.CONSOLE_NAME})`); console.log(''); console.log(`📊 控制台:${url}`); - console.log(' 在「🤖 Bot 管理」分頁新增 bot(token、工作目錄、權限都在裡面設定)。'); + console.log(' 在「🤖 Bot 管理」分頁新增 bot(選 Codex 或 Claude 引擎,token、工作目錄都在裡面設定)。'); } async function cmdConsoleAction(action) { @@ -96,6 +104,7 @@ async function cmdStatus() { 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)}`); + console.log(`設定目錄:${pm2.TGBOT_HOME}`); const bots = await listBots(); if (bots.length === 0) { @@ -104,6 +113,7 @@ async function cmdStatus() { } console.table(bots.map((b) => ({ name: b.name, + engine: b.engine, status: b.status, workDir: b.workDir, sandbox: b.sandbox, diff --git a/docs/01-bots.png b/docs/01-bots.png index 600a748..971f805 100644 Binary files a/docs/01-bots.png and b/docs/01-bots.png differ diff --git a/docs/02-create.png b/docs/02-create.png index 2c9def7..77a9b3f 100644 Binary files a/docs/02-create.png and b/docs/02-create.png differ diff --git a/docs/02b-create-claude.png b/docs/02b-create-claude.png new file mode 100644 index 0000000..c34cb68 Binary files /dev/null and b/docs/02b-create-claude.png differ diff --git a/docs/03-pm2.png b/docs/03-pm2.png index 8d3a4e3..88bcd6d 100644 Binary files a/docs/03-pm2.png and b/docs/03-pm2.png differ diff --git a/docs/04-log.png b/docs/04-log.png index 556633c..8406d96 100644 Binary files a/docs/04-log.png and b/docs/04-log.png differ diff --git a/image.png b/image.png deleted file mode 100644 index c523d62..0000000 Binary files a/image.png and /dev/null differ diff --git a/package.json b/package.json index 46ada66..50e4ce1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "telegram-codex-bot", - "version": "0.1.14", - "description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer", + "name": "telegram-bot", + "version": "0.2.0", + "description": "Telegram bot powered by your local AI CLI (OpenAI Codex or Claude Code) — zero-dependency, pm2-managed, with a web console to manage multiple bots", "license": "MIT", "type": "commonjs", "publishConfig": { @@ -13,8 +13,8 @@ }, "main": "src/index.js", "bin": { - "telegram-codex-bot": "bin/cli.js", - "tgcodex": "bin/cli.js" + "telegram-bot": "bin/cli.js", + "tgbot": "bin/cli.js" }, "files": [ "bin/", @@ -34,6 +34,8 @@ "bot", "codex", "codex-cli", + "claude", + "claude-code", "pm2", "ai", "agent" diff --git a/src/bot.js b/src/bot.js index a74d267..5e21891 100644 --- a/src/bot.js +++ b/src/bot.js @@ -1,11 +1,14 @@ 'use strict'; -// Bot 主邏輯:長輪詢、指令、佇列、進度回報。 +// Bot 主邏輯:長輪詢、指令、佇列、進度回報。實際跑 AI 的是 engines/(codex 或 claude), +// 這裡只認共同介面 run({ config, prompt, threadId, images, onProgress }) → { text, threadId, usage, model }。 const fs = require('fs'); const os = require('os'); const path = require('path'); +const crypto = require('crypto'); const { createTelegram } = require('./telegram'); -const { runCodex } = require('./codex'); +const { getEngine } = require('./engines'); const { createSessionStore } = require('./sessions'); +const { TGBOT_HOME } = require('./pm2util'); // codex 的模型快取與全域預設(config.toml),給 footer 顯示 ctx% 與模型/強度用 function loadModelMeta() { @@ -33,34 +36,27 @@ function loadCodexDefaults() { } // — ✅ 16:06:32 | ctx 24% +1.2k | gpt-5.6-sol / high -function buildFooter(config, usage, modelMeta, codexDefaults) { +function buildFooter({ model, effort, usage, contextWindow }) { 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); + parts.push(contextWindow ? `ctx ${Math.round((used / contextWindow) * 100)}% ${freshStr}` : freshStr); } - if (model) { - const effort = config.reasoningEffort || codexDefaults.effort || meta?.default_reasoning_level || ''; - parts.push(effort ? `${model} / ${effort}` : model); - } + if (model) parts.push(effort ? `${model} / ${effort}` : model); return `\n\n${parts.join(' | ')}`; } -const crypto = require('crypto'); - -// Codex 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫) -const OUTBOX_DIRNAME = '.tgcodex-outbox'; +// AI 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫);舊名稱也一併收 +const OUTBOX_DIRNAME = '.tgbot-outbox'; +const LEGACY_OUTBOX_DIRNAMES = ['.tgcodex-outbox']; // codex 內建 imagegen 技能的預設輸出位置(依會話 id 分資料夾) -const GENERATED_IMAGES_DIR = path.join(require('os').homedir(), '.codex', 'generated_images'); +const GENERATED_IMAGES_DIR = path.join(os.homedir(), '.codex', 'generated_images'); // 這一輪 codex 用 imagegen 生成、但沒複製進交件匣的圖(模型偶爾會忘) function collectGeneratedImages(threadId, sinceMs) { @@ -103,14 +99,23 @@ 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(); + const engine = getEngine(config.engine); + const modelMeta = engine.id === 'codex' ? loadModelMeta() : {}; + const codexDefaults = engine.id === 'codex' ? loadCodexDefaults() : { model: '', effort: '', tier: '' }; let botId = null; let botUsername = null; - // 同一個工作目錄不能同時跑兩個 codex(會互相踩檔案),全域串行 + // 同一個工作目錄不能同時跑兩個 AI(會互相踩檔案),全域串行 let queue = Promise.resolve(); + // footer / 自動重置用:這輪實際的模型、強度、上下文視窗 + function resolveDisplay(result) { + const model = result?.model || config.model || codexDefaults.model || ''; + const effort = config.reasoningEffort || codexDefaults.effort || modelMeta[model]?.default_reasoning_level || ''; + const contextWindow = result?.usage?.context_window || modelMeta[model]?.context_window || null; + return { model, effort, contextWindow }; + } + function shouldRespond(message) { if (message.chat.type === 'private') return true; const text = message.text || message.caption || ''; @@ -141,8 +146,8 @@ function startBot(config) { return commands.includes(head.toLowerCase()); } - // 把 codex 放進交件匣的檔案傳到 Telegram(傳完清掉), - // 再補傳這一輪 imagegen 生成但沒進交件匣的圖(用內容 hash 去重,避免重複傳)。 + // 把 AI 放進交件匣的檔案傳到 Telegram(傳完清掉), + // 再補傳這一輪 codex imagegen 生成但沒進交件匣的圖(用內容 hash 去重,避免重複傳)。 async function flushOutputs(chatId, replyTo, threadId, sinceMs) { const sentHashes = new Set(); let sentCount = 0; @@ -162,14 +167,16 @@ function startBot(config) { }; // 1) 交件匣(明確交付:任何檔案類型) - const dir = path.join(config.workDir, OUTBOX_DIRNAME); let outbox = []; - try { - outbox = fs.readdirSync(dir) - .map((f) => path.join(dir, f)) - .filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) - .sort(); - } catch { /* 沒有交件匣 */ } + for (const dirname of [OUTBOX_DIRNAME, ...LEGACY_OUTBOX_DIRNAMES]) { + const dir = path.join(config.workDir, dirname); + try { + outbox.push(...fs.readdirSync(dir) + .map((f) => path.join(dir, f)) + .filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) + .sort()); + } catch { /* 沒有這個交件匣 */ } + } for (const file of outbox.slice(0, MAX_OUTBOX_FILES)) { await send(file, { deleteAfter: true }); } @@ -177,10 +184,12 @@ function startBot(config) { await t.sendMessage(chatId, `⚠️ 交件匣一次最多傳 ${MAX_OUTBOX_FILES} 個檔案,還有 ${outbox.length - MAX_OUTBOX_FILES} 個留在 ${OUTBOX_DIRNAME}/`).catch(() => {}); } - // 2) 保險網:imagegen 這一輪的產圖(不刪原檔,codex 之後編輯圖片可能還要用) - for (const file of collectGeneratedImages(threadId, sinceMs).slice(0, MAX_OUTBOX_FILES)) { - if (sentCount >= MAX_OUTBOX_FILES) break; - await send(file, { deleteAfter: false }); + // 2) 保險網:codex imagegen 這一輪的產圖(不刪原檔,codex 之後編輯圖片可能還要用) + if (engine.supportsImageGen) { + for (const file of collectGeneratedImages(threadId, sinceMs).slice(0, MAX_OUTBOX_FILES)) { + if (sentCount >= MAX_OUTBOX_FILES) break; + await send(file, { deleteAfter: false }); + } } } @@ -218,7 +227,7 @@ function startBot(config) { // Telegram 限制:bot 永遠看不到其他 bot 的訊息——使用者「回覆另一隻 bot + tag 我」時, // reply_to_message 會被整個剝掉。繞法:每隻 bot 把自己在各聊天室的最後一則回覆 // 寫進共用資料夾,收到「只有 tag 沒內容」時就拿同聊天室最近一則兄弟 bot 的發言當輸入。 - const CHATLAST_DIR = path.join(os.homedir(), '.tgcodex', 'chatlast'); + const CHATLAST_DIR = path.join(TGBOT_HOME, 'chatlast'); const SIBLING_MAX_AGE_MS = 6 * 60 * 60 * 1000; // 超過 6 小時的舊訊息就不當引用 function recordChatLast(chatId, text) { @@ -260,12 +269,14 @@ function startBot(config) { function statusText(chatId) { const s = sessionKeyOf(chatId) ? sessions.get(sessionKeyOf(chatId)) : null; + const { model, effort } = resolveDisplay(null); return [ '📋 目前狀態', + `引擎:${engine.label}`, `工作目錄:${config.workDir}`, `沙盒模式:${config.sandbox}`, - `模型:${config.model || '(codex 預設)'}`, - `推理強度:${config.reasoningEffort || '(預設)'}`, + `模型:${model || `(${engine.label} 預設)`}`, + `推理強度:${effort || '(預設)'}`, `會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`, config.sessionMode === 'stateless' ? '會話:每則訊息獨立,不保留記憶' @@ -274,7 +285,7 @@ function startBot(config) { } const HELP_TEXT = [ - '🤖 我是 Codex bot,訊息直接丟給我就會在專案目錄裡動工。', + `🤖 我是 ${engine.label} bot,訊息直接丟給我就會在專案目錄裡動工。`, '', '指令:', '/new — 開新會話(清除目前對話記憶)', @@ -334,7 +345,7 @@ function startBot(config) { } await t.react(chatId, message.message_id, '👀'); - const statusMsg = await t.sendMessage(chatId, '🤔 Codex 處理中...', { replyTo: message.message_id }); + const statusMsg = await t.sendMessage(chatId, `🤔 ${engine.label} 處理中...`, { replyTo: message.message_id }); // Telegram 對 editMessageText 限流很兇:至少隔 3 秒、且不重疊 let lastEdit = 0; @@ -344,7 +355,7 @@ function startBot(config) { if (editing || now - lastEdit < 3000) return; editing = true; lastEdit = now; - const body = `🚧 Codex 處理中...\n\n${progressText}`; + const body = `🚧 ${engine.label} 處理中...\n\n${progressText}`; t.tg('editMessageText', { chat_id: chatId, message_id: statusMsg.message_id, @@ -379,12 +390,12 @@ function startBot(config) { const MAX_ATTEMPTS = 2; for (let attempt = 1; ; attempt++) { try { - result = await runCodex({ config, prompt, threadId: tid, images, onProgress }); + result = await engine.run({ config, prompt, threadId: tid, images, onProgress }); break; } catch (err) { - // 舊會話 resume 不了:1) 會話已被 codex 清掉 2) 換模型後 codex 拒絕跨模型 resume + // 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume // → 清掉會話、開新會話重跑(tid=null 之後不會再進這個分支,不會無限迴圈) - const sessionGone = /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread)/i.test(err.message); + const sessionGone = /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread|conversation)/i.test(err.message); const modelMismatch = /recorded with model/i.test(err.message); if (tid && (sessionGone || modelMismatch)) { if (sessionKey) sessions.clear(sessionKey); @@ -394,12 +405,12 @@ function startBot(config) { : '🆕 舊會話已失效,已自動開新會話。\n\n'; continue; } - // 暫時性連線中斷(OpenAI 端切斷串流等) + // 暫時性連線中斷(服務端切斷串流等) if (err.transient) { if (err.threadId) tid = err.threadId; if (tid && sessionKey) sessions.set(sessionKey, tid); // 會話已建立,先記下來以便 resume // 中斷前若圖已生成,直接交付,不重跑(避免重複生圖、多花時間) - if (collectGeneratedImages(tid, turnStart).length > 0) { + if (engine.supportsImageGen && collectGeneratedImages(tid, turnStart).length > 0) { result = { text: '(回覆文字因連線中斷遺失,圖片如下)', threadId: tid, usage: null }; sessionResetNote += '⚠️ 連線在回覆途中中斷,但圖片已生成完畢,直接送上。\n\n'; break; @@ -419,20 +430,24 @@ function startBot(config) { if (result.threadId && sessionKey) sessions.set(sessionKey, result.threadId); - // 上下文用量過高就自動開新會話:越積越慢(尤其含圖),也容易在串流時被切斷 + const display = resolveDisplay(result); let ctxNote = ''; - const cwModel = config.model || codexDefaults.model; - const cw = modelMeta[cwModel]?.context_window; - if (sessionKey && config.autoResetCtxPercent > 0 && result.usage && cw) { - const pct = Math.round(((result.usage.input_tokens || 0) + (result.usage.output_tokens || 0)) / cw * 100); + // 上下文用量過高就自動開新會話:越積越慢(尤其含圖),也容易在串流時被切斷 + if (sessionKey && config.autoResetCtxPercent > 0 && result.usage && display.contextWindow) { + const pct = Math.round(((result.usage.input_tokens || 0) + (result.usage.output_tokens || 0)) / display.contextWindow * 100); if (pct >= config.autoResetCtxPercent) { sessions.clear(sessionKey); - ctxNote = `\n\n🧹 對話記憶已用到 ${pct}%,下一則訊息會自動開新會話(避免越來越慢)。`; + ctxNote += `\n\n🧹 對話記憶已用到 ${pct}%,下一則訊息會自動開新會話(避免越來越慢)。`; } } + // claude 在非互動模式下被擋掉的操作(例如 workspace-write 不能執行指令),讓使用者知道 + if (Array.isArray(result.denials) && result.denials.length > 0) { + const tools = [...new Set(result.denials.map((d) => d.tool_name).filter(Boolean))].join('、'); + ctxNote += `\n\n⚠️ 有 ${result.denials.length} 個操作因沙盒權限被拒${tools ? `(${tools})` : ''};目前沙盒為 ${config.sandbox}。`; + } - const footer = buildFooter(config, result.usage, modelMeta, codexDefaults); - const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + ctxNote + footer); + const footer = buildFooter({ ...display, usage: result.usage }); + const html = t.mdToTgHtml(sessionResetNote + (result.text || `(${engine.label} 沒有回覆文字)`) + ctxNote + footer); await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true }); recordChatLast(chatId, result.text || ''); await flushOutputs(chatId, message.message_id, result.threadId, turnStart); @@ -474,7 +489,7 @@ function startBot(config) { ], }).catch(() => {}); - console.log(`✅ @${botUsername} 已啟動`); + console.log(`✅ @${botUsername} 已啟動(引擎:${engine.label})`); console.log(` 工作目錄:${config.workDir}`); console.log(` 沙盒模式:${config.sandbox}`); await pollLoop(); diff --git a/src/config.js b/src/config.js index d172d3e..5a6e664 100644 --- a/src/config.js +++ b/src/config.js @@ -3,44 +3,67 @@ const fs = require('fs'); const path = require('path'); -const CONFIG_BASENAME = 'tgcodex.config.json'; +const CONFIG_BASENAME = 'tgbot.config.json'; +// 0.1.x(telegram-codex-bot 時期)的設定檔名,既有 bot 沿用 +const LEGACY_CONFIG_BASENAME = 'tgcodex.config.json'; const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']; +const ENGINE_IDS = ['codex', 'claude']; +const SESSION_MODES = ['per-chat', 'shared', 'stateless']; const DEFAULTS = { // Telegram Bot token(BotFather 取得) telegramToken: '', - // Codex 可讀寫的專案目錄(相對路徑以設定檔所在目錄為基準) + // AI 引擎:codex(OpenAI Codex CLI)或 claude(Claude Code CLI) + engine: 'codex', + // 引擎可讀寫的專案目錄(相對路徑以設定檔所在目錄為基準) workDir: '.', - // Codex 沙盒模式,預設可讀寫專案目錄 + // 沙盒模式,預設可讀寫專案目錄。 + // codex:對應 codex 的 sandbox;claude:read-only 禁用寫檔/執行工具、 + // workspace-write 自動接受檔案編輯(不執行指令)、danger-full-access 跳過所有權限確認 sandbox: 'workspace-write', - // 指定模型;空字串 = 使用 codex 預設 + // 指定模型;空字串 = 引擎預設(codex 讀 ~/.codex/config.toml;claude 讀它自己的設定) model: '', - // 推理強度(low/medium/high/xhigh/max/ultra…依模型而定);空字串 = codex 預設 + // 推理強度(codex:low/medium/high/xhigh/max/ultra 依模型而定;claude:low/medium/high/max);空字串 = 引擎預設 reasoningEffort: '', - // 速度固定 Fast(priority = 1.5x);要改只能手動編輯設定檔 + // codex 專用:速度固定 Fast(priority = 1.5x);要改只能手動編輯設定檔 serviceTier: 'priority', // 會話模式:per-chat = 每個聊天室獨立(預設);shared = 所有聊天室共用; // stateless = 每則訊息獨立、不保留記憶(最快,適合單張生圖這類不需上下文的 bot) sessionMode: 'per-chat', // 會話上下文用量達此百分比時自動開新會話(避免越積越慢、串流被切);0 = 不自動 autoResetCtxPercent: 80, - // 允許 Codex 在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開) + // codex 專用:允許在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開) networkAccess: false, // 單次回應逾時(分鐘) timeoutMinutes: 30, - // pm2 程序名稱(web 檢視工具會叫 -web) - pm2Name: 'tgcodex-bot', - // 內建 PM2 web 檢視工具 + // pm2 程序名稱 + pm2Name: 'tgbot', + // 內建 web 控制台(UI 建的 bot 一律關閉,由控制台統一管理) web: { enabled: true, port: 3799, host: '127.0.0.1' }, // 手動指定 codex 入口(codex.js 或執行檔路徑);空字串 = 自動偵測 codexPath: '', + // 手動指定 claude 執行檔路徑;空字串 = 自動偵測(PATH 與 ~/.local/bin) + claudePath: '', }; +function firstExisting(...files) { + return files.find((f) => fs.existsSync(f)) || files[0]; +} + 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); + const fromEnv = process.env.TGBOT_CONFIG || process.env.TGCODEX_CONFIG; + if (fromEnv) return path.resolve(fromEnv); + return firstExisting( + path.resolve(process.cwd(), CONFIG_BASENAME), + path.resolve(process.cwd(), LEGACY_CONFIG_BASENAME) + ); +} + +function envOr(...names) { + for (const n of names) if (process.env[n]) return process.env[n]; + return undefined; } function loadConfig(explicitPath) { @@ -60,20 +83,34 @@ function loadConfig(explicitPath) { 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; + const envToken = envOr('TGBOT_TOKEN', 'TGCODEX_TOKEN'); + const envWorkDir = envOr('TGBOT_WORKDIR', 'TGCODEX_WORKDIR'); + const envSandbox = envOr('TGBOT_SANDBOX', 'TGCODEX_SANDBOX'); + const envModel = envOr('TGBOT_MODEL', 'TGCODEX_MODEL'); + const envEngine = envOr('TGBOT_ENGINE'); + const envPort = envOr('TGBOT_WEB_PORT', 'TGCODEX_WEB_PORT'); + const envHost = envOr('TGBOT_WEB_HOST', 'TGCODEX_WEB_HOST'); + if (envToken) cfg.telegramToken = envToken; + if (envWorkDir) cfg.workDir = envWorkDir; + if (envSandbox) cfg.sandbox = envSandbox; + if (envModel) cfg.model = envModel; + if (envEngine) cfg.engine = envEngine; + if (envPort) cfg.web.port = Number(envPort); + if (envHost) cfg.web.host = envHost; cfg.configPath = file; cfg.configDir = path.dirname(file); cfg.workDir = path.resolve(cfg.configDir, cfg.workDir); - cfg.stateDir = path.join(cfg.configDir, '.tgcodex'); + // 狀態目錄:新 bot 用 .tgbot;0.1.x 建的 bot 已有 .tgcodex 就沿用 + const newState = path.join(cfg.configDir, '.tgbot'); + const legacyState = path.join(cfg.configDir, '.tgcodex'); + cfg.stateDir = (!fs.existsSync(newState) && fs.existsSync(legacyState)) ? legacyState : newState; cfg.logsDir = path.join(cfg.stateDir, 'logs'); cfg.tmpDir = path.join(cfg.stateDir, 'tmp'); + if (!ENGINE_IDS.includes(cfg.engine)) { + throw new Error(`engine 必須是 ${ENGINE_IDS.join(' / ')},收到:${cfg.engine}`); + } if (!SANDBOX_MODES.includes(cfg.sandbox)) { throw new Error(`sandbox 必須是 ${SANDBOX_MODES.join(' / ')},收到:${cfg.sandbox}`); } @@ -87,10 +124,10 @@ function loadConfig(explicitPath) { } cfg.web.port = port; } - // 速度政策固定 Fast:舊設定檔存了空值也矯正回 priority + // codex 速度政策固定 Fast:舊設定檔存了空值也矯正回 priority if (!cfg.serviceTier) cfg.serviceTier = 'priority'; // 舊欄位 sharedSession 相容:轉成 sessionMode - if (!['per-chat', 'shared', 'stateless'].includes(cfg.sessionMode)) { + if (!SESSION_MODES.includes(cfg.sessionMode)) { cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat'; } cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0; @@ -102,12 +139,12 @@ function loadConfig(explicitPath) { return cfg; } -// bot 執行前的額外檢查(web 檢視工具不需要 token,所以拆開) +// bot 執行前的額外檢查(控制台不需要 token,所以拆開) function assertBotConfig(cfg) { if (!cfg.telegramToken) { throw new Error( `缺少 Telegram token。請在 ${cfg.configPath} 設定 telegramToken,` + - '或設環境變數 TGCODEX_TOKEN。(先跑 `telegram-codex-bot init` 產生設定檔)' + '或設環境變數 TGBOT_TOKEN。(在控制台網頁新增 bot 會自動產生設定檔)' ); } if (!fs.existsSync(cfg.workDir)) { @@ -121,4 +158,7 @@ function ensureStateDirs(cfg) { } } -module.exports = { CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, loadConfig, assertBotConfig, ensureStateDirs, findConfigPath }; +module.exports = { + CONFIG_BASENAME, LEGACY_CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, + loadConfig, assertBotConfig, ensureStateDirs, findConfigPath, +}; diff --git a/src/doctor.js b/src/doctor.js index 60d0cf5..ee58805 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -1,5 +1,5 @@ 'use strict'; -// 環境檢查:start 前確認 codex CLI、pm2、git 等工具是否就緒。 +// 環境檢查:start 前確認 AI CLI(codex / claude 至少一個)、pm2、git 等工具是否就緒。 const { execSync } = require('child_process'); const fs = require('fs'); const os = require('os'); @@ -13,6 +13,27 @@ function tryExec(cmd) { } } +function codexVersion() { + return tryExec('codex --version'); +} + +function claudeVersion() { + const { resolveClaudeCommand } = require('./engines/claude'); + const { cmd } = resolveClaudeCommand({}); + const out = tryExec(`"${cmd}" --version`) || tryExec('claude --version'); + return out ? out.split('\n')[0] : null; +} + +function claudeLoggedIn() { + if (fs.existsSync(path.join(os.homedir(), '.claude', '.credentials.json'))) return true; + try { + const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf-8')); + return !!j.oauthAccount; + } catch { + return false; + } +} + // 回傳 [{ name, ok, required, message }] function runChecks() { const checks = []; @@ -25,22 +46,48 @@ function runChecks() { message: nodeMajor >= 18 ? process.version : `${process.version}(需要 ≥ 18)`, }); - const codexVer = tryExec('codex --version'); + const codexVer = codexVersion(); checks.push({ name: 'Codex CLI', ok: !!codexVer, - required: true, - message: codexVer || '找不到 codex,請安裝:npm install -g @openai/codex', + required: false, + group: 'engine', + message: codexVer || '找不到 codex(要用 Codex 引擎才需要):npm install -g @openai/codex', }); - if (codexVer) { - const authFile = path.join(os.homedir(), '.codex', 'auth.json'); - const loggedIn = fs.existsSync(authFile); + const loggedIn = fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json')); checks.push({ name: 'Codex 登入', ok: loggedIn, required: false, - message: loggedIn ? '已登入' : '尚未登入,請先執行:codex login(bot 啟動後才需要)', + message: loggedIn ? '已登入' : '尚未登入,請先執行:codex login(Codex bot 啟動後才需要)', + }); + } + + const claudeVer = claudeVersion(); + checks.push({ + name: 'Claude Code CLI', + ok: !!claudeVer, + required: false, + group: 'engine', + message: claudeVer || '找不到 claude(要用 Claude 引擎才需要):npm install -g @anthropic-ai/claude-code', + }); + if (claudeVer) { + const loggedIn = claudeLoggedIn(); + checks.push({ + name: 'Claude 登入', + ok: loggedIn, + required: false, + message: loggedIn ? '已登入' : '尚未登入,請先執行:claude(首次啟動會引導登入)', + }); + } + + if (!codexVer && !claudeVer) { + checks.push({ + name: 'AI 引擎', + ok: false, + required: true, + message: 'codex 與 claude 都找不到,至少要裝一個', }); } @@ -57,7 +104,7 @@ function runChecks() { name: 'git', ok: !!gitVer, required: false, - message: gitVer || '找不到 git(非必要:bot 以 --skip-git-repo-check 執行,但 Codex 要做版控操作時會需要)', + message: gitVer || '找不到 git(非必要:AI 要做版控操作時會需要)', }); return checks; @@ -74,4 +121,4 @@ function hasBlocker(checks) { return checks.some((c) => c.required && !c.ok); } -module.exports = { runChecks, printChecks, hasBlocker }; +module.exports = { runChecks, printChecks, hasBlocker, codexVersion, claudeVersion }; diff --git a/src/engines/claude.js b/src/engines/claude.js new file mode 100644 index 0000000..3239b7a --- /dev/null +++ b/src/engines/claude.js @@ -0,0 +1,212 @@ +'use strict'; +// Claude 引擎:spawn `claude -p --output-format stream-json --verbose`,逐行解析事件。 +// prompt 走 stdin(-p 沒有位置參數時讀 stdin),任意文字都安全。 +// 沙盒對應:read-only → 禁用寫檔/執行工具;workspace-write → 自動接受檔案編輯(不執行指令); +// danger-full-access → 跳過所有權限確認。 +const { spawn, execSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin'); +let cachedCommand = null; + +// 找 claude 執行檔實體:pm2 daemon 的 PATH 不一定帶到 ~/.local/bin(原生安裝位置),直接補找 +function resolveClaudeCommand(config) { + const custom = config && config.claudePath; + if (custom) return { cmd: custom, shell: /\.cmd$/i.test(custom) }; + if (cachedCommand) return cachedCommand; + const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude']; + const dirs = [...(process.env.PATH || '').split(path.delimiter), LOCAL_BIN]; + for (const dir of dirs) { + if (!dir) continue; + for (const n of names) { + const p = path.join(dir, n); + if (fs.existsSync(p)) { + cachedCommand = { cmd: p, shell: /\.cmd$/i.test(p) }; + return cachedCommand; + } + } + } + cachedCommand = { cmd: 'claude', shell: process.platform === 'win32' }; + return cachedCommand; +} + +// 只有退回 shell(.cmd shim)時才需要引號;參數裡不會有引號,包起來即可 +function quoteForShell(s) { + return /[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s; +} + +function killTree(proc) { + if (process.platform === 'win32') { + try { execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' }); } catch { /* ignore */ } + } else { + try { proc.kill('SIGKILL'); } catch { /* ignore */ } + } +} + +const TOOL_LABELS = { + Bash: '⚙️ 執行指令', + Read: '📖 讀取檔案', + Write: '✏️ 寫入檔案', + Edit: '✏️ 編輯檔案', + MultiEdit: '✏️ 編輯檔案', + NotebookEdit: '✏️ 編輯筆記本', + Glob: '🔍 搜尋檔案', + Grep: '🔍 搜尋內容', + WebFetch: '🌐 擷取網頁', + WebSearch: '🔎 搜尋網路', + Agent: '🤖 派出子代理', + Task: '🤖 派出子代理', +}; + +function permissionArgs(sandbox) { + switch (sandbox) { + case 'read-only': + return ['--permission-mode', 'default', '--disallowedTools', 'Write,Edit,MultiEdit,NotebookEdit,Bash']; + case 'danger-full-access': + return ['--dangerously-skip-permissions']; + default: // workspace-write + return ['--permission-mode', 'acceptEdits']; + } +} + +/** + * 執行一輪 Claude 對話。介面同 engines/codex.js 的 run。 + * @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null, denials: object[]}>} + */ +function run({ config, prompt, threadId, images = [], onProgress }) { + return new Promise((resolve, reject) => { + const { cmd, shell } = resolveClaudeCommand(config); + + const args = ['-p', '--output-format', 'stream-json', '--verbose']; + if (config.model) args.push('--model', config.model); + if (config.reasoningEffort) args.push('--effort', config.reasoningEffort); + args.push(...permissionArgs(config.sandbox)); + // 附圖:claude -p 沒有圖片參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍) + if (images.length > 0) args.push('--add-dir', path.dirname(images[0])); + if (threadId) args.push('--resume', threadId); + + let fullPrompt = prompt; + if (images.length > 0) { + fullPrompt += '\n\n【附圖】請先用 Read 工具讀取下列圖片檔再處理:\n' + images.join('\n'); + } + + const env = { ...process.env }; + // 讓子程序也找得到 claude 與它可能呼叫的工具 + env.PATH = `${env.PATH || ''}${path.delimiter}${LOCAL_BIN}`; + + const proc = shell + ? spawn(quoteForShell(cmd) + ' ' + args.map(quoteForShell).join(' '), { cwd: config.workDir, env, shell: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + : spawn(cmd, args, { cwd: config.workDir, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + + proc.stdin.write(fullPrompt); + proc.stdin.end(); + + let buffer = ''; + let stderrTail = ''; + let sessionId = threadId || null; + let model = null; + let resultText = null; + let resultError = null; + let usage = null; + let denials = []; + let lastText = ''; + + const timer = setTimeout(() => { + killTree(proc); + reject(new Error(`Claude 逾時(${config.timeoutMinutes} 分鐘)`)); + }, config.timeoutMinutes * 60 * 1000); + + const handleEvent = (obj) => { + if (obj.type === 'system') { + if (obj.session_id) sessionId = obj.session_id; + if (obj.subtype === 'init' && obj.model) model = obj.model; + return; + } + if (obj.type === 'assistant') { + if (obj.message?.model) model = obj.message.model; + if (!onProgress) return; + let toolLabel = null; + for (const block of obj.message?.content || []) { + if (block.type === 'text' && block.text?.trim()) { + const t = block.text.trim(); + lastText = t.length > 600 ? t.slice(0, 600) + '...' : t; + } else if (block.type === 'tool_use') { + let detail = ''; + if (block.name === 'Bash' && block.input?.command) { + const c = String(block.input.command); + detail = c.length > 300 ? c.slice(0, 300) + '...' : c; + } else if (block.input?.file_path) { + detail = String(block.input.file_path); + } + toolLabel = (TOOL_LABELS[block.name] || `🔧 ${block.name}`) + '...' + (detail ? '\n' + detail : ''); + } + } + if (toolLabel) onProgress(toolLabel); + else if (lastText) onProgress('💬 ' + lastText); + return; + } + if (obj.type === 'result') { + if (obj.session_id) sessionId = obj.session_id; + denials = Array.isArray(obj.permission_denials) ? obj.permission_denials : []; + const u = obj.usage || null; + if (u) { + // iterations 有的話取最後一輪:那才是目前會話的實際上下文大小 + const iters = u.iterations; + const ref = (Array.isArray(iters) && iters.length > 0) ? iters[iters.length - 1] : u; + const mu = obj.modelUsage && (obj.modelUsage[model] || Object.values(obj.modelUsage)[0]); + usage = { + input_tokens: (ref.input_tokens || 0) + (ref.cache_creation_input_tokens || 0) + (ref.cache_read_input_tokens || 0), + cached_input_tokens: ref.cache_read_input_tokens || 0, + output_tokens: ref.output_tokens || 0, + context_window: mu?.contextWindow || 200000, + }; + if (mu?.canonicalModel) model = model || mu.canonicalModel; + } + if (obj.is_error) { + const errs = Array.isArray(obj.errors) ? obj.errors.join('; ') : ''; + resultError = [obj.result, errs].filter(Boolean).join('\n') || obj.subtype || 'Claude 回報錯誤'; + } else { + resultText = obj.result ?? ''; + } + } + }; + + 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 行直接忽略 */ } + } + }); + + proc.stderr.on('data', (chunk) => { + stderrTail = (stderrTail + chunk.toString()).slice(-2000); + if (process.env.DEBUG) console.error('[claude stderr]', chunk.toString()); + }); + + proc.on('close', (code) => { + clearTimeout(timer); + if (resultText !== null) { + return resolve({ text: resultText, threadId: sessionId, usage, model, denials }); + } + const detail = resultError || stderrTail.split('\n').filter((l) => l.trim()).slice(-5).join('\n'); + const e = new Error(resultError ? `Claude 執行失敗:${resultError}` : `claude 結束(exit code ${code})${detail ? '\n' + detail : ''}`); + e.threadId = sessionId; + e.transient = /ECONNRESET|socket hang up|timed out|stream|overloaded|529|rate limit|connection/i.test(detail || ''); + reject(e); + }); + + proc.on('error', (err) => { + clearTimeout(timer); + reject(new Error(`無法啟動 claude:${err.message}(請確認已安裝 Claude Code 並在 PATH 中)`)); + }); + }); +} + +module.exports = { run, resolveClaudeCommand }; diff --git a/src/codex.js b/src/engines/codex.js similarity index 92% rename from src/codex.js rename to src/engines/codex.js index df3be02..c009850 100644 --- a/src/codex.js +++ b/src/engines/codex.js @@ -1,5 +1,5 @@ 'use strict'; -// Codex CLI 執行層:spawn `codex exec --json`,逐行解析 JSONL 事件。 +// Codex 引擎:spawn `codex exec --json`,逐行解析 JSONL 事件。 // Windows 上 codex 是 .cmd shim(node codex.js 的包裝),直接解析出 codex.js // 用 node 執行,避開 shell:true 的引號問題與 DEP0190 警告。 const { spawn, execSync } = require('child_process'); @@ -82,9 +82,9 @@ const PROGRESS_LABELS = { * @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}>} + * @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null}>} */ -function runCodex({ config, prompt, threadId, images = [], onProgress }) { +function run({ config, prompt, threadId, images = [], onProgress }) { return new Promise((resolve, reject) => { const { cmd, baseArgs, shell } = resolveCodexCommand(config); @@ -122,6 +122,9 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) { let usage = null; let turnCompleted = false; let turnFailedMessage = null; + // item 層級的 error 可能只是警告(例如「service tier 不支援,將省略」),turn 仍會正常完成; + // 只有在 turn 沒完成時才把它當成失敗原因 + let itemErrorMessage = null; const messages = []; const timer = setTimeout(() => { @@ -166,7 +169,7 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) { onProgress('💬 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t)); } } else if (item.type === 'error' && item.message) { - turnFailedMessage = item.message; + itemErrorMessage = item.message; } else if (item.type === 'reasoning' && item.text && onProgress) { const t = item.text.trim(); onProgress('🤔 ' + (t.length > 600 ? t.slice(0, 600) + '...' : t)); @@ -197,6 +200,7 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) { proc.on('close', (code) => { clearTimeout(timer); + if (!turnFailedMessage && !turnCompleted && itemErrorMessage) turnFailedMessage = itemErrorMessage; if (turnFailedMessage) { const e = new Error(`Codex 執行失敗:${turnFailedMessage}`); e.threadId = resultThreadId; // 會話可能已建立,讓呼叫端能 resume 重試 @@ -205,7 +209,7 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) { return reject(e); } if (turnCompleted || messages.length > 0) { - return resolve({ text: messages.join('\n\n'), threadId: resultThreadId, usage }); + return resolve({ text: messages.join('\n\n'), threadId: resultThreadId, usage, model: config.model || null }); } const hint = stderrTail .split('\n') @@ -222,4 +226,4 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) { }); } -module.exports = { runCodex, resolveCodexCommand, findCodexJs }; +module.exports = { run, runCodex: run, resolveCodexCommand, findCodexJs }; diff --git a/src/engines/index.js b/src/engines/index.js new file mode 100644 index 0000000..e8fa8ed --- /dev/null +++ b/src/engines/index.js @@ -0,0 +1,32 @@ +'use strict'; +// AI 引擎註冊表:每個引擎提供同介面的 run({ config, prompt, threadId, images, onProgress }) +// → { text, threadId, usage, model }。bot 主邏輯只認這個介面,不關心底下是 codex 還是 claude。 +const ENGINES = { + codex: { + id: 'codex', + label: 'Codex', + // 引擎讀取的專案規範檔(放在工作目錄) + instructionsFile: 'AGENTS.md', + // 沙盒可另外開關網路(codex 的 workspace-write 預設禁網) + supportsNetworkToggle: true, + // 內建生圖技能(imagegen) + supportsImageGen: true, + run: (opts) => require('./codex').run(opts), + }, + claude: { + id: 'claude', + label: 'Claude', + instructionsFile: 'CLAUDE.md', + supportsNetworkToggle: false, + supportsImageGen: false, + run: (opts) => require('./claude').run(opts), + }, +}; + +const ENGINE_IDS = Object.keys(ENGINES); + +function getEngine(id) { + return ENGINES[id] || ENGINES.codex; +} + +module.exports = { ENGINES, ENGINE_IDS, getEngine }; diff --git a/src/index.js b/src/index.js index 2458ccf..3f26d61 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ 'use strict'; // Bot 進程進入點(pm2 跑的就是這支)。 -// 設定檔位置:環境變數 TGCODEX_CONFIG,或目前目錄的 tgcodex.config.json。 +// 設定檔位置:環境變數 TGBOT_CONFIG(舊名 TGCODEX_CONFIG 也認),或目前目錄的 tgbot.config.json。 const { loadConfig, assertBotConfig, ensureStateDirs } = require('./config'); const { startBot } = require('./bot'); diff --git a/src/migrate.js b/src/migrate.js new file mode 100644 index 0000000..259bc5c --- /dev/null +++ b/src/migrate.js @@ -0,0 +1,87 @@ +'use strict'; +// 從 telegram-codex-bot(0.1.x)升級: +// 1) ~/.tgcodex → ~/.tgbot(設定、registry、會話全部搬過去,registry 裡的路徑一併改寫) +// 2) 跑在舊套件路徑(telegram-codex-bot/src/index.js)上的 bot 程序重新掛到新套件 +// 3) 舊控制台程序 tgcodex-console 移除(ensureConsoleOnPm2 會掛新的 tgbot-console) +// 每一步都是冪等的:沒有舊東西就什麼都不做。 +const fs = require('fs'); +const path = require('path'); +const pm2 = require('./pm2util'); + +function rewritePrefix(p, from, to) { + if (typeof p !== 'string') return p; + const norm = path.resolve(p); + const fromNorm = path.resolve(from); + if (norm.toLowerCase().startsWith(fromNorm.toLowerCase())) return path.join(to, norm.slice(fromNorm.length)); + return p; +} + +async function migrateLegacy() { + const notes = []; + let list = await pm2.jlist().catch(() => []); + const isOnline = (p) => p.pm2_env?.status === 'online'; + + // 1) 家目錄搬家 + if (fs.existsSync(pm2.LEGACY_HOME) && !fs.existsSync(pm2.NEW_HOME)) { + // 先把跑在舊家目錄上的 bot 從 pm2 拿掉(記住哪些在線上,搬完再啟) + const affected = []; + for (const p of list) { + const cfgPath = pm2.botConfigPathOf(p); + if (!cfgPath) continue; + if (!path.resolve(cfgPath).toLowerCase().startsWith(path.resolve(pm2.LEGACY_HOME).toLowerCase())) continue; + affected.push({ name: p.name, online: isOnline(p), cfgPath: rewritePrefix(cfgPath, pm2.LEGACY_HOME, pm2.NEW_HOME) }); + await pm2.pm2Exec(`delete ${JSON.stringify(p.name)}`).catch(() => {}); + } + fs.renameSync(pm2.LEGACY_HOME, pm2.NEW_HOME); + notes.push(`📦 設定目錄已搬到 ${pm2.NEW_HOME}(原 ${pm2.LEGACY_HOME})`); + + // registry 裡的設定檔路徑改寫 + const regFile = path.join(pm2.NEW_HOME, 'registry.json'); + try { + const reg = JSON.parse(fs.readFileSync(regFile, 'utf-8')); + for (const entry of Object.values(reg.bots || {})) { + entry.configPath = rewritePrefix(entry.configPath, pm2.LEGACY_HOME, pm2.NEW_HOME); + } + fs.writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n'); + } catch { /* 沒有 registry 就算了 */ } + + // 之前在線上的 bot 用新路徑重新掛回去 + const { loadConfig } = require('./config'); + for (const b of affected) { + if (!b.online) { notes.push(`⏸ ${b.name} 原本是停止狀態,已從 pm2 移除,可在控制台重新啟動`); continue; } + try { + await pm2.startBotOnPm2(loadConfig(b.cfgPath)); + notes.push(`🔄 ${b.name} 已用新路徑重新啟動`); + } catch (err) { + notes.push(`⚠️ ${b.name} 重新啟動失敗:${err.message}(請在控制台手動啟動)`); + } + } + list = await pm2.jlist().catch(() => []); + } + + // 2) 還跑在舊套件(或舊安裝路徑)上的 bot:重新掛到目前套件的 index.js + const { loadConfig } = require('./config'); + for (const p of list) { + const cfgPath = pm2.botConfigPathOf(p); + if (!cfgPath) continue; + const script = p.pm2_env?.pm_exec_path || ''; + if (path.resolve(script) === path.resolve(pm2.BOT_SCRIPT)) continue; + if (!fs.existsSync(cfgPath)) continue; + if (!isOnline(p)) { + await pm2.pm2Exec(`delete ${JSON.stringify(p.name)}`).catch(() => {}); + notes.push(`⏸ ${p.name} 原本是停止狀態(舊套件),已從 pm2 移除,可在控制台重新啟動`); + continue; + } + try { + await pm2.startBotOnPm2(loadConfig(cfgPath)); + notes.push(`🔄 ${p.name} 已從舊套件切換到 telegram-bot 並重新啟動`); + } catch (err) { + notes.push(`⚠️ ${p.name} 切換失敗:${err.message}`); + } + } + + // 3) 舊控制台程序由 ensureConsoleOnPm2 處理(會刪掉 tgcodex-console 再掛 tgbot-console) + return notes; +} + +module.exports = { migrateLegacy }; diff --git a/src/pm2util.js b/src/pm2util.js index 41d0d26..1f18f79 100644 --- a/src/pm2util.js +++ b/src/pm2util.js @@ -5,9 +5,19 @@ 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'); +// 家目錄:~/.tgbot。0.1.x(telegram-codex-bot)用的是 ~/.tgcodex, +// `telegram-bot start` 會自動搬家(見 migrate.js);搬家前仍沿用舊目錄,兩邊都能跑。 +const NEW_HOME = path.join(os.homedir(), '.tgbot'); +const LEGACY_HOME = path.join(os.homedir(), '.tgcodex'); +// 動態判斷:CLI start 在同一個程序裡先搬家再掛控制台,搬完後要拿到新路徑 +function resolveHome() { + return (!fs.existsSync(NEW_HOME) && fs.existsSync(LEGACY_HOME)) ? LEGACY_HOME : NEW_HOME; +} +const TGBOT_HOME = resolveHome(); + +const CONSOLE_NAME = 'tgbot-console'; +const LEGACY_CONSOLE_NAME = 'tgcodex-console'; +const consoleSettingsFile = () => path.join(resolveHome(), 'console.json'); const WEB_SCRIPT = path.join(__dirname, 'web', 'server.js'); const BOT_SCRIPT = path.join(__dirname, 'index.js'); @@ -34,6 +44,21 @@ function saveList() { return pm2Exec('save').catch(() => {}); } +// pm2 程序的自訂 env(不同版本可能在 pm2_env.env 或直接攤平在 pm2_env) +function procEnv(p) { + return { ...(p.pm2_env || {}), ...(p.pm2_env?.env || {}) }; +} + +// 這個 pm2 程序是不是我們的 bot(跑 index.js 且帶設定檔路徑);回傳設定檔路徑或 null +function botConfigPathOf(p) { + const env = procEnv(p); + const cfgPath = env.TGBOT_CONFIG || env.TGCODEX_CONFIG; + if (!cfgPath || typeof cfgPath !== 'string') return null; + const script = p.pm2_env?.pm_exec_path || ''; + if (path.basename(script) !== 'index.js') return null; + return cfgPath; +} + // 對存在於 jlist 的程序執行操作(名稱先驗證存在,杜絕注入) async function actionByName(action, name) { const list = await jlist(); @@ -49,7 +74,7 @@ async function startBotOnPm2(cfg) { name: cfg.pm2Name, script: BOT_SCRIPT, cwd: cfg.configDir, - env: { TGCODEX_CONFIG: cfg.configPath }, + env: { TGBOT_CONFIG: cfg.configPath }, out_file: path.join(cfg.logsDir, 'bot-out.log'), error_file: path.join(cfg.logsDir, 'bot-error.log'), restart_delay: 5000, @@ -58,6 +83,7 @@ async function startBotOnPm2(cfg) { merge_logs: true, time: true, }; + fs.mkdirSync(cfg.logsDir, { recursive: true }); fs.writeFileSync(eco, `module.exports = ${JSON.stringify({ apps: [app] }, null, 2)};\n`); if (await hasApp(cfg.pm2Name)) { // 已在 pm2 上(可能 stopped):restart 會套用既有設定,先刪再啟才吃新 ecosystem @@ -71,42 +97,48 @@ async function startBotOnPm2(cfg) { function loadConsoleSettings() { const defaults = { port: 3799, host: '127.0.0.1' }; try { - return { ...defaults, ...JSON.parse(fs.readFileSync(CONSOLE_SETTINGS_FILE, 'utf-8')) }; + return { ...defaults, ...JSON.parse(fs.readFileSync(consoleSettingsFile(), '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'); + fs.mkdirSync(resolveHome(), { recursive: true }); + fs.writeFileSync(consoleSettingsFile(), JSON.stringify(settings, null, 2) + '\n'); } function consoleUrl(settings) { return `http://${settings.host === '0.0.0.0' ? 'localhost' : settings.host}:${settings.port}`; } -// 確保控制台掛在 pm2 上;已存在就不動它 +// 確保控制台掛在 pm2 上;已存在就不動它(除非要求 restart) 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 home = resolveHome(); + fs.mkdirSync(path.join(home, 'logs'), { recursive: true }); + const eco = path.join(home, 'console.ecosystem.config.js'); const app = { name: CONSOLE_NAME, script: WEB_SCRIPT, - cwd: TGCODEX_HOME, + cwd: home, env: { - TGCODEX_WEB_PORT: String(settings.port), - TGCODEX_WEB_HOST: settings.host, + TGBOT_WEB_PORT: String(settings.port), + TGBOT_WEB_HOST: settings.host, }, - out_file: path.join(TGCODEX_HOME, 'logs', 'console-out.log'), - error_file: path.join(TGCODEX_HOME, 'logs', 'console-error.log'), + out_file: path.join(home, 'logs', 'console-out.log'), + error_file: path.join(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); + const list = await jlist(); + const exists = list.some((p) => p.name === CONSOLE_NAME); + // 舊控制台程序(tgcodex-console)一律拿掉,port 才不會被佔住 + if (list.some((p) => p.name === LEGACY_CONSOLE_NAME)) { + await pm2Exec(`delete ${JSON.stringify(LEGACY_CONSOLE_NAME)}`).catch(() => {}); + } if (exists && restart) { await pm2Exec(`delete ${JSON.stringify(CONSOLE_NAME)}`).catch(() => {}); } @@ -118,11 +150,18 @@ async function ensureConsoleOnPm2({ restart = false } = {}) { } module.exports = { - TGCODEX_HOME, + TGBOT_HOME, + resolveHome, + NEW_HOME, + LEGACY_HOME, CONSOLE_NAME, + LEGACY_CONSOLE_NAME, + BOT_SCRIPT, pm2Exec, jlist, hasApp, + procEnv, + botConfigPathOf, actionByName, startBotOnPm2, loadConsoleSettings, diff --git a/src/registry.js b/src/registry.js index 062fd56..56c3b18 100644 --- a/src/registry.js +++ b/src/registry.js @@ -1,16 +1,15 @@ 'use strict'; // Bot 註冊表:web 控制台管理的 bot 實例清單。 -// bot 設定放在 ~/.tgcodex/bots//tgcodex.config.json。 -// pm2 上帶 TGCODEX_CONFIG 但不在註冊表裡的 bot 程序會被自動探索回來 +// bot 設定放在 ~/.tgbot/bots//tgbot.config.json(0.1.x 建的是 tgcodex.config.json,沿用)。 +// pm2 上帶設定檔路徑但不在註冊表裡的 bot 程序會被自動探索回來 //(registry.json 遺失時的自我修復)。 const fs = require('fs'); const path = require('path'); -const { TGCODEX_HOME, jlist } = require('./pm2util'); -const { loadConfig, SANDBOX_MODES } = require('./config'); +const { TGBOT_HOME, BOT_SCRIPT, jlist, botConfigPathOf } = require('./pm2util'); +const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME } = 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 REG_FILE = path.join(TGBOT_HOME, 'registry.json'); +const BOTS_DIR = path.join(TGBOT_HOME, 'bots'); const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/; @@ -24,7 +23,7 @@ function loadRegistry() { } function saveRegistry(reg) { - fs.mkdirSync(TGCODEX_HOME, { recursive: true }); + fs.mkdirSync(TGBOT_HOME, { recursive: true }); fs.writeFileSync(REG_FILE, JSON.stringify(reg, null, 2) + '\n'); } @@ -33,18 +32,17 @@ function maskToken(token) { return token.length <= 10 ? '***' : token.slice(0, 6) + '…' + token.slice(-4); } -// 從 pm2 探索不在註冊表裡的 bot(跑著我們 src/index.js 且帶 TGCODEX_CONFIG 的程序) +// 從 pm2 探索不在註冊表裡的 bot(跑著我們 src/index.js 且帶設定檔路徑的程序) 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 cfgPath = botConfigPathOf(p); + if (!cfgPath) continue; 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' }; + if (!fs.existsSync(cfgPath)) continue; + reg.bots[p.name] = { configPath: cfgPath, source: 'external' }; changed = true; } if (changed) saveRegistry(reg); @@ -74,6 +72,7 @@ async function listBots() { source: entry.source || 'ui', configPath: entry.configPath, error, + engine: cfg?.engine || 'codex', workDir: cfg?.workDir || null, sandbox: cfg?.sandbox || null, model: cfg?.model || '', @@ -98,7 +97,7 @@ async function listBots() { return bots; } -function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) { +function validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate }) { if (isCreate) { if (!name || !NAME_RE.test(name)) { throw new Error('名稱只能是英數字開頭,含英數字、點、底線、連字號,最長 40 字'); @@ -112,10 +111,13 @@ function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) { if (sandbox !== undefined && !SANDBOX_MODES.includes(sandbox)) { throw new Error(`sandbox 必須是 ${SANDBOX_MODES.join(' / ')}`); } + if (engine !== undefined && !ENGINE_IDS.includes(engine)) { + throw new Error(`engine 必須是 ${ENGINE_IDS.join(' / ')}`); + } } -async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sessionMode, networkAccess, timeoutMinutes }) { - validateBotInput({ name, token, workDir, sandbox }, { isCreate: true }); +async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, timeoutMinutes }) { + validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate: true }); const reg = loadRegistry(); if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`); const list = await jlist().catch(() => []); @@ -123,16 +125,17 @@ async function createBot({ name, token, workDir, sandbox, model, reasoningEffort const dir = path.join(BOTS_DIR, name); fs.mkdirSync(dir, { recursive: true }); - const configPath = path.join(dir, 'tgcodex.config.json'); + const configPath = path.join(dir, CONFIG_BASENAME); const config = { telegramToken: token, + engine: engine || 'codex', workDir: path.resolve(workDir), sandbox: sandbox || 'workspace-write', model: model || '', reasoningEffort: reasoningEffort || '', - sessionMode: ['per-chat','shared','stateless'].includes(sessionMode) ? sessionMode : 'per-chat', + sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat', networkAccess: !!networkAccess, - serviceTier: 'priority', // 速度固定 Fast 1.5x + serviceTier: 'priority', // codex 速度固定 Fast 1.5x timeoutMinutes: Number(timeoutMinutes) || 30, pm2Name: name, // UI 建的 bot 不各自開 web,統一由控制台管理 @@ -150,26 +153,36 @@ async function updateBot(name, patch) { const entry = reg.bots[name]; if (!entry) throw new Error(`找不到 bot:${name}`); const raw = JSON.parse(fs.readFileSync(entry.configPath, 'utf-8')); + const before = loadConfig(entry.configPath); if (patch.workDir !== undefined && patch.workDir !== '') { validateBotInput({ workDir: patch.workDir }, { isCreate: false }); raw.workDir = path.resolve(patch.workDir); } if (patch.token) raw.telegramToken = patch.token; // 空字串 = 不變更 + if (patch.engine !== undefined) { + validateBotInput({ engine: patch.engine }, { isCreate: false }); + raw.engine = patch.engine; + } if (patch.sandbox !== undefined) { validateBotInput({ sandbox: patch.sandbox }, { isCreate: false }); raw.sandbox = patch.sandbox; } if (patch.model !== undefined) raw.model = patch.model; if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort; - if (['per-chat','shared','stateless'].includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; } + if (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; } if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess; 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); + const after = loadConfig(entry.configPath); + // 換引擎:舊會話 id 對新引擎沒意義,清掉 + if (after.engine !== before.engine) { + try { fs.unlinkSync(path.join(after.stateDir, 'sessions.json')); } catch { /* 沒有就算了 */ } + } + return after; } function getBotConfig(name) { @@ -186,7 +199,7 @@ function removeBot(name, { deleteFiles = false } = {}) { delete reg.bots[name]; saveRegistry(reg); if (deleteFiles) { - // 只允許刪除放在 ~/.tgcodex/bots/ 底下的(UI 建的);folder 模式的設定檔不動 + // 只允許刪除放在 ~/.tgbot/bots/ 底下的(UI 建的);外部設定檔不動 const dir = path.dirname(entry.configPath); if (dir.startsWith(BOTS_DIR + path.sep)) { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/web/public/index.html b/src/web/public/index.html index 69a8f26..afaace2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3,7 +3,7 @@ -tgcodex 控制台 +telegram-bot 控制台
-

tgcodex 控制台

+

telegram-bot 控制台

@@ -143,7 +147,7 @@

- Bot 設定存於 ~/.tgcodex/bots/<名稱>/;啟動後掛在 pm2 上(同名程序)。 + Bot 設定存於 ~/.tgbot/bots/<名稱>/;啟動後掛在 pm2 上(同名程序)。每個 bot 可各自選 Codex 或 Claude 引擎。 私訊直接回應;群組中要 @bot 或回覆 bot 的訊息才會回應。

@@ -188,36 +192,44 @@
英數字、點、底線、連字號;也是 pm2 程序名稱,建立後不可改
+
+ + +
+
跟 @BotFather 申請;儲存時會自動驗證
- +
-
Codex 預設能讀寫這個目錄(workspace-write);選擇視窗會開在這台機器的桌面上
+
bot 預設能讀寫這個目錄(workspace-write);選擇視窗會開在這台機器的桌面上。
-
清單來自本機 codex 的模型快取
+
清單來自本機 codex 的模型快取
-
速度固定為 Fast(1.5x)
+
速度固定為 Fast(1.5x)
@@ -228,7 +240,7 @@
記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話
-
+
workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開
@@ -305,9 +317,55 @@ function refreshActive() { if (activeTab === 'bots') loadBots(); else loadPM2(); } -// ---------- 模型清單(來自 /api/models,讀 codex 的 models cache) ---------- -let modelsCache = []; -let modelDefaults = { model: '', effort: '', tier: '' }; // codex 全域預設(config.toml) +// ---------- 模型清單(來自 /api/models:codex 讀 models cache,claude 用內建清單) ---------- +let enginesCache = {}; // { codex: {available, models, defaults}, claude: {...} } +let defaultEngine = 'codex'; +const ENGINE_UI = { + codex: { + hint: '本機 OpenAI Codex CLI(codex exec)。專案規範讀工作目錄的 AGENTS.md', + workdir: '規範檔:AGENTS.md', + modelHint: '清單來自本機 codex 的模型快取', + effortHint: '速度固定為 Fast(1.5x)', + sandbox: { + 'workspace-write': 'workspace-write(可讀寫工作目錄,預設)', + 'read-only': 'read-only(唯讀)', + 'danger-full-access': 'danger-full-access(不設限,危險)', + }, + }, + claude: { + hint: '本機 Claude Code CLI(claude -p)。專案規範讀工作目錄的 CLAUDE.md', + workdir: '規範檔:CLAUDE.md', + modelHint: '別名 opus / sonnet / haiku 永遠指向最新版;也可在編輯時手動改成任何模型 id', + effortHint: 'Claude 的 effort 對應 --effort 參數', + sandbox: { + 'workspace-write': 'workspace-write(自動接受檔案編輯;不執行指令,預設)', + 'read-only': 'read-only(只讀:不改檔、不執行指令)', + 'danger-full-access': 'danger-full-access(跳過所有權限確認,危險)', + }, + }, +}; +function currentEngine() { return document.getElementById('f-engine').value || 'codex'; } +function engineData() { return enginesCache[currentEngine()] || { models: [], defaults: {} }; } +function engineTag(engine) { + const e = engine === 'claude' ? 'claude' : 'codex'; + return '' + (e === 'claude' ? 'Claude' : 'Codex') + ''; +} +// 引擎切換:改文案、顯示/隱藏引擎專屬選項、重載模型清單 +function onEngineChange(selModel, selEffort) { + const ui = ENGINE_UI[currentEngine()]; + const avail = enginesCache[currentEngine()] && enginesCache[currentEngine()].available; + document.getElementById('f-engine-hint').textContent = ui.hint + (avail === false ? ' ⚠ 這台機器找不到此 CLI,bot 會啟動失敗' : ''); + document.getElementById('f-workdir-hint').textContent = ui.workdir; + document.getElementById('f-model-hint').textContent = ui.modelHint; + document.getElementById('f-effort-hint').textContent = ui.effortHint; + document.getElementById('f-sb-ww').textContent = ui.sandbox['workspace-write']; + document.getElementById('f-sb-ro').textContent = ui.sandbox['read-only']; + document.getElementById('f-sb-full').textContent = ui.sandbox['danger-full-access']; + document.getElementById('f-network-wrap').style.display = currentEngine() === 'codex' ? '' : 'none'; + // 換引擎時強度清單重來,不要把上一個引擎選的值帶過去 + if (!selEffort) document.getElementById('f-effort').innerHTML = ''; + populateModelSelects(selModel || '', selEffort || ''); +} const EFFORT_LABELS = { low: 'low(快速、輕度推理)', medium: 'medium(平衡,一般預設)', @@ -319,13 +377,20 @@ const EFFORT_LABELS = { async function loadModels() { try { const d = await fetchJson('/api/models'); - modelsCache = d.models || []; - modelDefaults = d.defaults || modelDefaults; - } catch { modelsCache = []; } + enginesCache = d.engines || {}; + defaultEngine = d.defaultEngine || 'codex'; + // 沒裝的引擎在下拉選單標出來 + for (const opt of document.getElementById('f-engine').options) { + const e = enginesCache[opt.value]; + if (e && e.available === false) opt.textContent = opt.textContent.replace(/(未安裝)$/, '') + '(未安裝)'; + } + } catch { enginesCache = {}; } } // 下拉沒有「預設」空選項:直接把真實預設值選起來 function populateModelSelects(selModel, selEffort) { const mSel = document.getElementById('f-model'); + const modelsCache = engineData().models || []; + const modelDefaults = engineData().defaults || {}; let opts = modelsCache.map(m => '').join(''); // 設定檔裡有但快取沒有的模型(手填過的)也要能顯示 if (selModel && !modelsCache.some(m => m.slug === selModel)) { @@ -340,6 +405,8 @@ function populateModelSelects(selModel, selEffort) { } function syncEffortTier(selEffort) { const slug = document.getElementById('f-model').value; + const modelsCache = engineData().models || []; + const modelDefaults = engineData().defaults || {}; const m = modelsCache.find(x => x.slug === slug); let efforts = m ? m.efforts.slice() : ['low', 'medium', 'high', 'xhigh', 'max']; if (!efforts.length) efforts = ['medium']; @@ -382,7 +449,7 @@ function renderBots(bots) { const uptime = (b.status === 'online' && b.uptime) ? fmtUptime(Date.now() - b.uptime) : '-'; const online = b.status === 'online'; return '' + - '' + esc(b.name) + '' + + '' + esc(b.name) + '' + engineTag(b.engine) + (b.error ? '
' + esc(b.error) + '' : '') + '' + '' + statusBadge(b.status) + '' + '' + esc(shortPath(b.workDir)) + '' + @@ -456,7 +523,8 @@ function openCreate() { document.getElementById('f-token-hint').textContent = '跟 @BotFather 申請;儲存時會自動驗證'; document.getElementById('f-workdir').value = ''; document.getElementById('f-sandbox').value = 'workspace-write'; - populateModelSelects('', ''); + document.getElementById('f-engine').value = defaultEngine; + onEngineChange('', ''); document.getElementById('f-session').value = 'per-chat'; document.getElementById('f-network').checked = false; document.getElementById('f-timeout').value = '30'; @@ -479,7 +547,8 @@ function openEdit(name) { 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 || ''); + document.getElementById('f-engine').value = b.engine || 'codex'; + onEngineChange(b.model || '', b.reasoningEffort || ''); document.getElementById('f-session').value = b.sessionMode || 'per-chat'; document.getElementById('f-network').checked = !!b.networkAccess; document.getElementById('f-timeout').value = b.timeoutMinutes || 30; @@ -499,6 +568,7 @@ async function saveBot() { name: document.getElementById('f-name').value.trim(), token: document.getElementById('f-token').value.trim(), workDir: document.getElementById('f-workdir').value.trim(), + engine: document.getElementById('f-engine').value, sandbox: document.getElementById('f-sandbox').value, model: document.getElementById('f-model').value, reasoningEffort: document.getElementById('f-effort').value, diff --git a/src/web/server.js b/src/web/server.js index 5ceb81e..0401862 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1,10 +1,11 @@ 'use strict'; -// tgcodex 控制台:管理 TG bot(新增/編輯/啟停,token 等設定都在網頁填)+ PM2 檢視分頁。 +// tgbot 控制台:管理 TG bot(新增/編輯/啟停,token 等設定都在網頁填)+ PM2 檢視分頁。 // 零依賴(內建 http)。預設只綁 127.0.0.1 —— 這個介面能控制 pm2 與 bot token,不要裸露到外網。 const http = require('http'); const fs = require('fs'); +const os = require('os'); const path = require('path'); -const { jlist, actionByName, startBotOnPm2 } = require('../pm2util'); +const { jlist, actionByName, startBotOnPm2, TGBOT_HOME } = require('../pm2util'); const { listBots, createBot, updateBot, removeBot, getBotConfig } = require('../registry'); const { assertBotConfig, ensureStateDirs } = require('../config'); @@ -94,35 +95,41 @@ async function apiPm2Log(pmId, type, 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:直接讀它自己的 models cache(~/.codex/models_cache.json),新模型上線後下拉選單自動跟上; +// 讀不到就退回內建清單。 +// claude:CLI 沒有模型清單可查,用內建清單(別名 opus/sonnet/haiku 永遠指向最新版)。 +const CODEX_FALLBACK_MODELS = [ + { slug: 'gpt-5.6-sol', label: 'GPT-5.6-Sol', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium' }, + { slug: 'gpt-5.6-terra', label: 'GPT-5.6-Terra', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], defaultEffort: 'medium' }, + { slug: 'gpt-5.6-luna', label: 'GPT-5.6-Luna', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defaultEffort: 'medium' }, +]; + +const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max']; +const CLAUDE_MODELS = [ + { slug: 'opus', label: 'Opus(最新版,目前 Opus 5)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, + { slug: 'sonnet', label: 'Sonnet(最新版,較快)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, + { slug: 'haiku', label: 'Haiku(最快、最省)', efforts: ['low', 'medium', 'high'], defaultEffort: 'medium' }, + { slug: 'claude-opus-5', label: 'claude-opus-5(鎖定版本)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, + { slug: 'claude-sonnet-5', label: 'claude-sonnet-5(鎖定版本)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' }, ]; // 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: '' }; + return { model: '', effort: '' }; } } -function apiModels() { +function codexModels() { 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 cache = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.codex', 'models_cache.json'), 'utf-8')); const models = (cache.models || []) .filter((m) => m.visibility !== 'hide' && m.slug) .map((m) => ({ @@ -130,12 +137,41 @@ function apiModels() { 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' }; + return { models: CODEX_FALLBACK_MODELS, defaults, source: 'fallback' }; +} + +// claude 全域預設(~/.claude/settings.json 的 model;沒有就 opus) +function claudeModels() { + let model = ''; + try { + const s = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.json'), 'utf-8')); + if (typeof s.model === 'string') model = s.model; + } catch { /* ignore */ } + return { models: CLAUDE_MODELS, defaults: { model: model || 'opus', effort: '' }, source: 'builtin' }; +} + +// 引擎是否裝了(開控制台時查一次就好,之後不變) +let availabilityCache = null; +function engineAvailability() { + if (!availabilityCache) { + const { codexVersion, claudeVersion } = require('../doctor'); + availabilityCache = { codex: !!codexVersion(), claude: !!claudeVersion() }; + } + return availabilityCache; +} + +function apiModels() { + const avail = engineAvailability(); + return { + engines: { + codex: { available: avail.codex, ...codexModels() }, + claude: { available: avail.claude, ...claudeModels() }, + }, + defaultEngine: avail.codex || !avail.claude ? 'codex' : 'claude', + }; } // ---------- Bot 管理分頁 ---------- @@ -259,7 +295,7 @@ function pickFolderNative() { '$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.Description = '選擇 bot 的工作目錄'", '$f.ShowNewFolderButton = $true', '$r = $f.ShowDialog($o)', '$o.Close()', @@ -269,11 +305,11 @@ function pickFolderNative() { 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) => { + child = execFile('osascript', ['-e', 'POSIX path of (choose folder with prompt "選擇 bot 的工作目錄")'], 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) => { + child = execFile('zenity', ['--file-selection', '--directory', '--title=選擇 bot 的工作目錄'], opts, (err, stdout) => { settle(child, null, err ? null : (stdout.trim() || null)); }); } @@ -311,6 +347,9 @@ function createWebServer({ port = 3799, host = '127.0.0.1' } = {}) { if (url.pathname === '/api/models' && req.method === 'GET') { return json(200, apiModels()); } + if (url.pathname === '/api/info' && req.method === 'GET') { + return json(200, { home: TGBOT_HOME, version: require('../../package.json').version }); + } if (url.pathname === '/api/pick-folder' && req.method === 'POST') { const picked = await pickFolderNative(); return json(200, picked ? { path: picked } : { canceled: true }); @@ -360,7 +399,7 @@ function createWebServer({ port = 3799, host = '127.0.0.1' } = {}) { 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}`); + console.log(`✅ telegram-bot 控制台已啟動:http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`); resolve(server); }); }); @@ -371,12 +410,12 @@ function createWebServer({ port = 3799, host = '127.0.0.1' } = {}) { module.exports = { createWebServer }; -// 直接執行:node src/web/server.js(pm2 的 tgcodex-console 跑的就是這個) +// 直接執行:node src/web/server.js(pm2 的 tgbot-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; + const port = Number(process.env.TGBOT_WEB_PORT || process.env.TGCODEX_WEB_PORT) || settings.port; + const host = process.env.TGBOT_WEB_HOST || process.env.TGCODEX_WEB_HOST || settings.host; createWebServer({ port, host }).listen().catch((err) => { console.error('啟動失敗:', err.message); process.exit(1);