Files
telegram-codex-bot/src/bot.js
T
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

398 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 的 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) {
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 拒絕跨模型 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 {
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 };