2026-08-04 16:25:39 +08:00
|
|
|
|
'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: '' };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 12:02:07 +08:00
|
|
|
|
// — ✅ 16:06:32 | ctx 24% +1.2k | gpt-5.6-sol / high
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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 || '';
|
2026-08-07 12:02:07 +08:00
|
|
|
|
parts.push(effort ? `${model} / ${effort}` : model);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return `\n\n${parts.join(' | ')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 17:23:06 +08:00
|
|
|
|
const crypto = require('crypto');
|
|
|
|
|
|
|
2026-08-14 17:13:12 +08:00
|
|
|
|
// Codex 要傳檔案給使用者的交件匣(在工作目錄底下,沙盒內可寫)
|
|
|
|
|
|
const OUTBOX_DIRNAME = '.tgcodex-outbox';
|
2026-08-14 17:23:06 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-14 17:13:12 +08:00
|
|
|
|
const OUTBOX_RULE =
|
|
|
|
|
|
`【系統規則】若要把圖片或檔案傳給使用者,請將檔案寫入工作目錄下的 ${OUTBOX_DIRNAME}/ 資料夾,` +
|
|
|
|
|
|
'bot 會在回覆後自動傳送到 Telegram 並清空該資料夾。';
|
|
|
|
|
|
const MAX_OUTBOX_FILES = 10;
|
|
|
|
|
|
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
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)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 17:23:06 +08:00
|
|
|
|
// 把 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) 交件匣(明確交付:任何檔案類型)
|
2026-08-14 17:13:12 +08:00
|
|
|
|
const dir = path.join(config.workDir, OUTBOX_DIRNAME);
|
2026-08-14 17:23:06 +08:00
|
|
|
|
let outbox = [];
|
2026-08-14 17:13:12 +08:00
|
|
|
|
try {
|
2026-08-14 17:23:06 +08:00
|
|
|
|
outbox = fs.readdirSync(dir)
|
2026-08-14 17:13:12 +08:00
|
|
|
|
.map((f) => path.join(dir, f))
|
|
|
|
|
|
.filter((p) => { try { return fs.statSync(p).isFile(); } catch { return false; } })
|
|
|
|
|
|
.sort();
|
2026-08-14 17:23:06 +08:00
|
|
|
|
} catch { /* 沒有交件匣 */ }
|
|
|
|
|
|
for (const file of outbox.slice(0, MAX_OUTBOX_FILES)) {
|
|
|
|
|
|
await send(file, { deleteAfter: true });
|
2026-08-14 17:13:12 +08:00
|
|
|
|
}
|
2026-08-14 17:23:06 +08:00
|
|
|
|
if (outbox.length > MAX_OUTBOX_FILES) {
|
|
|
|
|
|
await t.sendMessage(chatId, `⚠️ 交件匣一次最多傳 ${MAX_OUTBOX_FILES} 個檔案,還有 ${outbox.length - MAX_OUTBOX_FILES} 個留在 ${OUTBOX_DIRNAME}/`).catch(() => {});
|
2026-08-14 17:13:12 +08:00
|
|
|
|
}
|
2026-08-14 17:23:06 +08:00
|
|
|
|
|
|
|
|
|
|
// 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 });
|
2026-08-14 17:13:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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;
|
2026-08-14 17:13:12 +08:00
|
|
|
|
const parts = [OUTBOX_RULE];
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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) {
|
2026-08-10 12:05:30 +08:00
|
|
|
|
const s = sessions.get(sessionKeyOf(chatId));
|
2026-08-04 16:25:39 +08:00
|
|
|
|
return [
|
|
|
|
|
|
'📋 目前狀態',
|
|
|
|
|
|
`工作目錄:${config.workDir}`,
|
|
|
|
|
|
`沙盒模式:${config.sandbox}`,
|
|
|
|
|
|
`模型:${config.model || '(codex 預設)'}`,
|
|
|
|
|
|
`推理強度:${config.reasoningEffort || '(預設)'}`,
|
2026-08-10 12:05:30 +08:00
|
|
|
|
`會話模式:${config.sharedSession ? '所有聊天室共用' : '各聊天室獨立'}`,
|
2026-08-04 16:25:39 +08:00
|
|
|
|
s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const HELP_TEXT = [
|
|
|
|
|
|
'🤖 我是 Codex bot,訊息直接丟給我就會在專案目錄裡動工。',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'指令:',
|
|
|
|
|
|
'/new — 開新會話(清除目前對話記憶)',
|
|
|
|
|
|
'/status — 查看會話與設定',
|
|
|
|
|
|
'/help — 顯示這則說明',
|
|
|
|
|
|
'',
|
|
|
|
|
|
'群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片(附文字說明)。',
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
2026-08-10 12:05:30 +08:00
|
|
|
|
// 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把
|
|
|
|
|
|
function sessionKeyOf(chatId) {
|
|
|
|
|
|
return config.sharedSession ? 'shared' : chatId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 16:25:39 +08:00
|
|
|
|
async function handleMessage(message) {
|
|
|
|
|
|
if (!message || message.from?.id === botId) return;
|
|
|
|
|
|
const chatId = message.chat.id;
|
2026-08-10 12:05:30 +08:00
|
|
|
|
const sessionKey = sessionKeyOf(chatId);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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)) {
|
2026-08-10 12:05:30 +08:00
|
|
|
|
sessions.clear(sessionKey);
|
|
|
|
|
|
await t.sendMessage(
|
|
|
|
|
|
chatId,
|
|
|
|
|
|
config.sharedSession
|
|
|
|
|
|
? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。'
|
|
|
|
|
|
: '🆕 已開新會話,之前的對話記憶已清除。',
|
|
|
|
|
|
{ replyTo: message.message_id }
|
|
|
|
|
|
);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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 || '(使用者只傳了圖片,請描述並依上下文處理)');
|
2026-08-10 12:05:30 +08:00
|
|
|
|
const existing = sessions.get(sessionKey);
|
2026-08-14 17:23:06 +08:00
|
|
|
|
const turnStart = Date.now();
|
2026-08-04 16:25:39 +08:00
|
|
|
|
|
|
|
|
|
|
let result;
|
2026-08-07 11:56:22 +08:00
|
|
|
|
let sessionResetNote = '';
|
2026-08-04 16:25:39 +08:00
|
|
|
|
try {
|
|
|
|
|
|
result = await runCodex({ config, prompt, threadId: existing?.threadId || null, images, onProgress });
|
|
|
|
|
|
} catch (err) {
|
2026-08-07 11:56:22 +08:00
|
|
|
|
// 舊會話 resume 不了的情況,自動開新會話重試一次:
|
|
|
|
|
|
// 1) 會話已被 codex 清掉(not found)
|
|
|
|
|
|
// 2) bot 換了模型,codex 拒絕跨模型 resume(recorded 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)) {
|
2026-08-10 12:05:30 +08:00
|
|
|
|
sessions.clear(sessionKey);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
result = await runCodex({ config, prompt, threadId: null, images, onProgress });
|
2026-08-07 11:56:22 +08:00
|
|
|
|
sessionResetNote = modelMismatch
|
|
|
|
|
|
? '🆕 模型設定已變更,舊會話無法沿用,已自動開新會話(先前的對話記憶未帶入)。\n\n'
|
|
|
|
|
|
: '🆕 舊會話已失效,已自動開新會話。\n\n';
|
2026-08-04 16:25:39 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 12:05:30 +08:00
|
|
|
|
if (result.threadId) sessions.set(sessionKey, result.threadId);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
|
|
|
|
|
|
const footer = buildFooter(config, result.usage, modelMeta, codexDefaults);
|
2026-08-07 11:56:22 +08:00
|
|
|
|
const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + footer);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
await t.editOrSplit(chatId, statusMsg.message_id, html, { html: true });
|
2026-08-14 17:23:06 +08:00
|
|
|
|
await flushOutputs(chatId, message.message_id, result.threadId, turnStart);
|
2026-08-04 16:25:39 +08:00
|
|
|
|
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 };
|