3 Commits
Author SHA1 Message Date
JianMiauandClaude Fable 5 cfa61aa01a 發佈 0.1.11:連線中斷自動重試,已生成的圖不再丟失
根本原因:
imagegen 產圖成功後,模型把含圖結果串流回來時 OpenAI 端偶發切斷
WebSocket(stream disconnected / websocket closed),codex 回報 turn.failed,
bot 視為致命錯誤,連已生好的圖一起丟掉,使用者只看到錯誤。

修法:
1. codex.js 失敗時附帶 threadId 與 transient 旗標(辨識暫時性連線錯誤)。
2. bot.js 改為重試迴圈:暫時性錯誤時,若本輪 generated_images 已有產圖,
   直接交付並註明「連線中斷但圖已生成」,不重跑(避免重複生圖);
   沒產出才 resume 同一會話重試一次,狀態訊息顯示「自動重試中」。
   會話失效/換模型的自動開新會話邏輯併入同一迴圈。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:08:58 +08:00
JianMiauandClaude Fable 5 ec06c77bde 發佈 0.1.10:tag 優先於回覆觸發,多 bot 同群不搶答
根本原因:
回覆 A bot 的訊息並 tag B bot 時,A 因「被回覆」觸發搶答,
B 才是使用者真正要叫的對象。

修法:
群組觸發改為 tag 優先:訊息含任何 bot tag(@xxx...bot)時,
只有第一個被 tag 的 bot 回應,被回覆的 bot 自動讓位;
沒有 bot tag 時維持原邏輯(回覆我 → 我回應)。
tag 一般使用者(非 bot username)不影響判定。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:27:01 +08:00
JianMiauandClaude Fable 5 2324347868 發佈 0.1.9:imagegen 產圖保險網,漏複製也會傳到 Telegram
根本原因:
codex 的 imagegen 技能預設把圖存在 ~/.codex/generated_images/<會話id>/,
是否複製進 .tgcodex-outbox/ 全靠模型遵守 prompt 規則,偶爾會漏,
導致 bot 回「畫好了」但沒附圖。

修法:
每輪結束除了交件匣,另掃描該會話的 generated_images 目錄,把本輪
(mtime 在輪次時間窗內)新生成的圖一併傳出;以檔案內容 MD5 去重,
交件匣有複製過的不會重複傳。generated_images 原檔保留(後續編輯可能要用)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:23:06 +08:00
3 changed files with 124 additions and 41 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "telegram-codex-bot",
"version": "0.1.8",
"version": "0.1.11",
"description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer",
"license": "MIT",
"type": "commonjs",
+118 -39
View File
@@ -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 並清空該資料夾。';
@@ -79,14 +111,21 @@ function startBot(config) {
function shouldRespond(message) {
if (message.chat.type === 'private') return true;
if (message.reply_to_message?.from?.id === botId) return true;
const text = message.text || message.caption || '';
const entities = message.entities || message.caption_entities || [];
return entities.some((e) =>
(e.type === 'mention' &&
text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername.toLowerCase()}`) ||
(e.type === 'text_mention' && e.user?.id === botId)
);
// 訊息中所有被 tag 的 botTelegram 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) {
@@ -100,30 +139,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,24 +318,48 @@ function startBot(config) {
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 拒絕跨模型 resumerecorded 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 {
let tid = existing?.threadId || null;
const MAX_ATTEMPTS = 2;
for (let attempt = 1; ; attempt++) {
try {
result = await runCodex({ config, prompt, threadId: tid, images, onProgress });
break;
} catch (err) {
// 舊會話 resume 不了:1) 會話已被 codex 清掉 2) 換模型後 codex 拒絕跨模型 resume
// → 清掉會話、開新會話重跑(tid=null 之後不會再進這個分支,不會無限迴圈)
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 (tid && (sessionGone || modelMismatch)) {
sessions.clear(sessionKey);
tid = null;
sessionResetNote = modelMismatch
? '🆕 模型設定已變更,舊會話無法沿用,已自動開新會話(先前的對話記憶未帶入)。\n\n'
: '🆕 舊會話已失效,已自動開新會話。\n\n';
continue;
}
// 暫時性連線中斷(OpenAI 端切斷串流等)
if (err.transient) {
if (err.threadId) tid = err.threadId;
if (tid) sessions.set(sessionKey, tid); // 會話已建立,先記下來以便 resume
// 中斷前若圖已生成,直接交付,不重跑(避免重複生圖、多花時間)
if (collectGeneratedImages(tid, turnStart).length > 0) {
result = { text: '(回覆文字因連線中斷遺失,圖片如下)', threadId: tid, usage: null };
sessionResetNote += '⚠️ 連線在回覆途中中斷,但圖片已生成完畢,直接送上。\n\n';
break;
}
if (attempt < MAX_ATTEMPTS) {
await t.tg('editMessageText', {
chat_id: chatId,
message_id: statusMsg.message_id,
text: `⚠️ 連線中斷,自動重試中(${attempt + 1}/${MAX_ATTEMPTS})…`,
}).catch(() => {});
continue;
}
}
throw err;
}
}
@@ -290,7 +369,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);
+5 -1
View File
@@ -198,7 +198,11 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) {
proc.on('close', (code) => {
clearTimeout(timer);
if (turnFailedMessage) {
return reject(new Error(`Codex 執行失敗:${turnFailedMessage}`));
const e = new Error(`Codex 執行失敗:${turnFailedMessage}`);
e.threadId = resultThreadId; // 會話可能已建立,讓呼叫端能 resume 重試
// 暫時性連線問題(OpenAI 端切斷串流等),呼叫端可重試
e.transient = /stream disconnected|websocket closed|Falling back from WebSockets|connection reset|ECONNRESET|socket hang up|timed out/i.test(turnFailedMessage);
return reject(e);
}
if (turnCompleted || messages.length > 0) {
return resolve({ text: messages.join('\n\n'), threadId: resultThreadId, usage });