發佈 0.2.0:改名 telegram-bot,新增 Claude Code 引擎(新增 bot 時可選 Codex / Claude),start 自動從 0.1.x 遷移
This commit is contained in:
+67
-52
@@ -1,11 +1,14 @@
|
||||
'use strict';
|
||||
// Bot 主邏輯:長輪詢、指令、佇列、進度回報。
|
||||
// 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 { runCodex } = require('./codex');
|
||||
const { getEngine } = require('./engines');
|
||||
const { createSessionStore } = require('./sessions');
|
||||
const { TGBOT_HOME } = require('./pm2util');
|
||||
|
||||
// codex 的模型快取與全域預設(config.toml),給 footer 顯示 ctx% 與模型/強度用
|
||||
function loadModelMeta() {
|
||||
@@ -33,34 +36,27 @@ function loadCodexDefaults() {
|
||||
}
|
||||
|
||||
// — ✅ 16:06:32 | ctx 24% +1.2k | gpt-5.6-sol / high
|
||||
function buildFooter(config, usage, modelMeta, codexDefaults) {
|
||||
function buildFooter({ model, effort, usage, contextWindow }) {
|
||||
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);
|
||||
parts.push(contextWindow ? `ctx ${Math.round((used / contextWindow) * 100)}% ${freshStr}` : freshStr);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
const effort = config.reasoningEffort || codexDefaults.effort || meta?.default_reasoning_level || '';
|
||||
parts.push(effort ? `${model} / ${effort}` : model);
|
||||
}
|
||||
if (model) parts.push(effort ? `${model} / ${effort}` : model);
|
||||
return `\n\n${parts.join(' | ')}`;
|
||||
}
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Codex 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫)
|
||||
const OUTBOX_DIRNAME = '.tgcodex-outbox';
|
||||
// AI 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫);舊名稱也一併收
|
||||
const OUTBOX_DIRNAME = '.tgbot-outbox';
|
||||
const LEGACY_OUTBOX_DIRNAMES = ['.tgcodex-outbox'];
|
||||
// codex 內建 imagegen 技能的預設輸出位置(依會話 id 分資料夾)
|
||||
const GENERATED_IMAGES_DIR = path.join(require('os').homedir(), '.codex', 'generated_images');
|
||||
const GENERATED_IMAGES_DIR = path.join(os.homedir(), '.codex', 'generated_images');
|
||||
|
||||
// 這一輪 codex 用 imagegen 生成、但沒複製進交件匣的圖(模型偶爾會忘)
|
||||
function collectGeneratedImages(threadId, sinceMs) {
|
||||
@@ -103,14 +99,23 @@ 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();
|
||||
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;
|
||||
// 同一個工作目錄不能同時跑兩個 codex(會互相踩檔案),全域串行
|
||||
// 同一個工作目錄不能同時跑兩個 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 || '';
|
||||
@@ -141,8 +146,8 @@ function startBot(config) {
|
||||
return commands.includes(head.toLowerCase());
|
||||
}
|
||||
|
||||
// 把 codex 放進交件匣的檔案傳到 Telegram(傳完清掉),
|
||||
// 再補傳這一輪 imagegen 生成但沒進交件匣的圖(用內容 hash 去重,避免重複傳)。
|
||||
// 把 AI 放進交件匣的檔案傳到 Telegram(傳完清掉),
|
||||
// 再補傳這一輪 codex imagegen 生成但沒進交件匣的圖(用內容 hash 去重,避免重複傳)。
|
||||
async function flushOutputs(chatId, replyTo, threadId, sinceMs) {
|
||||
const sentHashes = new Set();
|
||||
let sentCount = 0;
|
||||
@@ -162,14 +167,16 @@ function startBot(config) {
|
||||
};
|
||||
|
||||
// 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 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 });
|
||||
}
|
||||
@@ -177,10 +184,12 @@ function startBot(config) {
|
||||
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 });
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +227,7 @@ function startBot(config) {
|
||||
// Telegram 限制:bot 永遠看不到其他 bot 的訊息——使用者「回覆另一隻 bot + tag 我」時,
|
||||
// reply_to_message 會被整個剝掉。繞法:每隻 bot 把自己在各聊天室的最後一則回覆
|
||||
// 寫進共用資料夾,收到「只有 tag 沒內容」時就拿同聊天室最近一則兄弟 bot 的發言當輸入。
|
||||
const CHATLAST_DIR = path.join(os.homedir(), '.tgcodex', 'chatlast');
|
||||
const CHATLAST_DIR = path.join(TGBOT_HOME, 'chatlast');
|
||||
const SIBLING_MAX_AGE_MS = 6 * 60 * 60 * 1000; // 超過 6 小時的舊訊息就不當引用
|
||||
|
||||
function recordChatLast(chatId, text) {
|
||||
@@ -260,12 +269,14 @@ function startBot(config) {
|
||||
|
||||
function statusText(chatId) {
|
||||
const s = sessionKeyOf(chatId) ? sessions.get(sessionKeyOf(chatId)) : null;
|
||||
const { model, effort } = resolveDisplay(null);
|
||||
return [
|
||||
'📋 目前狀態',
|
||||
`引擎:${engine.label}`,
|
||||
`工作目錄:${config.workDir}`,
|
||||
`沙盒模式:${config.sandbox}`,
|
||||
`模型:${config.model || '(codex 預設)'}`,
|
||||
`推理強度:${config.reasoningEffort || '(預設)'}`,
|
||||
`模型:${model || `(${engine.label} 預設)`}`,
|
||||
`推理強度:${effort || '(預設)'}`,
|
||||
`會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`,
|
||||
config.sessionMode === 'stateless'
|
||||
? '會話:每則訊息獨立,不保留記憶'
|
||||
@@ -274,7 +285,7 @@ function startBot(config) {
|
||||
}
|
||||
|
||||
const HELP_TEXT = [
|
||||
'🤖 我是 Codex bot,訊息直接丟給我就會在專案目錄裡動工。',
|
||||
`🤖 我是 ${engine.label} bot,訊息直接丟給我就會在專案目錄裡動工。`,
|
||||
'',
|
||||
'指令:',
|
||||
'/new — 開新會話(清除目前對話記憶)',
|
||||
@@ -334,7 +345,7 @@ function startBot(config) {
|
||||
}
|
||||
|
||||
await t.react(chatId, message.message_id, '👀');
|
||||
const statusMsg = await t.sendMessage(chatId, '🤔 Codex 處理中...', { replyTo: message.message_id });
|
||||
const statusMsg = await t.sendMessage(chatId, `🤔 ${engine.label} 處理中...`, { replyTo: message.message_id });
|
||||
|
||||
// Telegram 對 editMessageText 限流很兇:至少隔 3 秒、且不重疊
|
||||
let lastEdit = 0;
|
||||
@@ -344,7 +355,7 @@ function startBot(config) {
|
||||
if (editing || now - lastEdit < 3000) return;
|
||||
editing = true;
|
||||
lastEdit = now;
|
||||
const body = `🚧 Codex 處理中...\n\n${progressText}`;
|
||||
const body = `🚧 ${engine.label} 處理中...\n\n${progressText}`;
|
||||
t.tg('editMessageText', {
|
||||
chat_id: chatId,
|
||||
message_id: statusMsg.message_id,
|
||||
@@ -379,12 +390,12 @@ function startBot(config) {
|
||||
const MAX_ATTEMPTS = 2;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
result = await runCodex({ config, prompt, threadId: tid, images, onProgress });
|
||||
result = await engine.run({ config, prompt, threadId: tid, images, onProgress });
|
||||
break;
|
||||
} catch (err) {
|
||||
// 舊會話 resume 不了:1) 會話已被 codex 清掉 2) 換模型後 codex 拒絕跨模型 resume
|
||||
// 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume
|
||||
// → 清掉會話、開新會話重跑(tid=null 之後不會再進這個分支,不會無限迴圈)
|
||||
const sessionGone = /session|thread|conversation/i.test(err.message) && /not.*found|找不到|no .*(session|thread)/i.test(err.message);
|
||||
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);
|
||||
@@ -394,12 +405,12 @@ function startBot(config) {
|
||||
: '🆕 舊會話已失效,已自動開新會話。\n\n';
|
||||
continue;
|
||||
}
|
||||
// 暫時性連線中斷(OpenAI 端切斷串流等)
|
||||
// 暫時性連線中斷(服務端切斷串流等)
|
||||
if (err.transient) {
|
||||
if (err.threadId) tid = err.threadId;
|
||||
if (tid && sessionKey) sessions.set(sessionKey, tid); // 會話已建立,先記下來以便 resume
|
||||
// 中斷前若圖已生成,直接交付,不重跑(避免重複生圖、多花時間)
|
||||
if (collectGeneratedImages(tid, turnStart).length > 0) {
|
||||
if (engine.supportsImageGen && collectGeneratedImages(tid, turnStart).length > 0) {
|
||||
result = { text: '(回覆文字因連線中斷遺失,圖片如下)', threadId: tid, usage: null };
|
||||
sessionResetNote += '⚠️ 連線在回覆途中中斷,但圖片已生成完畢,直接送上。\n\n';
|
||||
break;
|
||||
@@ -419,20 +430,24 @@ function startBot(config) {
|
||||
|
||||
if (result.threadId && sessionKey) sessions.set(sessionKey, result.threadId);
|
||||
|
||||
// 上下文用量過高就自動開新會話:越積越慢(尤其含圖),也容易在串流時被切斷
|
||||
const display = resolveDisplay(result);
|
||||
let ctxNote = '';
|
||||
const cwModel = config.model || codexDefaults.model;
|
||||
const cw = modelMeta[cwModel]?.context_window;
|
||||
if (sessionKey && config.autoResetCtxPercent > 0 && result.usage && cw) {
|
||||
const pct = Math.round(((result.usage.input_tokens || 0) + (result.usage.output_tokens || 0)) / cw * 100);
|
||||
// 上下文用量過高就自動開新會話:越積越慢(尤其含圖),也容易在串流時被切斷
|
||||
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}%,下一則訊息會自動開新會話(避免越來越慢)。`;
|
||||
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(config, result.usage, modelMeta, codexDefaults);
|
||||
const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + ctxNote + footer);
|
||||
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);
|
||||
@@ -474,7 +489,7 @@ function startBot(config) {
|
||||
],
|
||||
}).catch(() => {});
|
||||
|
||||
console.log(`✅ @${botUsername} 已啟動`);
|
||||
console.log(`✅ @${botUsername} 已啟動(引擎:${engine.label})`);
|
||||
console.log(` 工作目錄:${config.workDir}`);
|
||||
console.log(` 沙盒模式:${config.sandbox}`);
|
||||
await pollLoop();
|
||||
|
||||
Reference in New Issue
Block a user