diff --git a/package.json b/package.json index afe6f07..c0e1cac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telegram-codex-bot", - "version": "0.1.8", + "version": "0.1.9", "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 c757606..8cdd35b 100644 --- a/src/bot.js +++ b/src/bot.js @@ -55,8 +55,40 @@ function buildFooter(config, usage, modelMeta, codexDefaults) { 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 並清空該資料夾。'; @@ -100,30 +132,46 @@ function startBot(config) { return commands.includes(head.toLowerCase()); } - // 把 codex 放進交件匣的檔案傳到 Telegram,傳完清掉 - async function flushOutbox(chatId, replyTo) { + // 把 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 files = []; + let outbox = []; try { - files = fs.readdirSync(dir) + outbox = fs.readdirSync(dir) .map((f) => path.join(dir, f)) .filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } }) .sort(); - } catch { - return; // 沒有交件匣就沒事 + } catch { /* 沒有交件匣 */ } + for (const file of outbox.slice(0, MAX_OUTBOX_FILES)) { + await send(file, { deleteAfter: true }); } - 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 (outbox.length > MAX_OUTBOX_FILES) { + await t.sendMessage(chatId, `⚠️ 交件匣一次最多傳 ${MAX_OUTBOX_FILES} 個檔案,還有 ${outbox.length - MAX_OUTBOX_FILES} 個留在 ${OUTBOX_DIRNAME}/`).catch(() => {}); } - if (skipped > 0) { - await t.sendMessage(chatId, `⚠️ 交件匣一次最多傳 ${MAX_OUTBOX_FILES} 個檔案,還有 ${skipped} 個留在 ${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 }); } } @@ -263,6 +311,7 @@ function startBot(config) { const prompt = buildPrompt(message, content || '(使用者只傳了圖片,請描述並依上下文處理)'); const existing = sessions.get(sessionKey); + const turnStart = Date.now(); let result; let sessionResetNote = ''; @@ -290,7 +339,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 flushOutputs(chatId, message.message_id, result.threadId, turnStart); await t.react(chatId, message.message_id, '👍'); } catch (error) { console.error('處理訊息失敗:', error);