From b80d6f0a1bed22675a9bed2d9e494c583c34d6cb Mon Sep 17 00:00:00 2001 From: JianMiau Date: Fri, 14 Aug 2026 17:13:12 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BC=E4=BD=88=200.1.8=EF=BC=9A=E6=94=AF?= =?UTF-8?q?=E6=8F=B4=E5=82=B3=E5=9C=96=EF=BC=8F=E5=82=B3=E6=AA=94=E7=B5=A6?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=80=85=EF=BC=8C=E6=96=B0=E5=A2=9E=E9=80=A3?= =?UTF-8?q?=E7=B6=B2=E9=96=8B=E9=97=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 摘要: 1. 交件匣機制:每輪 prompt 附系統規則,Codex 把要給使用者的檔案放進 工作目錄的 .tgcodex-outbox/,回覆後 bot 自動傳到 Telegram(圖片走 sendPhoto、其餘或超過 10MB 走 sendDocument)並清空,一輪上限 10 個檔案。 2. bot 設定新增「允許 Codex 連網」開關(-c sandbox_workspace_write.network_access), 開啟後可用 codex 的 imagegen 技能生圖,已實測產出 PNG 進交件匣。 Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/bot.js | 37 ++++++++++++++++++++++++++++++++++++- src/codex.js | 1 + src/config.js | 2 ++ src/registry.js | 5 ++++- src/telegram.js | 31 ++++++++++++++++++++++++++++++- src/web/public/index.html | 11 +++++++++-- 7 files changed, 83 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3c13b5e..afe6f07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-codex-bot", - "version": "0.1.7", + "version": "0.1.8", "description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer", "license": "MIT", "type": "commonjs", diff --git a/src/bot.js b/src/bot.js index 44b99ea..c757606 100644 --- a/src/bot.js +++ b/src/bot.js @@ -55,6 +55,13 @@ function buildFooter(config, usage, modelMeta, codexDefaults) { return `\n\n${parts.join(' | ')}`; } +// Codex 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫) +const OUTBOX_DIRNAME = '.tgcodex-outbox'; +const OUTBOX_RULE = + `【系統規則】若要把圖片或檔案傳給使用者,請將檔案寫入工作目錄下的 ${OUTBOX_DIRNAME}/ 資料夾,` + + 'bot 會在回覆後自動傳送到 Telegram 並清空該資料夾。'; +const MAX_OUTBOX_FILES = 10; + const NEW_COMMANDS = ['/new', '!clear', '!reset', '!new', '!清除', '!重置', '!新會話']; const STATUS_COMMANDS = ['/status', '!status', '!狀態']; const HELP_COMMANDS = ['/start', '/help', '!help', '!幫助']; @@ -93,6 +100,33 @@ function startBot(config) { return commands.includes(head.toLowerCase()); } + // 把 codex 放進交件匣的檔案傳到 Telegram,傳完清掉 + async function flushOutbox(chatId, replyTo) { + const dir = path.join(config.workDir, OUTBOX_DIRNAME); + let files = []; + try { + files = fs.readdirSync(dir) + .map((f) => path.join(dir, f)) + .filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) + .sort(); + } catch { + return; // 沒有交件匣就沒事 + } + const skipped = files.length - MAX_OUTBOX_FILES; + for (const file of files.slice(0, MAX_OUTBOX_FILES)) { + try { + await t.sendFile(chatId, file, { replyTo }); + fs.unlinkSync(file); + } catch (err) { + console.error('傳送交件匣檔案失敗:', file, err.message); + await t.sendMessage(chatId, `⚠️ 檔案傳送失敗:${path.basename(file)}(${err.message})`).catch(() => {}); + } + } + if (skipped > 0) { + await t.sendMessage(chatId, `⚠️ 交件匣一次最多傳 ${MAX_OUTBOX_FILES} 個檔案,還有 ${skipped} 個留在 ${OUTBOX_DIRNAME}/`).catch(() => {}); + } + } + function cleanTmp() { const ttl = 24 * 60 * 60 * 1000; try { @@ -123,7 +157,7 @@ function startBot(config) { const from = message.from || {}; const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者'; const sender = from.username ? `${name}(@${from.username})` : name; - const parts = []; + const parts = [OUTBOX_RULE]; const replied = message.reply_to_message; if (replied && replied.from?.id !== botId && (replied.text || replied.caption)) { parts.push(`【被回覆的訊息】\n${replied.text || replied.caption}`); @@ -256,6 +290,7 @@ function startBot(config) { const footer = buildFooter(config, result.usage, modelMeta, codexDefaults); const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + footer); await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true }); + await flushOutbox(chatId, message.message_id); await t.react(chatId, message.message_id, '👍'); } catch (error) { console.error('處理訊息失敗:', error); diff --git a/src/codex.js b/src/codex.js index c75aebb..334520c 100644 --- a/src/codex.js +++ b/src/codex.js @@ -101,6 +101,7 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) { } if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`); if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`); + if (config.networkAccess) args.push('-c', 'sandbox_workspace_write.network_access=true'); for (const img of images) args.push('-i', img); args.push('-'); // prompt 從 stdin 讀 diff --git a/src/config.js b/src/config.js index 26b39bb..1b88e4d 100644 --- a/src/config.js +++ b/src/config.js @@ -22,6 +22,8 @@ const DEFAULTS = { serviceTier: 'priority', // 會話模式:false = 每個聊天室獨立會話(預設);true = 所有聊天室共用一個會話 sharedSession: false, + // 允許 Codex 在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開) + networkAccess: false, // 單次回應逾時(分鐘) timeoutMinutes: 30, // pm2 程序名稱(web 檢視工具會叫 -web) diff --git a/src/registry.js b/src/registry.js index ab7daaa..f76af53 100644 --- a/src/registry.js +++ b/src/registry.js @@ -79,6 +79,7 @@ async function listBots() { model: cfg?.model || '', reasoningEffort: cfg?.reasoningEffort || '', sharedSession: !!cfg?.sharedSession, + networkAccess: !!cfg?.networkAccess, serviceTier: cfg?.serviceTier || '', timeoutMinutes: cfg?.timeoutMinutes || null, tokenMasked: maskToken(cfg?.telegramToken), @@ -113,7 +114,7 @@ function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) { } } -async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sharedSession, timeoutMinutes }) { +async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sharedSession, networkAccess, timeoutMinutes }) { validateBotInput({ name, token, workDir, sandbox }, { isCreate: true }); const reg = loadRegistry(); if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`); @@ -130,6 +131,7 @@ async function createBot({ name, token, workDir, sandbox, model, reasoningEffort model: model || '', reasoningEffort: reasoningEffort || '', sharedSession: !!sharedSession, + networkAccess: !!networkAccess, serviceTier: 'priority', // 速度固定 Fast 1.5x timeoutMinutes: Number(timeoutMinutes) || 30, pm2Name: name, @@ -161,6 +163,7 @@ async function updateBot(name, patch) { if (patch.model !== undefined) raw.model = patch.model; if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort; if (patch.sharedSession !== undefined) raw.sharedSession = !!patch.sharedSession; + if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess; if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) { raw.timeoutMinutes = Number(patch.timeoutMinutes); } diff --git a/src/telegram.js b/src/telegram.js index a397807..65dab99 100644 --- a/src/telegram.js +++ b/src/telegram.js @@ -86,6 +86,35 @@ function createTelegram(token) { }).catch(() => {}); } + // 上傳檔案到 Telegram:圖片走 sendPhoto,其餘(或 sendPhoto 失敗時)走 sendDocument + async function sendFile(chatId, filePath, { caption, replyTo } = {}) { + const fs = require('fs'); + const path = require('path'); + const name = path.basename(filePath); + const isImage = /\.(png|jpe?g|gif|webp)$/i.test(name); + const size = fs.statSync(filePath).size; + + const upload = async (method, field) => { + const form = new FormData(); + form.append('chat_id', String(chatId)); + if (caption) form.append('caption', caption.slice(0, 1000)); + if (replyTo) form.append('reply_parameters', JSON.stringify({ message_id: replyTo, allow_sending_without_reply: true })); + form.append(field, new Blob([fs.readFileSync(filePath)]), name); + const res = await fetch(`${API}/${method}`, { method: 'POST', body: form }); + const data = await res.json(); + if (!data.ok) throw new Error(`${method} 失敗:${data.description}`); + return data.result; + }; + + // sendPhoto 上限 10MB,且某些格式會被拒,失敗就退回用檔案傳 + if (isImage && size <= 9.5 * 1024 * 1024) { + try { + return await upload('sendPhoto', 'photo'); + } catch { /* fall through */ } + } + return upload('sendDocument', 'document'); + } + async function downloadFile(fileId, destPath) { const fs = require('fs'); const info = await tg('getFile', { file_id: fileId }); @@ -96,7 +125,7 @@ function createTelegram(token) { return destPath; } - return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile }; + return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile, sendFile }; } module.exports = { createTelegram }; diff --git a/src/web/public/index.html b/src/web/public/index.html index 33a2747..d385091 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -227,6 +227,10 @@
共用模式下 /new 會清掉所有聊天室的記憶
+
+ +
workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開
+
@@ -382,8 +386,8 @@ function renderBots(bots) { '' + statusBadge(b.status) + '' + '' + esc(shortPath(b.workDir)) + '' + '' + esc(b.sandbox || '-') + - ((b.model || b.reasoningEffort || b.sharedSession) - ? '
' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : ''].filter(Boolean).join(' / ')) + '' + ((b.model || b.reasoningEffort || b.sharedSession || b.networkAccess) + ? '
' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '' : '') + '' + '' + esc(b.tokenMasked || '-') + '' + '' + fmtBytes(b.memory) + '' + @@ -453,6 +457,7 @@ function openCreate() { document.getElementById('f-sandbox').value = 'workspace-write'; populateModelSelects('', ''); document.getElementById('f-session').value = 'per-chat'; + document.getElementById('f-network').checked = false; document.getElementById('f-timeout').value = '30'; document.getElementById('f-autostart-wrap').style.display = ''; document.getElementById('f-autostart').checked = true; @@ -475,6 +480,7 @@ function openEdit(name) { document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write'; populateModelSelects(b.model || '', b.reasoningEffort || ''); document.getElementById('f-session').value = b.sharedSession ? 'shared' : 'per-chat'; + document.getElementById('f-network').checked = !!b.networkAccess; document.getElementById('f-timeout').value = b.timeoutMinutes || 30; document.getElementById('f-autostart-wrap').style.display = 'none'; document.getElementById('bot-modal').classList.add('show'); @@ -496,6 +502,7 @@ async function saveBot() { model: document.getElementById('f-model').value, reasoningEffort: document.getElementById('f-effort').value, sharedSession: document.getElementById('f-session').value === 'shared', + networkAccess: document.getElementById('f-network').checked, timeoutMinutes: document.getElementById('f-timeout').value, }; try {