Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34938f0588 | ||
|
|
6faafabc9d | ||
|
|
cfc9671179 |
@@ -143,7 +143,7 @@ telegram-bot web 前景執行控制台(除錯用)
|
||||
|
||||
- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應
|
||||
- **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入
|
||||
- **附件**:圖片、PDF、文字/程式碼檔都會下載給 AI 讀(Telegram Bot 下載上限 20MB;docx/xlsx/zip 等二進位不支援,bot 會回覆說明)
|
||||
- **附件**:圖片、PDF、文字/程式碼檔都會下載給 AI 讀(Telegram Bot 下載上限 20MB;docx/xlsx/zip 等二進位不支援,bot 會回覆說明);「回覆別人傳的檔案/圖片再 tag bot」也會讀到那個附件(回覆另一隻 bot 傳的檔案除外,Telegram 不給)
|
||||
- **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內)
|
||||
- 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 65 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "telegram-bot",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.7",
|
||||
"description": "Telegram bot powered by your local AI CLI (OpenAI Codex or Claude Code) — zero-dependency, pm2-managed, with a web console to manage multiple bots",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
|
||||
+18
-5
@@ -305,7 +305,7 @@ function startBot(config) {
|
||||
'/status — 查看會話與設定',
|
||||
'/help — 顯示這則說明',
|
||||
'',
|
||||
'群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片(附文字說明)。',
|
||||
'群組中要 @我 或回覆我的訊息才會觸發。可以直接傳圖片/檔案(附文字說明),或回覆別人傳的檔案再 @我。',
|
||||
].join('\n');
|
||||
|
||||
// 會話 key:獨立模式用 chat id,共用模式所有聊天室用同一把
|
||||
@@ -320,7 +320,13 @@ function startBot(config) {
|
||||
const chatId = message.chat.id;
|
||||
const sessionKey = sessionKeyOf(chatId);
|
||||
const text = message.text || message.caption || '';
|
||||
const attachment = pickAttachment(message);
|
||||
// 附件:自己這則沒有的話,退而看被回覆的那則(「別人傳檔案 → 回覆它並 tag 我」)
|
||||
let attachment = pickAttachment(message);
|
||||
let attachmentFromReply = false;
|
||||
if (!attachment && message.reply_to_message) {
|
||||
attachment = pickAttachment(message.reply_to_message);
|
||||
attachmentFromReply = !!attachment;
|
||||
}
|
||||
const photo = attachment; // 有任何附件都視同有內容
|
||||
if (!text && !photo) return;
|
||||
if (!shouldRespond(message)) return;
|
||||
@@ -388,24 +394,31 @@ function startBot(config) {
|
||||
if (attachment) {
|
||||
const meta = [attachment.name && `原檔名 ${attachment.name}`, attachment.mime, attachment.size && `${Math.round(attachment.size / 1024)}KB`]
|
||||
.filter(Boolean).join(',');
|
||||
const label = attachmentFromReply ? '被回覆訊息的附件' : '附件';
|
||||
if (attachment.kind === 'unsupported') {
|
||||
attachmentNote = `\n\n【附件】(${meta})格式不支援(docx/xlsx/zip 等二進位讀不了),回覆時告知使用者,建議貼成文字或轉 PDF`;
|
||||
attachmentNote = `\n\n【${label}】(${meta})格式不支援(docx/xlsx/zip 等二進位讀不了),回覆時告知使用者,建議貼成文字或轉 PDF`;
|
||||
} else if (attachment.size > TELEGRAM_GETFILE_LIMIT) {
|
||||
attachmentNote = `\n\n【附件】(${meta})超過 Telegram Bot 下載上限 20MB 無法讀取,回覆時告知使用者`;
|
||||
attachmentNote = `\n\n【${label}】(${meta})超過 Telegram Bot 下載上限 20MB 無法讀取,回覆時告知使用者`;
|
||||
} else {
|
||||
cleanTmp();
|
||||
const dest = path.join(config.tmpDir, `${Date.now()}-${attachment.uniqueId}${attachment.ext}`);
|
||||
await t.downloadFile(attachment.fileId, dest);
|
||||
if (attachment.kind === 'image') {
|
||||
images.push(dest);
|
||||
if (attachmentFromReply) attachmentNote = '\n\n【附圖是使用者回覆的那則訊息裡的圖片】';
|
||||
} else {
|
||||
files.push(dest);
|
||||
attachmentNote = `\n\n【附件檔案】${meta ? `(${meta})` : ''}請先用讀檔工具讀取再處理:\n${dest}`;
|
||||
attachmentNote = `\n\n【${label}檔案】${meta ? `(${meta})` : ''}請先用讀檔工具讀取再處理:\n${dest}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fallback = files.length ? '(使用者只傳了檔案,請讀取後依上下文處理)' : '(使用者只傳了圖片,請描述並依上下文處理)';
|
||||
if (attachmentFromReply) {
|
||||
fallback = files.length
|
||||
? '(使用者沒打字,只回覆了一則帶檔案的訊息並 tag 我:請讀取該檔案後依上下文處理)'
|
||||
: '(使用者沒打字,只回覆了一則帶圖片的訊息並 tag 我:請描述該圖片並依上下文處理)';
|
||||
}
|
||||
if (quotedOnly && (quoted || !sibling)) {
|
||||
fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)';
|
||||
} else if (quotedOnly && sibling) {
|
||||
|
||||
+22
-2
@@ -86,6 +86,10 @@ function createTelegram(token) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const TRANSIENT_RE = /Bad Gateway|Gateway Timeout|Too Many Requests|Service Unavailable|Internal Server Error|ETIMEDOUT|ECONNRESET|fetch failed/i;
|
||||
const SEND_FILE_RETRIES = 3;
|
||||
const SEND_FILE_RETRY_DELAY = 4000;
|
||||
|
||||
// 上傳檔案到 Telegram:圖片走 sendPhoto,其餘(或 sendPhoto 失敗時)走 sendDocument
|
||||
async function sendFile(chatId, filePath, { caption, replyTo } = {}) {
|
||||
const fs = require('fs');
|
||||
@@ -106,13 +110,29 @@ function createTelegram(token) {
|
||||
return data.result;
|
||||
};
|
||||
|
||||
// 帶重試的上傳:Telegram 暫時性故障(502/504/429 等)自動等一下重來
|
||||
const uploadWithRetry = async (method, field) => {
|
||||
for (let i = 1; ; i++) {
|
||||
try {
|
||||
return await upload(method, field);
|
||||
} catch (err) {
|
||||
if (i < SEND_FILE_RETRIES && TRANSIENT_RE.test(err.message)) {
|
||||
console.log(`sendFile 重試 ${i}/${SEND_FILE_RETRIES}(${err.message})`);
|
||||
await new Promise((r) => setTimeout(r, SEND_FILE_RETRY_DELAY * i));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// sendPhoto 上限 10MB,且某些格式會被拒,失敗就退回用檔案傳
|
||||
if (isImage && size <= 9.5 * 1024 * 1024) {
|
||||
try {
|
||||
return await upload('sendPhoto', 'photo');
|
||||
return await uploadWithRetry('sendPhoto', 'photo');
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return upload('sendDocument', 'document');
|
||||
return uploadWithRetry('sendDocument', 'document');
|
||||
}
|
||||
|
||||
async function downloadFile(fileId, destPath) {
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
.notice-steps b { color: #e2e8f0; background: rgba(88,101,242,0.18); padding: 1px 7px;
|
||||
border-radius: 5px; font-weight: 600; }
|
||||
.workdir { cursor: help; border-bottom: 1px dotted #475569; }
|
||||
.sandbox-cell { cursor: help; }
|
||||
.tag { display: inline-block; padding: 1px 7px; border-radius: 5px; font-size: 10px; font-weight: 700;
|
||||
margin-left: 6px; vertical-align: middle; letter-spacing: .3px; }
|
||||
.tag-codex { background: rgba(16,163,127,0.18); color: #34d399; }
|
||||
@@ -486,10 +487,13 @@ function renderBots(bots) {
|
||||
(b.error ? '<br><span style="font-size:11px;color:#ed4245">' + esc(b.error) + '</span>' : '') + '</td>' +
|
||||
'<td>' + statusBadge(b.status) + '</td>' +
|
||||
'<td><span class="mono workdir" title="' + esc(b.workDir) + '">' + esc(folderName(b.workDir)) + '</span></td>' +
|
||||
'<td class="mono">' + esc(b.sandbox || '-') +
|
||||
((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 ? '可連網' : '', (b.writeUsers && b.writeUsers.length) ? '限 @' + b.writeUsers.join(' @') + ' 可寫' : '', b.promptRules ? '有回覆規範' : ''].filter(Boolean).join(' / ')) + '</span>'
|
||||
: '') + '</td>' +
|
||||
(function() {
|
||||
var line2 = [b.model, b.reasoningEffort].filter(Boolean).join(' / ');
|
||||
var extra = [b.sessionMode === 'shared' ? '共用會話' : b.sessionMode === 'stateless' ? '無記憶' : '', b.networkAccess ? '可連網' : '', (b.writeUsers && b.writeUsers.length) ? '限 @' + b.writeUsers.join(' @') + ' 可寫' : '', b.promptRules ? '有回覆規範' : ''].filter(Boolean).join(' / ');
|
||||
var tip = [b.sandbox, line2, extra].filter(Boolean).join('\n');
|
||||
return '<td class="mono sandbox-cell" title="' + esc(tip) + '">' + esc(b.sandbox || '-') +
|
||||
(line2 ? '<br><span class="script-name">' + esc(line2) + '</span>' : '') + '</td>';
|
||||
})() +
|
||||
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
|
||||
'<td>' + fmtBytes(b.memory) + '</td>' +
|
||||
'<td>' + (b.restarts ?? '-') + '</td>' +
|
||||
|
||||
+4
-1
@@ -106,9 +106,12 @@ const CODEX_FALLBACK_MODELS = [
|
||||
|
||||
const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max'];
|
||||
const CLAUDE_MODELS = [
|
||||
{ slug: 'claude-fable-5', label: 'Fable 5(最強,Mythos 級)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
|
||||
{ slug: 'claude-fable-5', label: 'Fable 5(最強)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
|
||||
{ slug: 'claude-opus-5', label: 'Opus 5', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
|
||||
{ slug: 'claude-opus-4-8', label: 'Opus 4.8', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
|
||||
{ slug: 'claude-opus-4-6', label: 'Opus 4.6', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
|
||||
{ slug: 'claude-sonnet-5', label: 'Sonnet 5(較快)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
|
||||
{ slug: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5(最快最便宜)', efforts: CLAUDE_EFFORTS, defaultEffort: 'high' },
|
||||
];
|
||||
|
||||
// codex 全域預設(~/.codex/config.toml),前端用來當下拉選單的預選值
|
||||
|
||||
Reference in New Issue
Block a user