6 Commits
Author SHA1 Message Date
JianMiau a49f403a48 發佈 0.1.13:只引用+tag 沒打字時,直接把引用內容當輸入 2026-08-17 16:59:06 +08:00
JianMiauandClaude Fable 5 24c66130d0 發佈 0.1.12:會話模式新增「每則獨立」,ctx 達 80% 自動開新會話
根本原因:
會話越用越慢——每張生成圖都會塞回上下文,實測同一會話從 36 秒
一路漲到 135 秒,也更容易在串流時被切斷。

修法:
1. 會話模式 sharedSession 布林改為 sessionMode(per-chat / shared / stateless),
   stateless = 每則訊息獨立、不保留記憶,適合單張生圖這類不需上下文的 bot;
   舊設定檔的 sharedSession 自動相容轉換。
2. 新增 autoResetCtxPercent(預設 80):任一會話上下文用量達門檻即自動
   開新會話並在回覆末尾提示,避免無限累積。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:18:33 +08:00
JianMiauandClaude Fable 5 cfa61aa01a 發佈 0.1.11:連線中斷自動重試,已生成的圖不再丟失
根本原因:
imagegen 產圖成功後,模型把含圖結果串流回來時 OpenAI 端偶發切斷
WebSocket(stream disconnected / websocket closed),codex 回報 turn.failed,
bot 視為致命錯誤,連已生好的圖一起丟掉,使用者只看到錯誤。

修法:
1. codex.js 失敗時附帶 threadId 與 transient 旗標(辨識暫時性連線錯誤)。
2. bot.js 改為重試迴圈:暫時性錯誤時,若本輪 generated_images 已有產圖,
   直接交付並註明「連線中斷但圖已生成」,不重跑(避免重複生圖);
   沒產出才 resume 同一會話重試一次,狀態訊息顯示「自動重試中」。
   會話失效/換模型的自動開新會話邏輯併入同一迴圈。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:08:58 +08:00
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
JianMiauandClaude Fable 5 2324347868 發佈 0.1.9:imagegen 產圖保險網,漏複製也會傳到 Telegram
根本原因:
codex 的 imagegen 技能預設把圖存在 ~/.codex/generated_images/<會話id>/,
是否複製進 .tgcodex-outbox/ 全靠模型遵守 prompt 規則,偶爾會漏,
導致 bot 回「畫好了」但沒附圖。

修法:
每輪結束除了交件匣,另掃描該會話的 generated_images 目錄,把本輪
(mtime 在輪次時間窗內)新生成的圖一併傳出;以檔案內容 MD5 去重,
交件匣有複製過的不會重複傳。generated_images 原檔保留(後續編輯可能要用)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:23:06 +08:00
JianMiauandClaude Fable 5 b80d6f0a1b 發佈 0.1.8:支援傳圖/傳檔給使用者,新增連網開關
摘要:
1. 交件匣機制:每輪 prompt 附系統規則,Codex 把要給使用者的檔案放進
   工作目錄的 .tgcodex-outbox/,回覆後 bot 自動傳到 Telegram(圖片走
   sendPhoto、其餘或超過 10MB 走 sendDocument)並清空,一輪上限 10 個檔案。
2. bot 設定新增「允許 Codex 連網」開關(-c sandbox_workspace_write.network_access),
   開啟後可用 codex 的 imagegen 技能生圖,已實測產出 PNG 進交件匣。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:13:12 +08:00
7 changed files with 251 additions and 51 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "telegram-codex-bot",
"version": "0.1.7",
"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",
+182 -37
View File
@@ -55,6 +55,47 @@ function buildFooter(config, usage, modelMeta, codexDefaults) {
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 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', '!幫助'];
@@ -72,14 +113,21 @@ function startBot(config) {
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)
);
// 訊息中所有被 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) {
@@ -93,6 +141,49 @@ function startBot(config) {
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 {
@@ -119,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 = [];
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) {
@@ -136,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');
}
@@ -160,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) {
@@ -180,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;
@@ -194,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;
}
@@ -227,35 +332,75 @@ 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 拒絕跨模型 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 {
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, '👍');
} catch (error) {
console.error('處理訊息失敗:', error);
+6 -1
View File
@@ -101,6 +101,7 @@ function runCodex({ config, prompt, threadId, images = [], onProgress }) {
}
if (config.reasoningEffort) args.push('-c', `model_reasoning_effort="${config.reasoningEffort}"`);
if (config.serviceTier) args.push('-c', `service_tier="${config.serviceTier}"`);
if (config.networkAccess) args.push('-c', 'sandbox_workspace_write.network_access=true');
for (const img of images) args.push('-i', img);
args.push('-'); // prompt 從 stdin 讀
@@ -197,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 });
+12 -2
View File
@@ -20,8 +20,13 @@ const DEFAULTS = {
reasoningEffort: '',
// 速度固定 Fastpriority = 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,
// 單次回應逾時(分鐘)
timeoutMinutes: 30,
// pm2 程序名稱(web 檢視工具會叫 <pm2Name>-web
@@ -84,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}`);
+7 -4
View File
@@ -78,7 +78,8 @@ 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,
tokenMasked: maskToken(cfg?.telegramToken),
@@ -113,7 +114,7 @@ function validateBotInput({ name, token, workDir, sandbox }, { isCreate }) {
}
}
async function createBot({ name, token, workDir, sandbox, model, reasoningEffort, sharedSession, 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}`);
@@ -129,7 +130,8 @@ 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,
pm2Name: name,
@@ -160,7 +162,8 @@ 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);
}
+30 -1
View File
@@ -86,6 +86,35 @@ function createTelegram(token) {
}).catch(() => {});
}
// 上傳檔案到 Telegram:圖片走 sendPhoto,其餘(或 sendPhoto 失敗時)走 sendDocument
async function sendFile(chatId, filePath, { caption, replyTo } = {}) {
const fs = require('fs');
const path = require('path');
const name = path.basename(filePath);
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(name);
const size = fs.statSync(filePath).size;
const upload = async (method, field) => {
const form = new FormData();
form.append('chat_id', String(chatId));
if (caption) form.append('caption', caption.slice(0, 1000));
if (replyTo) form.append('reply_parameters', JSON.stringify({ message_id: replyTo, allow_sending_without_reply: true }));
form.append(field, new Blob([fs.readFileSync(filePath)]), name);
const res = await fetch(`${API}/${method}`, { method: 'POST', body: form });
const data = await res.json();
if (!data.ok) throw new Error(`${method} 失敗:${data.description}`);
return data.result;
};
// sendPhoto 上限 10MB,且某些格式會被拒,失敗就退回用檔案傳
if (isImage && size <= 9.5 * 1024 * 1024) {
try {
return await upload('sendPhoto', 'photo');
} catch { /* fall through */ }
}
return upload('sendDocument', 'document');
}
async function downloadFile(fileId, destPath) {
const fs = require('fs');
const info = await tg('getFile', { file_id: fileId });
@@ -96,7 +125,7 @@ function createTelegram(token) {
return destPath;
}
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile };
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile, sendFile };
}
module.exports = { createTelegram };
+13 -5
View File
@@ -224,8 +224,13 @@
<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>
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開</div>
</div>
<div class="field">
<label>逾時(分鐘)</label>
@@ -382,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)
? '<br><span class="script-name">' + esc([b.model, b.reasoningEffort, b.sharedSession ? '共用會話' : ''].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>' +
@@ -453,6 +458,7 @@ function openCreate() {
document.getElementById('f-sandbox').value = 'workspace-write';
populateModelSelects('', '');
document.getElementById('f-session').value = 'per-chat';
document.getElementById('f-network').checked = false;
document.getElementById('f-timeout').value = '30';
document.getElementById('f-autostart-wrap').style.display = '';
document.getElementById('f-autostart').checked = true;
@@ -474,7 +480,8 @@ 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';
document.getElementById('bot-modal').classList.add('show');
@@ -495,7 +502,8 @@ 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,
};
try {