發佈 0.1.8:支援傳圖/傳檔給使用者,新增連網開關

摘要:
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 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 17:13:12 +08:00
co-authored by Claude Fable 5
parent d5c4464fe1
commit b80d6f0a1b
7 changed files with 83 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "telegram-codex-bot", "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", "description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer",
"license": "MIT", "license": "MIT",
"type": "commonjs", "type": "commonjs",
+36 -1
View File
@@ -55,6 +55,13 @@ function buildFooter(config, usage, modelMeta, codexDefaults) {
return `\n\n${parts.join(' | ')}`; 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 NEW_COMMANDS = ['/new', '!clear', '!reset', '!new', '!清除', '!重置', '!新會話'];
const STATUS_COMMANDS = ['/status', '!status', '!狀態']; const STATUS_COMMANDS = ['/status', '!status', '!狀態'];
const HELP_COMMANDS = ['/start', '/help', '!help', '!幫助']; const HELP_COMMANDS = ['/start', '/help', '!help', '!幫助'];
@@ -93,6 +100,33 @@ function startBot(config) {
return commands.includes(head.toLowerCase()); 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() { function cleanTmp() {
const ttl = 24 * 60 * 60 * 1000; const ttl = 24 * 60 * 60 * 1000;
try { try {
@@ -123,7 +157,7 @@ function startBot(config) {
const from = message.from || {}; const from = message.from || {};
const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者'; const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者';
const sender = from.username ? `${name}@${from.username}` : name; const sender = from.username ? `${name}@${from.username}` : name;
const parts = []; const parts = [OUTBOX_RULE];
const replied = message.reply_to_message; const replied = message.reply_to_message;
if (replied && replied.from?.id !== botId && (replied.text || replied.caption)) { if (replied && replied.from?.id !== botId && (replied.text || replied.caption)) {
parts.push(`【被回覆的訊息】\n${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 footer = buildFooter(config, result.usage, modelMeta, codexDefaults);
const html = t.mdToTgHtml(sessionResetNote + (result.text || 'Codex 沒有回覆文字)') + footer); const html = t.mdToTgHtml(sessionResetNote + (result.text || 'Codex 沒有回覆文字)') + footer);
await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true }); await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true });
await flushOutbox(chatId, message.message_id);
await t.react(chatId, message.message_id, '👍'); await t.react(chatId, message.message_id, '👍');
} catch (error) { } catch (error) {
console.error('處理訊息失敗:', error); console.error('處理訊息失敗:', error);
+1
View File
@@ -101,6 +101,7 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) {
} }
if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`); if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`);
if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`); 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); for (const img of images) args.push('-i', img);
args.push('-'); // prompt 從 stdin 讀 args.push('-'); // prompt 從 stdin 讀
+2
View File
@@ -22,6 +22,8 @@ const DEFAULTS = {
serviceTier: 'priority', serviceTier: 'priority',
// 會話模式:false = 每個聊天室獨立會話(預設);true = 所有聊天室共用一個會話 // 會話模式:false = 每個聊天室獨立會話(預設);true = 所有聊天室共用一個會話
sharedSession: false, sharedSession: false,
// 允許 Codex 在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開)
networkAccess: false,
// 單次回應逾時(分鐘) // 單次回應逾時(分鐘)
timeoutMinutes: 30, timeoutMinutes: 30,
// pm2 程序名稱(web 檢視工具會叫 <pm2Name>-web // pm2 程序名稱(web 檢視工具會叫 <pm2Name>-web
+4 -1
View File
@@ -79,6 +79,7 @@ async function listBots() {
model: cfg?.model || '', model: cfg?.model || '',
reasoningEffort: cfg?.reasoningEffort || '', reasoningEffort: cfg?.reasoningEffort || '',
sharedSession: !!cfg?.sharedSession, sharedSession: !!cfg?.sharedSession,
networkAccess: !!cfg?.networkAccess,
serviceTier: cfg?.serviceTier || '', serviceTier: cfg?.serviceTier || '',
timeoutMinutes: cfg?.timeoutMinutes || null, timeoutMinutes: cfg?.timeoutMinutes || null,
tokenMasked: maskToken(cfg?.telegramToken), 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 }); validateBotInput({ name, token, workDir, sandbox }, { isCreate: true });
const reg = loadRegistry(); const reg = loadRegistry();
if (reg.bots[name]) throw new Error(`已有同名 bot${name}`); if (reg.bots[name]) throw new Error(`已有同名 bot${name}`);
@@ -130,6 +131,7 @@ async function createBot({ name, token, workDir, sandbox, model, reasoningEffort
model: model || '', model: model || '',
reasoningEffort: reasoningEffort || '', reasoningEffort: reasoningEffort || '',
sharedSession: !!sharedSession, sharedSession: !!sharedSession,
networkAccess: !!networkAccess,
serviceTier: 'priority', // 速度固定 Fast 1.5x serviceTier: 'priority', // 速度固定 Fast 1.5x
timeoutMinutes: Number(timeoutMinutes) || 30, timeoutMinutes: Number(timeoutMinutes) || 30,
pm2Name: name, pm2Name: name,
@@ -161,6 +163,7 @@ async function updateBot(name, patch) {
if (patch.model !== undefined) raw.model = patch.model; if (patch.model !== undefined) raw.model = patch.model;
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort; if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
if (patch.sharedSession !== undefined) raw.sharedSession = !!patch.sharedSession; if (patch.sharedSession !== undefined) raw.sharedSession = !!patch.sharedSession;
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess;
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) { if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
raw.timeoutMinutes = Number(patch.timeoutMinutes); raw.timeoutMinutes = Number(patch.timeoutMinutes);
} }
+30 -1
View File
@@ -86,6 +86,35 @@ function createTelegram(token) {
}).catch(() => {}); }).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) { async function downloadFile(fileId, destPath) {
const fs = require('fs'); const fs = require('fs');
const info = await tg('getFile', { file_id: fileId }); const info = await tg('getFile', { file_id: fileId });
@@ -96,7 +125,7 @@ function createTelegram(token) {
return destPath; return destPath;
} }
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile }; return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile, sendFile };
} }
module.exports = { createTelegram }; module.exports = { createTelegram };
+9 -2
View File
@@ -227,6 +227,10 @@
</select> </select>
<div class="hint">共用模式下 /new 會清掉所有聊天室的記憶</div> <div class="hint">共用模式下 /new 會清掉所有聊天室的記憶</div>
</div> </div>
<div class="field">
<label class="check" style="margin-top:2px"><input type="checkbox" id="f-network"> 允許 Codex 連網</label>
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開</div>
</div>
<div class="field"> <div class="field">
<label>逾時(分鐘)</label> <label>逾時(分鐘)</label>
<input id="f-timeout" type="number" value="30" min="1"> <input id="f-timeout" type="number" value="30" min="1">
@@ -382,8 +386,8 @@ function renderBots(bots) {
'<td>' + statusBadge(b.status) + '</td>' + '<td>' + statusBadge(b.status) + '</td>' +
'<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' + '<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' +
'<td class="mono">' + esc(b.sandbox || '-') + '<td class="mono">' + esc(b.sandbox || '-') +
((b.model || b.reasoningEffort || b.sharedSession) ((b.model || b.reasoningEffort || b.sharedSession || b.networkAccess)
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : ''].filter(Boolean).join(' / ')) + '</span>' ? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '</span>'
: '') + '</td>' + : '') + '</td>' +
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' + '<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
'<td>' + fmtBytes(b.memory) + '</td>' + '<td>' + fmtBytes(b.memory) + '</td>' +
@@ -453,6 +457,7 @@ function openCreate() {
document.getElementById('f-sandbox').value = 'workspace-write'; document.getElementById('f-sandbox').value = 'workspace-write';
populateModelSelects('', ''); populateModelSelects('', '');
document.getElementById('f-session').value = 'per-chat'; document.getElementById('f-session').value = 'per-chat';
document.getElementById('f-network').checked = false;
document.getElementById('f-timeout').value = '30'; document.getElementById('f-timeout').value = '30';
document.getElementById('f-autostart-wrap').style.display = ''; document.getElementById('f-autostart-wrap').style.display = '';
document.getElementById('f-autostart').checked = true; document.getElementById('f-autostart').checked = true;
@@ -475,6 +480,7 @@ function openEdit(name) {
document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write'; document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write';
populateModelSelects(b.model || '', b.reasoningEffort || ''); populateModelSelects(b.model || '', b.reasoningEffort || '');
document.getElementById('f-session').value = b.sharedSession ? 'shared' : 'per-chat'; 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-timeout').value = b.timeoutMinutes || 30;
document.getElementById('f-autostart-wrap').style.display = 'none'; document.getElementById('f-autostart-wrap').style.display = 'none';
document.getElementById('bot-modal').classList.add('show'); document.getElementById('bot-modal').classList.add('show');
@@ -496,6 +502,7 @@ async function saveBot() {
model: document.getElementById('f-model').value, model: document.getElementById('f-model').value,
reasoningEffort: document.getElementById('f-effort').value, reasoningEffort: document.getElementById('f-effort').value,
sharedSession: document.getElementById('f-session').value === 'shared', sharedSession: document.getElementById('f-session').value === 'shared',
networkAccess: document.getElementById('f-network').checked,
timeoutMinutes: document.getElementById('f-timeout').value, timeoutMinutes: document.getElementById('f-timeout').value,
}; };
try { try {