diff --git a/README.md b/README.md index b2800ea..94a8f95 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ - **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組 - **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟 - **多 bot**:一頁管理多個 bot,各自不同引擎、token、專案目錄 -- **會話延續**:每個聊天室一個會話,`/new` 開新會話;支援傳圖、引用回覆;bot 之間可互相接力(引用另一隻 bot 的回覆再 tag) +- **會話延續**:每個聊天室一個會話,`/new` 開新會話;支援傳圖與附件(PDF/文字/程式碼檔)、引用回覆;bot 之間可互相接力(引用另一隻 bot 的回覆再 tag) +- **權限與規範**:可限定只有哪些 Telegram 帳號能讓 AI 改檔/跑指令(其他人自動唯讀);可設每則訊息前綴的回覆規範(專案守門、成員身分) ## 前置需求 @@ -63,6 +64,7 @@ telegram-bot start # 重新 start 一次,bot 程序會自動切到新版 | `danger-full-access` | 不設限 | `--dangerously-skip-permissions`,不設限 | | 生圖 | 內建 imagegen 技能,產圖自動傳回 | 無(可自行在 CLAUDE.md 接其他工具) | | 傳圖給 bot | `-i` 直接餵給模型 | 存暫存檔後由 Read 工具讀取 | +| 傳檔案給 bot(PDF / 文字 / 程式碼) | 路徑寫進 prompt,由 codex 讀取 | 存暫存檔後由 Read 工具讀取 | 兩種引擎的回覆尾端都會附 `— ✅ 時間 | ctx 用量% +本輪 token | 模型 / 強度`。 AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動傳到 Telegram 並清空。 @@ -89,7 +91,9 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動 4. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入 5. **模型 / 推理強度**:下拉選單,預設值自動帶你機器上該引擎的全域設定;換模型時強度選項會連動 6. **會話模式**:各聊天室獨立 / 所有聊天室共用 / 每則訊息獨立(不保留記憶,最快) -7. 勾「建立後立即啟動」→ 儲存,bot 就上線了 +7. **可讀寫成員**(選填):填 Telegram username(逗號分隔),清單外的人一律以 read-only 沙盒執行,由 CLI 層硬擋;留空 = 所有人都依沙盒設定 +8. **回覆規範**(選填):每則訊息開頭附加的規則,例如「只回答本專案相關問題」「@xxx 是後端工程師,回覆用 API 契約視角」;專案本身的規範仍放工作目錄的 `AGENTS.md` / `CLAUDE.md` +9. 勾「建立後立即啟動」→ 儲存,bot 就上線了  @@ -97,6 +101,10 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動  +**編輯既有 bot** — 引擎、模型、可讀寫成員、回覆規範都可事後修改;執行中的 bot 儲存後會自動重啟套用。 + + + **③ PM2 分頁** — 整台機器所有 pm2 程序。Port 可直接點開對應網頁,右側可啟停/重啟/刪除任何程序。  @@ -134,6 +142,7 @@ telegram-bot web 前景執行控制台(除錯用) - **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應 - **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入 +- **附件**:圖片、PDF、文字/程式碼檔都會下載給 AI 讀(Telegram Bot 下載上限 20MB;docx/xlsx/zip 等二進位不支援,bot 會回覆說明) - **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內) - 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢 diff --git a/docs/01-bots.png b/docs/01-bots.png index ed62681..8a9b244 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 2a240ed..240ae34 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 index caedf39..9fb145f 100644 Binary files a/docs/02b-create-claude.png and b/docs/02b-create-claude.png differ diff --git a/docs/03-pm2.png b/docs/03-pm2.png index 019c283..4464ad1 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 be5200f..9408f35 100644 Binary files a/docs/04-log.png and b/docs/04-log.png differ diff --git a/docs/05-edit-claude.png b/docs/05-edit-claude.png new file mode 100644 index 0000000..ccc8c78 Binary files /dev/null and b/docs/05-edit-claude.png differ diff --git a/src/bot.js b/src/bot.js index 5e21891..59d5e0b 100644 --- a/src/bot.js +++ b/src/bot.js @@ -205,18 +205,28 @@ function startBot(config) { } catch { /* ignore */ } } - // 取出訊息附圖(photo 取最大尺寸;image/* 的 document 也支援) - function pickPhoto(message) { + // 取出訊息附件:photo 取最大尺寸;document 依 mime/副檔名分成 image / file(PDF、文字、程式碼)/ unsupported + const TELEGRAM_GETFILE_LIMIT = 20 * 1024 * 1024; // Bot API getFile 上限 + const TEXT_EXTS = new Set(['.txt', '.md', '.log', '.json', '.yaml', '.yml', '.csv', '.tsv', '.xml', '.html', '.htm', '.css', + '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rs', '.c', '.cc', '.cpp', '.h', '.hpp', '.cs', + '.php', '.rb', '.sh', '.bat', '.ps1', '.sql', '.toml', '.ini', '.cfg', '.env', '.conf', '.vue', '.svelte', '.kt', '.swift']); + function pickAttachment(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' }; + return { kind: 'image', fileId: largest.file_id, uniqueId: largest.file_unique_id, ext: '.jpg', name: '', size: largest.file_size || 0 }; } 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 }; + if (!doc) return null; + const mime = doc.mime_type || ''; + const name = doc.file_name || ''; + const extFromName = path.extname(name).toLowerCase(); + const base = { fileId: doc.file_id, uniqueId: doc.file_unique_id, name, mime, size: doc.file_size || 0 }; + if (/^image\//.test(mime)) return { ...base, kind: 'image', ext: extFromName || '.' + (mime.split('/')[1] || 'png') }; + if (mime === 'application/pdf' || extFromName === '.pdf') return { ...base, kind: 'file', ext: '.pdf' }; + if (/^text\//.test(mime) || /^application\/(json|xml|x-yaml|yaml|javascript|typescript|x-sh|toml)$/.test(mime) || TEXT_EXTS.has(extFromName)) { + return { ...base, kind: 'file', ext: extFromName || '.txt' }; } - return null; + return { ...base, kind: 'unsupported', ext: extFromName }; } // 使用者引用/回覆的文字(圈選引用優先,其次是被回覆的整則訊息) @@ -255,6 +265,7 @@ function startBot(config) { const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者'; const sender = from.username ? `${name}(@${from.username})` : name; const parts = [OUTBOX_RULE]; + if (config.promptRules) parts.push(config.promptRules); const replied = message.reply_to_message; // 回覆我自己的訊息通常已在會話記憶裡,不重複貼;但使用者沒打字只回覆時例外(那就是輸入本身) if (replied && (includeOwnReply || replied.from?.id !== botId) && (replied.text || replied.caption)) { @@ -278,6 +289,8 @@ function startBot(config) { `模型:${model || `(${engine.label} 預設)`}`, `推理強度:${effort || '(預設)'}`, `會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`, + config.writeUsers.length ? `可讀寫成員:${config.writeUsers.map((u) => '@' + u).join('、')}(其他人唯讀)` : '可讀寫成員:所有人(依沙盒設定)', + config.promptRules ? `回覆規範:已設定(${config.promptRules.length} 字)` : '回覆規範:無', config.sessionMode === 'stateless' ? '會話:每則訊息獨立,不保留記憶' : s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)', @@ -307,9 +320,14 @@ function startBot(config) { const chatId = message.chat.id; const sessionKey = sessionKeyOf(chatId); const text = message.text || message.caption || ''; - const photo = pickPhoto(message); + const attachment = pickAttachment(message); + const photo = attachment; // 有任何附件都視同有內容 if (!text && !photo) return; if (!shouldRespond(message)) return; + // 讀寫權限:有設 writeUsers 時,清單外的人一律 read-only(沙盒層硬擋,不靠模型自律) + const senderName = (message.from?.username || '').toLowerCase(); + const canWrite = config.writeUsers.length === 0 || config.writeUsers.some((u) => u.toLowerCase() === senderName); + const runConfig = canWrite ? config : { ...config, sandbox: 'read-only', networkAccess: false }; const content = stripMention(text); @@ -365,14 +383,29 @@ function startBot(config) { 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 files = []; + let attachmentNote = ''; + if (attachment) { + const meta = [attachment.name && `原檔名 ${attachment.name}`, attachment.mime, attachment.size && `${Math.round(attachment.size / 1024)}KB`] + .filter(Boolean).join(','); + if (attachment.kind === 'unsupported') { + attachmentNote = `\n\n【附件】(${meta})格式不支援(docx/xlsx/zip 等二進位讀不了),回覆時告知使用者,建議貼成文字或轉 PDF`; + } else if (attachment.size > TELEGRAM_GETFILE_LIMIT) { + attachmentNote = `\n\n【附件】(${meta})超過 Telegram Bot 下載上限 20MB 無法讀取,回覆時告知使用者`; + } else { + cleanTmp(); + const dest = path.join(config.tmpDir, `${Date.now()}-${attachment.uniqueId}${attachment.ext}`); + await t.downloadFile(attachment.fileId, dest); + if (attachment.kind === 'image') { + images.push(dest); + } else { + files.push(dest); + attachmentNote = `\n\n【附件檔案】${meta ? `(${meta})` : ''}請先用讀檔工具讀取再處理:\n${dest}`; + } + } } - let fallback = '(使用者只傳了圖片,請描述並依上下文處理)'; + let fallback = files.length ? '(使用者只傳了檔案,請讀取後依上下文處理)' : '(使用者只傳了圖片,請描述並依上下文處理)'; if (quotedOnly && (quoted || !sibling)) { fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)'; } else if (quotedOnly && sibling) { @@ -380,7 +413,7 @@ function startBot(config) { `【引用的 bot 訊息(@${sibling.botUsername} 在本聊天室的最近發言)】\n${sibling.text}\n\n` + '(使用者沒有輸入文字,只引用了訊息並 tag 我:請直接把上面引用的內容當作這次的輸入來處理)'; } - const prompt = buildPrompt(message, content || fallback, { includeOwnReply: quotedOnly }); + const prompt = buildPrompt(message, (content || fallback) + attachmentNote, { includeOwnReply: quotedOnly }); const existing = sessionKey ? sessions.get(sessionKey) : null; const turnStart = Date.now(); @@ -390,7 +423,7 @@ function startBot(config) { const MAX_ATTEMPTS = 2; for (let attempt = 1; ; attempt++) { try { - result = await engine.run({ config, prompt, threadId: tid, images, onProgress }); + result = await engine.run({ config: runConfig, prompt, threadId: tid, images, files, onProgress }); break; } catch (err) { // 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume @@ -443,7 +476,7 @@ function startBot(config) { // 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}。`; + ctxNote += `\n\n⚠️ 有 ${result.denials.length} 個操作因沙盒權限被拒${tools ? `(${tools})` : ''};目前沙盒為 ${runConfig.sandbox}${canWrite ? '' : '(你不在可讀寫成員清單內)'}。`; } const footer = buildFooter({ ...display, usage: result.usage }); diff --git a/src/config.js b/src/config.js index 5a6e664..8de83cc 100644 --- a/src/config.js +++ b/src/config.js @@ -35,6 +35,11 @@ const DEFAULTS = { autoResetCtxPercent: 80, // codex 專用:允許在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開) networkAccess: false, + // 每則訊息開頭附加的回覆規範(例如:只回答專案相關問題、成員身分說明);空字串 = 不加 + promptRules: '', + // 可讀寫的 Telegram username 清單(不含 @);非空時,清單外的人一律以 read-only 沙盒執行。 + // 空陣列 = 所有人都依 sandbox 設定 + writeUsers: [], // 單次回應逾時(分鐘) timeoutMinutes: 30, // pm2 程序名稱 @@ -47,6 +52,12 @@ const DEFAULTS = { claudePath: '', }; +// 'a, @b' / ['a','@b'] → ['a','b'](去 @、去空白、去重) +function normalizeUsers(v) { + const arr = Array.isArray(v) ? v : String(v || '').split(/[\s,,;;]+/); + return [...new Set(arr.map((u) => String(u || '').trim().replace(/^@/, '')).filter(Boolean))]; +} + function firstExisting(...files) { return files.find((f) => fs.existsSync(f)) || files[0]; } @@ -131,6 +142,8 @@ function loadConfig(explicitPath) { cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat'; } cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0; + cfg.promptRules = typeof cfg.promptRules === 'string' ? cfg.promptRules.trim() : ''; + cfg.writeUsers = normalizeUsers(cfg.writeUsers); const minutes = Number(cfg.timeoutMinutes); if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`); @@ -160,5 +173,5 @@ function ensureStateDirs(cfg) { module.exports = { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, - loadConfig, assertBotConfig, ensureStateDirs, findConfigPath, + loadConfig, assertBotConfig, ensureStateDirs, findConfigPath, normalizeUsers, }; diff --git a/src/engines/claude.js b/src/engines/claude.js index 3239b7a..00aaddc 100644 --- a/src/engines/claude.js +++ b/src/engines/claude.js @@ -75,7 +75,7 @@ function permissionArgs(sandbox) { * 執行一輪 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 }) { +function run({ config, prompt, threadId, images = [], files = [], onProgress }) { return new Promise((resolve, reject) => { const { cmd, shell } = resolveClaudeCommand(config); @@ -83,8 +83,9 @@ function run({ config, prompt, threadId, images = [], onProgress }) { 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])); + // 附圖/附件:claude -p 沒有檔案參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍) + const attachDirs = [...new Set([...images, ...files].map((f) => path.dirname(f)))]; + for (const d of attachDirs) args.push('--add-dir', d); if (threadId) args.push('--resume', threadId); let fullPrompt = prompt; diff --git a/src/registry.js b/src/registry.js index 56c3b18..3ba5c1c 100644 --- a/src/registry.js +++ b/src/registry.js @@ -6,7 +6,7 @@ const fs = require('fs'); const path = require('path'); const { TGBOT_HOME, BOT_SCRIPT, jlist, botConfigPathOf } = require('./pm2util'); -const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME } = require('./config'); +const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME, normalizeUsers } = require('./config'); const REG_FILE = path.join(TGBOT_HOME, 'registry.json'); const BOTS_DIR = path.join(TGBOT_HOME, 'bots'); @@ -80,6 +80,8 @@ async function listBots() { sessionMode: cfg?.sessionMode || 'per-chat', networkAccess: !!cfg?.networkAccess, serviceTier: cfg?.serviceTier || '', + promptRules: cfg?.promptRules || '', + writeUsers: cfg?.writeUsers || [], timeoutMinutes: cfg?.timeoutMinutes || null, tokenMasked: maskToken(cfg?.telegramToken), hasToken: !!cfg?.telegramToken, @@ -116,7 +118,7 @@ function validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate } } -async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, timeoutMinutes }) { +async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, promptRules, writeUsers, timeoutMinutes }) { validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate: true }); const reg = loadRegistry(); if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`); @@ -135,6 +137,8 @@ async function createBot({ name, token, workDir, engine, sandbox, model, reasoni reasoningEffort: reasoningEffort || '', sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat', networkAccess: !!networkAccess, + promptRules: typeof promptRules === 'string' ? promptRules.trim() : '', + writeUsers: normalizeUsers(writeUsers), serviceTier: 'priority', // codex 速度固定 Fast 1.5x timeoutMinutes: Number(timeoutMinutes) || 30, pm2Name: name, @@ -172,6 +176,8 @@ async function updateBot(name, patch) { if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort; if (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; } if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess; + if (patch.promptRules !== undefined) raw.promptRules = String(patch.promptRules || '').trim(); + if (patch.writeUsers !== undefined) raw.writeUsers = normalizeUsers(patch.writeUsers); if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) { raw.timeoutMinutes = Number(patch.timeoutMinutes); } diff --git a/src/web/public/index.html b/src/web/public/index.html index 07fdb72..57a7eea 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -66,6 +66,11 @@ .field { margin-bottom: 13px; } .field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; } .field label b { color: #ed4245; } + .field textarea { + width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px; + color: #e2e8f0; padding: 9px 11px; font-size: 12.5px; font-family: inherit; line-height: 1.5; resize: vertical; min-height: 84px; + } + .field textarea:focus { outline: none; border-color: #5865f2; } .field input, .field select { width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px; color: #e2e8f0; padding: 9px 11px; font-size: 13px; font-family: inherit; @@ -249,6 +254,16 @@