Files
telegram-bot/src/bot.js
T

502 lines
22 KiB
JavaScript
Raw Normal View History

'use strict';
// Bot 主邏輯:長輪詢、指令、佇列、進度回報。實際跑 AI 的是 engines/codex 或 claude),
// 這裡只認共同介面 run({ config, prompt, threadId, images, onProgress }) → { text, threadId, usage, model }。
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { createTelegram } = require('./telegram');
const { getEngine } = require('./engines');
const { createSessionStore } = require('./sessions');
const { TGBOT_HOME } = require('./pm2util');
// 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({ model, effort, usage, contextWindow }) {
const time = new Date().toLocaleTimeString('zh-TW', { hour12: false });
const parts = [`— ✅ ${time}`];
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}`;
parts.push(contextWindow ? `ctx ${Math.round((used / contextWindow) * 100)}% ${freshStr}` : freshStr);
}
if (model) parts.push(effort ? `${model} / ${effort}` : model);
return `\n\n${parts.join(' | ')}`;
}
// AI 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫);舊名稱也一併收
const OUTBOX_DIRNAME = '.tgbot-outbox';
const LEGACY_OUTBOX_DIRNAMES = ['.tgcodex-outbox'];
// codex 內建 imagegen 技能的預設輸出位置(依會話 id 分資料夾)
const GENERATED_IMAGES_DIR = path.join(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 SESSION_MODE_LABELS = { 'per-chat': '各聊天室獨立', shared: '所有聊天室共用', stateless: '每則訊息獨立(不保留記憶)' };
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 engine = getEngine(config.engine);
const modelMeta = engine.id === 'codex' ? loadModelMeta() : {};
const codexDefaults = engine.id === 'codex' ? loadCodexDefaults() : { model: '', effort: '', tier: '' };
let botId = null;
let botUsername = null;
// 同一個工作目錄不能同時跑兩個 AI(會互相踩檔案),全域串行
let queue = Promise.resolve();
// footer / 自動重置用:這輪實際的模型、強度、上下文視窗
function resolveDisplay(result) {
const model = result?.model || config.model || codexDefaults.model || '';
const effort = config.reasoningEffort || codexDefaults.effort || modelMeta[model]?.default_reasoning_level || '';
const contextWindow = result?.usage?.context_window || modelMeta[model]?.context_window || null;
return { model, effort, contextWindow };
}
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());
}
// 把 AI 放進交件匣的檔案傳到 Telegram(傳完清掉),
// 再補傳這一輪 codex 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) 交件匣(明確交付:任何檔案類型)
let outbox = [];
for (const dirname of [OUTBOX_DIRNAME, ...LEGACY_OUTBOX_DIRNAMES]) {
const dir = path.join(config.workDir, dirname);
try {
outbox.push(...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) 保險網:codex imagegen 這一輪的產圖(不刪原檔,codex 之後編輯圖片可能還要用)
if (engine.supportsImageGen) {
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 quotedTextOf(message) {
return message.quote?.text || message.reply_to_message?.text || message.reply_to_message?.caption || '';
}
// Telegram 限制:bot 永遠看不到其他 bot 的訊息——使用者「回覆另一隻 bot + tag 我」時,
// reply_to_message 會被整個剝掉。繞法:每隻 bot 把自己在各聊天室的最後一則回覆
// 寫進共用資料夾,收到「只有 tag 沒內容」時就拿同聊天室最近一則兄弟 bot 的發言當輸入。
const CHATLAST_DIR = path.join(TGBOT_HOME, 'chatlast');
const SIBLING_MAX_AGE_MS = 6 * 60 * 60 * 1000; // 超過 6 小時的舊訊息就不當引用
function recordChatLast(chatId, text) {
try {
fs.mkdirSync(CHATLAST_DIR, { recursive: true });
fs.writeFileSync(
path.join(CHATLAST_DIR, `${chatId}.json`),
JSON.stringify({ botUsername, text, ts: Date.now() }) + '\n'
);
} catch { /* 純輔助功能,失敗不影響回覆 */ }
}
function siblingLastOf(chatId) {
try {
const j = JSON.parse(fs.readFileSync(path.join(CHATLAST_DIR, `${chatId}.json`), 'utf-8'));
if (!j.text || Date.now() - j.ts > SIBLING_MAX_AGE_MS) return null;
return j;
} catch {
return null;
}
}
function buildPrompt(message, content, { includeOwnReply = false } = {}) {
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 && (includeOwnReply || 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 = sessionKeyOf(chatId) ? sessions.get(sessionKeyOf(chatId)) : null;
const { model, effort } = resolveDisplay(null);
return [
'📋 目前狀態',
`引擎:${engine.label}`,
`工作目錄:${config.workDir}`,
`沙盒模式:${config.sandbox}`,
`模型:${model || `${engine.label} 預設)`}`,
`推理強度:${effort || '(預設)'}`,
`會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`,
config.sessionMode === 'stateless'
? '會話:每則訊息獨立,不保留記憶'
: s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
].join('\n');
}
const HELP_TEXT = [
`🤖 我是 ${engine.label} bot,訊息直接丟給我就會在專案目錄裡動工。`,
'',
'指令:',
'/new — 開新會話(清除目前對話記憶)',
'/status — 查看會話與設定',
'/help — 顯示這則說明',
'',
'群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片(附文字說明)。',
].join('\n');
// 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把
// stateless 模式回傳 null:永遠開新會話、不存
function sessionKeyOf(chatId) {
if (config.sessionMode === 'stateless') return null;
return config.sessionMode === 'shared' ? '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)) {
if (sessionKey) sessions.clear(sessionKey);
await t.sendMessage(
chatId,
config.sessionMode === 'stateless'
? 'ℹ️ 此 bot 每則訊息都是獨立會話,本來就不保留記憶。'
: config.sessionMode === 'shared'
? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。'
: '🆕 已開新會話,之前的對話記憶已清除。',
{ replyTo: message.message_id }
);
return;
}
if (matchCommand(content, STATUS_COMMANDS)) {
await t.sendMessage(chatId, statusText(chatId), { replyTo: message.message_id });
return;
}
// 只有「引用/回覆 + tag 我」沒打字:直接把引用內容當作這次的輸入。
// 引用的是其他 bot 的訊息時 Telegram 不給內容,改拿共用記錄裡同聊天室最近一則 bot 發言。
const quoted = quotedTextOf(message);
const sibling = (!content && !photo && !quoted) ? siblingLastOf(chatId) : null;
const quotedOnly = !content && !photo && !!(quoted || sibling);
if (!content && !photo && !quotedOnly) {
await t.sendMessage(chatId, HELP_TEXT, { replyTo: message.message_id });
return;
}
await t.react(chatId, message.message_id, '👀');
const statusMsg = await t.sendMessage(chatId, `🤔 ${engine.label} 處理中...`, { 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 = `🚧 ${engine.label} 處理中...\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);
}
let fallback = '(使用者只傳了圖片,請描述並依上下文處理)';
if (quotedOnly && (quoted || !sibling)) {
fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)';
} else if (quotedOnly && sibling) {
fallback =
`【引用的 bot 訊息(@${sibling.botUsername} 在本聊天室的最近發言)】\n${sibling.text}\n\n` +
'(使用者沒有輸入文字,只引用了訊息並 tag 我:請直接把上面引用的內容當作這次的輸入來處理)';
}
const prompt = buildPrompt(message, content || fallback, { includeOwnReply: quotedOnly });
const existing = sessionKey ? sessions.get(sessionKey) : null;
const turnStart = Date.now();
let result;
let sessionResetNote = '';
let tid = existing?.threadId || null;
const MAX_ATTEMPTS = 2;
for (let attempt = 1; ; attempt++) {
try {
result = await engine.run({ config, prompt, threadId: tid, images, onProgress });
break;
} catch (err) {
// 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume
// → 清掉會話、開新會話重跑(tid=null 之後不會再進這個分支,不會無限迴圈)
const sessionGone = /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread|conversation)/i.test(err.message);
const modelMismatch = /recorded with model/i.test(err.message);
if (tid && (sessionGone || modelMismatch)) {
if (sessionKey) sessions.clear(sessionKey);
tid = null;
sessionResetNote = modelMismatch
? '🆕 模型設定已變更,舊會話無法沿用,已自動開新會話(先前的對話記憶未帶入)。\n\n'
: '🆕 舊會話已失效,已自動開新會話。\n\n';
continue;
}
// 暫時性連線中斷(服務端切斷串流等)
if (err.transient) {
if (err.threadId) tid = err.threadId;
if (tid && sessionKey) sessions.set(sessionKey, tid); // 會話已建立,先記下來以便 resume
// 中斷前若圖已生成,直接交付,不重跑(避免重複生圖、多花時間)
if (engine.supportsImageGen && 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;
}
}
if (result.threadId && sessionKey) sessions.set(sessionKey, result.threadId);
const display = resolveDisplay(result);
let ctxNote = '';
// 上下文用量過高就自動開新會話:越積越慢(尤其含圖),也容易在串流時被切斷
if (sessionKey && config.autoResetCtxPercent > 0 && result.usage && display.contextWindow) {
const pct = Math.round(((result.usage.input_tokens || 0) + (result.usage.output_tokens || 0)) / display.contextWindow * 100);
if (pct >= config.autoResetCtxPercent) {
sessions.clear(sessionKey);
ctxNote += `\n\n🧹 對話記憶已用到 ${pct}%,下一則訊息會自動開新會話(避免越來越慢)。`;
}
}
// claude 在非互動模式下被擋掉的操作(例如 workspace-write 不能執行指令),讓使用者知道
if (Array.isArray(result.denials) && result.denials.length > 0) {
const tools = [...new Set(result.denials.map((d) => d.tool_name).filter(Boolean))].join('、');
ctxNote += `\n\n⚠️ 有 ${result.denials.length} 個操作因沙盒權限被拒${tools ? `${tools}` : ''};目前沙盒為 ${config.sandbox}。`;
}
const footer = buildFooter({ ...display, usage: result.usage });
const html = t.mdToTgHtml(sessionResetNote + (result.text || `${engine.label} 沒有回覆文字)`) + ctxNote + footer);
await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true });
recordChatLast(chatId, result.text || '');
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} 已啟動(引擎:${engine.label}`);
console.log(` 工作目錄:${config.workDir}`);
console.log(` 沙盒模式:${config.sandbox}`);
await pollLoop();
}
return { start };
}
module.exports = { startBot, buildFooter, loadModelMeta, loadCodexDefaults };