'use strict'; // Bot 主邏輯:長輪詢、指令、佇列、進度回報。 const fs = require('fs'); const os = require('os'); const path = require('path'); const { createTelegram } = require('./telegram'); const { runCodex } = require('./codex'); const { createSessionStore } = require('./sessions'); // codex 的模型快取與全域預設(config.toml),給 footer 顯示 ctx% 與模型/強度用 function loadModelMeta() { try { const cache = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.codex', 'models_cache.json'), 'utf-8')); const map = {}; for (const m of cache.models || []) map[m.slug] = m; return map; } catch { return {}; } } function loadCodexDefaults() { try { const toml = fs.readFileSync(path.join(os.homedir(), '.codex', 'config.toml'), 'utf-8'); return { model: toml.match(/^\s*model\s*=\s*"([^"]+)"/m)?.[1] || '', effort: toml.match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] || '', tier: toml.match(/^\s*service_tier\s*=\s*"([^"]+)"/m)?.[1] || '', }; } catch { return { model: '', effort: '', tier: '' }; } } // — ✅ 16:06:32 | ctx 24% +1.2k | gpt-5.6-sol / high function buildFooter(config, usage, modelMeta, codexDefaults) { const time = new Date().toLocaleTimeString('zh-TW', { hour12: false }); const parts = [`— ✅ ${time}`]; const model = config.model || codexDefaults.model; const meta = modelMeta[model]; if (usage) { const used = (usage.input_tokens || 0) + (usage.output_tokens || 0); // 本輪新消耗(扣掉 cache 命中的部分) const fresh = Math.max(0, (usage.input_tokens || 0) - (usage.cached_input_tokens || 0)) + (usage.output_tokens || 0); const freshStr = fresh >= 1000 ? `+${(fresh / 1000).toFixed(1)}k` : `+${fresh}`; const cw = meta?.context_window; parts.push(cw ? `ctx ${Math.round((used / cw) * 100)}% ${freshStr}` : freshStr); } if (model) { const effort = config.reasoningEffort || codexDefaults.effort || meta?.default_reasoning_level || ''; parts.push(effort ? `${model} / ${effort}` : model); } return `\n\n${parts.join(' | ')}`; } const crypto = require('crypto'); // Codex 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫) const OUTBOX_DIRNAME = '.tgcodex-outbox'; // codex 內建 imagegen 技能的預設輸出位置(依會話 id 分資料夾) const GENERATED_IMAGES_DIR = path.join(require('os').homedir(), '.codex', 'generated_images'); // 這一輪 codex 用 imagegen 生成、但沒複製進交件匣的圖(模型偶爾會忘) function collectGeneratedImages(threadId, sinceMs) { if (!threadId) return []; const dir = path.join(GENERATED_IMAGES_DIR, String(threadId)); try { return fs.readdirSync(dir) .filter((f) => /\.(png|jpe?g|gif|webp)$/i.test(f)) .map((f) => path.join(dir, f)) .filter((p) => { try { const st = fs.statSync(p); return st.isFile() && st.mtimeMs >= sinceMs; } catch { return false; } }) .sort(); } catch { return []; } } function md5File(p) { try { return crypto.createHash('md5').update(fs.readFileSync(p)).digest('hex'); } catch { return null; } } 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', '!幫助']; function startBot(config) { const t = createTelegram(config.telegramToken); const sessions = createSessionStore(config.stateDir); const modelMeta = loadModelMeta(); const codexDefaults = loadCodexDefaults(); let botId = null; let botUsername = null; // 同一個工作目錄不能同時跑兩個 codex(會互相踩檔案),全域串行 let queue = Promise.resolve(); function shouldRespond(message) { if (message.chat.type === 'private') return true; const text = message.text || message.caption || ''; const entities = message.entities || message.caption_entities || []; // 訊息中所有被 tag 的 bot(Telegram bot username 一定以 bot 結尾; // tag 到一般使用者不算,不影響回覆觸發) const botMentions = entities .filter((e) => e.type === 'mention') .map((e) => text.slice(e.offset + 1, e.offset + e.length)) .filter((u) => /bot$/i.test(u)); if (botMentions.length > 0) { // 有明確 tag 時以 tag 為準:只有第一個被 tag 的 bot 回應。 // 就算這則訊息是回覆我的(例如回覆我的訊息但 tag 別的 bot),我也讓位。 return botMentions[0].toLowerCase() === botUsername.toLowerCase(); } if (message.reply_to_message?.from?.id === botId) return true; return entities.some((e) => e.type === 'text_mention' && e.user?.id === botId); } function stripMention(text) { if (!botUsername) return text.trim(); return text.replace(new RegExp(`@${botUsername}`, 'gi'), '').trim(); } // 群組指令會帶 @BotName 後綴(例如 /new@MyBot) function matchCommand(text, commands) { const head = text.split(/\s/, 1)[0].replace(new RegExp(`@${botUsername}$`, 'i'), ''); return commands.includes(head.toLowerCase()); } // 把 codex 放進交件匣的檔案傳到 Telegram(傳完清掉), // 再補傳這一輪 imagegen 生成但沒進交件匣的圖(用內容 hash 去重,避免重複傳)。 async function flushOutputs(chatId, replyTo, threadId, sinceMs) { const sentHashes = new Set(); let sentCount = 0; const send = async (file, { deleteAfter }) => { const hash = md5File(file); if (!hash || sentHashes.has(hash)) return; try { await t.sendFile(chatId, file, { replyTo }); sentHashes.add(hash); sentCount++; if (deleteAfter) fs.unlinkSync(file); } catch (err) { console.error('傳送檔案失敗:', file, err.message); await t.sendMessage(chatId, `⚠️ 檔案傳送失敗:${path.basename(file)}(${err.message})`).catch(() => {}); } }; // 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 file of outbox.slice(0, MAX_OUTBOX_FILES)) { await send(file, { deleteAfter: true }); } if (outbox.length > MAX_OUTBOX_FILES) { 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 }); } } function cleanTmp() { const ttl = 24 * 60 * 60 * 1000; try { for (const f of fs.readdirSync(config.tmpDir)) { const p = path.join(config.tmpDir, f); try { if (Date.now() - fs.statSync(p).mtimeMs > ttl) fs.unlinkSync(p); } catch { /* ignore */ } } } catch { /* ignore */ } } // 取出訊息附圖(photo 取最大尺寸;image/* 的 document 也支援) function pickPhoto(message) { if (message.photo?.length) { const largest = message.photo[message.photo.length - 1]; return { fileId: largest.file_id, uniqueId: largest.file_unique_id, ext: '.jpg' }; } const doc = message.document; if (doc && /^image\//.test(doc.mime_type || '')) { const ext = path.extname(doc.file_name || '') || '.' + (doc.mime_type.split('/')[1] || 'png'); return { fileId: doc.file_id, uniqueId: doc.file_unique_id, ext }; } return null; } function buildPrompt(message, content) { const from = message.from || {}; const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者'; const sender = from.username ? `${name}(@${from.username})` : name; const parts = [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}`); } if (message.quote?.text) { parts.push(`【使用者圈選引用的段落,請聚焦於此】\n${message.quote.text}`); } parts.push(`【來自 Telegram 的 ${sender}】\n${content}`); return parts.join('\n\n'); } function statusText(chatId) { const s = sessions.get(sessionKeyOf(chatId)); return [ '📋 目前狀態', `工作目錄:${config.workDir}`, `沙盒模式:${config.sandbox}`, `模型:${config.model || '(codex 預設)'}`, `推理強度:${config.reasoningEffort || '(預設)'}`, `會話模式:${config.sharedSession ? '所有聊天室共用' : '各聊天室獨立'}`, s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)', ].join('\n'); } const HELP_TEXT = [ '🤖 我是 Codex bot,訊息直接丟給我就會在專案目錄裡動工。', '', '指令:', '/new — 開新會話(清除目前對話記憶)', '/status — 查看會話與設定', '/help — 顯示這則說明', '', '群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片(附文字說明)。', ].join('\n'); // 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把 function sessionKeyOf(chatId) { return config.sharedSession ? 'shared' : chatId; } async function handleMessage(message) { if (!message || message.from?.id === botId) return; const chatId = message.chat.id; const sessionKey = sessionKeyOf(chatId); const text = message.text || message.caption || ''; const photo = pickPhoto(message); if (!text && !photo) return; if (!shouldRespond(message)) return; const content = stripMention(text); if (matchCommand(content, HELP_COMMANDS)) { await t.sendMessage(chatId, HELP_TEXT, { replyTo: message.message_id }); return; } if (matchCommand(content, NEW_COMMANDS)) { sessions.clear(sessionKey); await t.sendMessage( chatId, config.sharedSession ? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。' : '🆕 已開新會話,之前的對話記憶已清除。', { replyTo: message.message_id } ); return; } if (matchCommand(content, STATUS_COMMANDS)) { await t.sendMessage(chatId, statusText(chatId), { replyTo: message.message_id }); return; } if (!content && !photo) { await t.sendMessage(chatId, HELP_TEXT, { replyTo: message.message_id }); return; } await t.react(chatId, message.message_id, '👀'); const statusMsg = await t.sendMessage(chatId, '🤔 Codex 處理中...', { replyTo: message.message_id }); // Telegram 對 editMessageText 限流很兇:至少隔 3 秒、且不重疊 let lastEdit = 0; let editing = false; const onProgress = (progressText) => { const now = Date.now(); if (editing || now - lastEdit < 3000) return; editing = true; lastEdit = now; const body = `🚧 Codex 處理中...\n\n${progressText}`; t.tg('editMessageText', { chat_id: chatId, message_id: statusMsg.message_id, text: body.length > 3900 ? body.slice(0, 3900) + '...' : body, }).catch(() => {}).finally(() => { editing = false; }); }; try { const images = []; if (photo) { cleanTmp(); const dest = path.join(config.tmpDir, `${Date.now()}-${photo.uniqueId}${photo.ext}`); await t.downloadFile(photo.fileId, dest); images.push(dest); } const prompt = buildPrompt(message, content || '(使用者只傳了圖片,請描述並依上下文處理)'); const existing = sessions.get(sessionKey); const turnStart = Date.now(); let result; let sessionResetNote = ''; try { result = await runCodex({ config, prompt, threadId: existing?.threadId || null, images, onProgress }); } catch (err) { // 舊會話 resume 不了的情況,自動開新會話重試一次: // 1) 會話已被 codex 清掉(not found) // 2) bot 換了模型,codex 拒絕跨模型 resume(recorded with model X but resuming with Y) const sessionGone = /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread)/i.test(err.message); const modelMismatch = /recorded with model/i.test(err.message); if (existing && (sessionGone || modelMismatch)) { sessions.clear(sessionKey); result = await runCodex({ config, prompt, threadId: null, images, onProgress }); sessionResetNote = modelMismatch ? '🆕 模型設定已變更,舊會話無法沿用,已自動開新會話(先前的對話記憶未帶入)。\n\n' : '🆕 舊會話已失效,已自動開新會話。\n\n'; } else { throw err; } } if (result.threadId) sessions.set(sessionKey, result.threadId); 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 flushOutputs(chatId, message.message_id, result.threadId, turnStart); await t.react(chatId, message.message_id, '👍'); } catch (error) { console.error('處理訊息失敗:', error); await t.editOrSplit(chatId, statusMsg.message_id, `❌ 發生錯誤:${error.message}`).catch(() => {}); await t.react(chatId, message.message_id, ''); } } async function pollLoop() { let offset = 0; for (;;) { try { const updates = await t.tg('getUpdates', { offset, timeout: 50, allowed_updates: ['message'] }); for (const update of updates) { offset = update.update_id + 1; queue = queue .then(() => handleMessage(update.message)) .catch((err) => console.error('處理訊息錯誤:', err)); } } catch (err) { console.error('輪詢錯誤:', err.message); await new Promise((r) => setTimeout(r, 5000)); } } } async function start() { const me = await t.tg('getMe', {}); botId = me.id; botUsername = me.username; await t.tg('setMyCommands', { commands: [ { command: 'new', description: '開新會話(清除對話記憶)' }, { command: 'status', description: '查看會話與設定' }, { command: 'help', description: '使用說明' }, ], }).catch(() => {}); console.log(`✅ @${botUsername} 已啟動`); console.log(` 工作目錄:${config.workDir}`); console.log(` 沙盒模式:${config.sandbox}`); await pollLoop(); } return { start }; } module.exports = { startBot, buildFooter, loadModelMeta, loadCodexDefaults };