Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a49f403a48 | ||
|
|
24c66130d0 | ||
|
|
cfa61aa01a |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "telegram-codex-bot",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"description": "Telegram bot powered by the local Codex CLI — zero-dependency, pm2-managed, with a built-in PM2 web viewer",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
|
||||
+84
-30
@@ -94,6 +94,8 @@ const OUTBOX_RULE =
|
||||
'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', '!幫助'];
|
||||
@@ -208,13 +210,19 @@ function startBot(config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPrompt(message, content) {
|
||||
// 使用者引用/回覆的文字(圈選引用優先,其次是被回覆的整則訊息)
|
||||
function quotedTextOf(message) {
|
||||
return message.quote?.text || message.reply_to_message?.text || message.reply_to_message?.caption || '';
|
||||
}
|
||||
|
||||
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 && replied.from?.id !== botId && (replied.text || replied.caption)) {
|
||||
// 回覆我自己的訊息通常已在會話記憶裡,不重複貼;但使用者沒打字只回覆時例外(那就是輸入本身)
|
||||
if (replied && (includeOwnReply || replied.from?.id !== botId) && (replied.text || replied.caption)) {
|
||||
parts.push(`【被回覆的訊息】\n${replied.text || replied.caption}`);
|
||||
}
|
||||
if (message.quote?.text) {
|
||||
@@ -225,15 +233,17 @@ function startBot(config) {
|
||||
}
|
||||
|
||||
function statusText(chatId) {
|
||||
const s = sessions.get(sessionKeyOf(chatId));
|
||||
const s = sessionKeyOf(chatId) ? sessions.get(sessionKeyOf(chatId)) : null;
|
||||
return [
|
||||
'📋 目前狀態',
|
||||
`工作目錄:${config.workDir}`,
|
||||
`沙盒模式:${config.sandbox}`,
|
||||
`模型:${config.model || '(codex 預設)'}`,
|
||||
`推理強度:${config.reasoningEffort || '(預設)'}`,
|
||||
`會話模式:${config.sharedSession ? '所有聊天室共用' : '各聊天室獨立'}`,
|
||||
s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
|
||||
`會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`,
|
||||
config.sessionMode === 'stateless'
|
||||
? '會話:每則訊息獨立,不保留記憶'
|
||||
: s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -249,8 +259,10 @@ function startBot(config) {
|
||||
].join('\n');
|
||||
|
||||
// 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把
|
||||
// stateless 模式回傳 null:永遠開新會話、不存
|
||||
function sessionKeyOf(chatId) {
|
||||
return config.sharedSession ? 'shared' : chatId;
|
||||
if (config.sessionMode === 'stateless') return null;
|
||||
return config.sessionMode === 'shared' ? 'shared' : chatId;
|
||||
}
|
||||
|
||||
async function handleMessage(message) {
|
||||
@@ -269,12 +281,14 @@ function startBot(config) {
|
||||
return;
|
||||
}
|
||||
if (matchCommand(content, NEW_COMMANDS)) {
|
||||
sessions.clear(sessionKey);
|
||||
if (sessionKey) sessions.clear(sessionKey);
|
||||
await t.sendMessage(
|
||||
chatId,
|
||||
config.sharedSession
|
||||
? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。'
|
||||
: '🆕 已開新會話,之前的對話記憶已清除。',
|
||||
config.sessionMode === 'stateless'
|
||||
? 'ℹ️ 此 bot 每則訊息都是獨立會話,本來就不保留記憶。'
|
||||
: config.sessionMode === 'shared'
|
||||
? '🆕 已開新會話(此 bot 為共用會話模式,所有聊天室的記憶一併清除)。'
|
||||
: '🆕 已開新會話,之前的對話記憶已清除。',
|
||||
{ replyTo: message.message_id }
|
||||
);
|
||||
return;
|
||||
@@ -283,7 +297,9 @@ function startBot(config) {
|
||||
await t.sendMessage(chatId, statusText(chatId), { replyTo: message.message_id });
|
||||
return;
|
||||
}
|
||||
if (!content && !photo) {
|
||||
// 只有「引用/回覆 + tag 我」沒打字:直接把引用內容當作這次的輸入
|
||||
const quotedOnly = !content && !photo && !!quotedTextOf(message);
|
||||
if (!content && !photo && !quotedOnly) {
|
||||
await t.sendMessage(chatId, HELP_TEXT, { replyTo: message.message_id });
|
||||
return;
|
||||
}
|
||||
@@ -316,35 +332,73 @@ function startBot(config) {
|
||||
images.push(dest);
|
||||
}
|
||||
|
||||
const prompt = buildPrompt(message, content || '(使用者只傳了圖片,請描述並依上下文處理)');
|
||||
const existing = sessions.get(sessionKey);
|
||||
const fallback = quotedOnly
|
||||
? '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)'
|
||||
: '(使用者只傳了圖片,請描述並依上下文處理)';
|
||||
const prompt = buildPrompt(message, content || fallback, { includeOwnReply: quotedOnly });
|
||||
const existing = sessionKey ? sessions.get(sessionKey) : null;
|
||||
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 拒絕跨模型 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)) {
|
||||
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)) {
|
||||
if (sessionKey) sessions.clear(sessionKey);
|
||||
tid = null;
|
||||
sessionResetNote = modelMismatch
|
||||
? '🆕 模型設定已變更,舊會話無法沿用,已自動開新會話(先前的對話記憶未帶入)。\n\n'
|
||||
: '🆕 舊會話已失效,已自動開新會話。\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) {
|
||||
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) sessions.set(sessionKey, result.threadId);
|
||||
if (result.threadId && sessionKey) sessions.set(sessionKey, result.threadId);
|
||||
|
||||
// 上下文用量過高就自動開新會話:越積越慢(尤其含圖),也容易在串流時被切斷
|
||||
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 (pct >= config.autoResetCtxPercent) {
|
||||
sessions.clear(sessionKey);
|
||||
ctxNote = `\n\n🧹 對話記憶已用到 ${pct}%,下一則訊息會自動開新會話(避免越來越慢)。`;
|
||||
}
|
||||
}
|
||||
|
||||
const footer = buildFooter(config, result.usage, modelMeta, codexDefaults);
|
||||
const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + footer);
|
||||
const html = t.mdToTgHtml(sessionResetNote + (result.text || '(Codex 沒有回覆文字)') + ctxNote + 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, '👍');
|
||||
|
||||
+5
-1
@@ -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 });
|
||||
|
||||
+10
-2
@@ -20,8 +20,11 @@ const DEFAULTS = {
|
||||
reasoningEffort: '',
|
||||
// 速度固定 Fast(priority = 1.5x);要改只能手動編輯設定檔
|
||||
serviceTier: 'priority',
|
||||
// 會話模式:false = 每個聊天室獨立會話(預設);true = 所有聊天室共用一個會話
|
||||
sharedSession: false,
|
||||
// 會話模式:per-chat = 每個聊天室獨立(預設);shared = 所有聊天室共用;
|
||||
// stateless = 每則訊息獨立、不保留記憶(最快,適合單張生圖這類不需上下文的 bot)
|
||||
sessionMode: 'per-chat',
|
||||
// 會話上下文用量達此百分比時自動開新會話(避免越積越慢、串流被切);0 = 不自動
|
||||
autoResetCtxPercent: 80,
|
||||
// 允許 Codex 在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開)
|
||||
networkAccess: false,
|
||||
// 單次回應逾時(分鐘)
|
||||
@@ -86,6 +89,11 @@ function loadConfig(explicitPath) {
|
||||
}
|
||||
// 速度政策固定 Fast:舊設定檔存了空值也矯正回 priority
|
||||
if (!cfg.serviceTier) cfg.serviceTier = 'priority';
|
||||
// 舊欄位 sharedSession 相容:轉成 sessionMode
|
||||
if (!['per-chat', 'shared', 'stateless'].includes(cfg.sessionMode)) {
|
||||
cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat';
|
||||
}
|
||||
cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0;
|
||||
|
||||
const minutes = Number(cfg.timeoutMinutes);
|
||||
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
|
||||
|
||||
+4
-4
@@ -78,7 +78,7 @@ async function listBots() {
|
||||
sandbox: cfg?.sandbox || null,
|
||||
model: cfg?.model || '',
|
||||
reasoningEffort: cfg?.reasoningEffort || '',
|
||||
sharedSession: !!cfg?.sharedSession,
|
||||
sessionMode: cfg?.sessionMode || 'per-chat',
|
||||
networkAccess: !!cfg?.networkAccess,
|
||||
serviceTier: cfg?.serviceTier || '',
|
||||
timeoutMinutes: cfg?.timeoutMinutes || null,
|
||||
@@ -114,7 +114,7 @@ function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sharedSession, networkAccess, timeoutMinutes }) {
|
||||
async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sessionMode, networkAccess, timeoutMinutes }) {
|
||||
validateBotInput({ name, token, workDir, sandbox }, { isCreate: true });
|
||||
const reg = loadRegistry();
|
||||
if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`);
|
||||
@@ -130,7 +130,7 @@ async function createBot({ name, token, workDir, sandbox, model, reasoningEffort
|
||||
sandbox: sandbox || 'workspace-write',
|
||||
model: model || '',
|
||||
reasoningEffort: reasoningEffort || '',
|
||||
sharedSession: !!sharedSession,
|
||||
sessionMode: ['per-chat','shared','stateless'].includes(sessionMode) ? sessionMode : 'per-chat',
|
||||
networkAccess: !!networkAccess,
|
||||
serviceTier: 'priority', // 速度固定 Fast 1.5x
|
||||
timeoutMinutes: Number(timeoutMinutes) || 30,
|
||||
@@ -162,7 +162,7 @@ async function updateBot(name, patch) {
|
||||
}
|
||||
if (patch.model !== undefined) raw.model = patch.model;
|
||||
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
|
||||
if (patch.sharedSession !== undefined) raw.sharedSession = !!patch.sharedSession;
|
||||
if (['per-chat','shared','stateless'].includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; }
|
||||
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess;
|
||||
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
|
||||
raw.timeoutMinutes = Number(patch.timeoutMinutes);
|
||||
|
||||
@@ -224,8 +224,9 @@
|
||||
<select id="f-session">
|
||||
<option value="per-chat" selected>各聊天室獨立(私訊、每個群組各自記憶)</option>
|
||||
<option value="shared">所有聊天室共用(同一條記憶,適合單一專案助理)</option>
|
||||
<option value="stateless">每則訊息獨立(不保留記憶,最快;適合單張生圖)</option>
|
||||
</select>
|
||||
<div class="hint">共用模式下 /new 會清掉所有聊天室的記憶</div>
|
||||
<div class="hint">記憶會隨對話變慢(尤其含圖);用量達 80% 時所有模式都會自動開新會話</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check" style="margin-top:2px"><input type="checkbox" id="f-network"> 允許 Codex 連網</label>
|
||||
@@ -386,8 +387,8 @@ function renderBots(bots) {
|
||||
'<td>' + statusBadge(b.status) + '</td>' +
|
||||
'<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' +
|
||||
'<td class="mono">' + esc(b.sandbox || '-') +
|
||||
((b.model || b.reasoningEffort || b.sharedSession || b.networkAccess)
|
||||
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '</span>'
|
||||
((b.model || b.reasoningEffort || b.sessionMode !== 'per-chat' || b.networkAccess)
|
||||
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sessionMode === 'shared' ? '共用會話' : b.sessionMode === 'stateless' ? '無記憶' : '', b.networkAccess ? '可連網' : ''].filter(Boolean).join(' / ')) + '</span>'
|
||||
: '') + '</td>' +
|
||||
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
|
||||
'<td>' + fmtBytes(b.memory) + '</td>' +
|
||||
@@ -479,7 +480,7 @@ function openEdit(name) {
|
||||
document.getElementById('f-workdir').value = b.workDir || '';
|
||||
document.getElementById('f-sandbox').value = b.sandbox || 'workspace-write';
|
||||
populateModelSelects(b.model || '', b.reasoningEffort || '');
|
||||
document.getElementById('f-session').value = b.sharedSession ? 'shared' : 'per-chat';
|
||||
document.getElementById('f-session').value = b.sessionMode || 'per-chat';
|
||||
document.getElementById('f-network').checked = !!b.networkAccess;
|
||||
document.getElementById('f-timeout').value = b.timeoutMinutes || 30;
|
||||
document.getElementById('f-autostart-wrap').style.display = 'none';
|
||||
@@ -501,7 +502,7 @@ async function saveBot() {
|
||||
sandbox: document.getElementById('f-sandbox').value,
|
||||
model: document.getElementById('f-model').value,
|
||||
reasoningEffort: document.getElementById('f-effort').value,
|
||||
sharedSession: document.getElementById('f-session').value === 'shared',
|
||||
sessionMode: document.getElementById('f-session').value,
|
||||
networkAccess: document.getElementById('f-network').checked,
|
||||
timeoutMinutes: document.getElementById('f-timeout').value,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user