新增每 bot 回覆規範(promptRules)、可讀寫成員(writeUsers,其他人唯讀)、PDF/文字附件;cluemarket-tg-bot 搬入控制台
@@ -8,7 +8,8 @@
|
|||||||
- **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組
|
- **零依賴**:不用裝任何 npm 套件,只吃 Node 內建模組
|
||||||
- **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟
|
- **pm2 託管**:控制台與每個 bot 都掛在 pm2,開機自啟、當機自動重啟
|
||||||
- **多 bot**:一頁管理多個 bot,各自不同引擎、token、專案目錄
|
- **多 bot**:一頁管理多個 bot,各自不同引擎、token、專案目錄
|
||||||
- **會話延續**:每個聊天室一個會話,`/new` 開新會話;支援傳圖、引用回覆;bot 之間可互相接力(引用另一隻 bot 的回覆再 tag)
|
- **會話延續**:每個聊天室一個會話,`/new` 開新會話;支援傳圖與附件(PDF/文字/程式碼檔)、引用回覆;bot 之間可互相接力(引用另一隻 bot 的回覆再 tag)
|
||||||
|
- **權限與規範**:可限定只有哪些 Telegram 帳號能讓 AI 改檔/跑指令(其他人自動唯讀);可設每則訊息前綴的回覆規範(專案守門、成員身分)
|
||||||
|
|
||||||
## 前置需求
|
## 前置需求
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ telegram-bot start # 重新 start 一次,bot 程序會自動切到新版
|
|||||||
| `danger-full-access` | 不設限 | `--dangerously-skip-permissions`,不設限 |
|
| `danger-full-access` | 不設限 | `--dangerously-skip-permissions`,不設限 |
|
||||||
| 生圖 | 內建 imagegen 技能,產圖自動傳回 | 無(可自行在 CLAUDE.md 接其他工具) |
|
| 生圖 | 內建 imagegen 技能,產圖自動傳回 | 無(可自行在 CLAUDE.md 接其他工具) |
|
||||||
| 傳圖給 bot | `-i` 直接餵給模型 | 存暫存檔後由 Read 工具讀取 |
|
| 傳圖給 bot | `-i` 直接餵給模型 | 存暫存檔後由 Read 工具讀取 |
|
||||||
|
| 傳檔案給 bot(PDF / 文字 / 程式碼) | 路徑寫進 prompt,由 codex 讀取 | 存暫存檔後由 Read 工具讀取 |
|
||||||
|
|
||||||
兩種引擎的回覆尾端都會附 `— ✅ 時間 | ctx 用量% +本輪 token | 模型 / 強度`。
|
兩種引擎的回覆尾端都會附 `— ✅ 時間 | ctx 用量% +本輪 token | 模型 / 強度`。
|
||||||
AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動傳到 Telegram 並清空。
|
AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動傳到 Telegram 並清空。
|
||||||
@@ -89,7 +91,9 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動
|
|||||||
4. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入
|
4. **工作目錄**:按「📂 瀏覽…」會在這台機器桌面跳出原生資料夾視窗,選完自動帶入
|
||||||
5. **模型 / 推理強度**:下拉選單,預設值自動帶你機器上該引擎的全域設定;換模型時強度選項會連動
|
5. **模型 / 推理強度**:下拉選單,預設值自動帶你機器上該引擎的全域設定;換模型時強度選項會連動
|
||||||
6. **會話模式**:各聊天室獨立 / 所有聊天室共用 / 每則訊息獨立(不保留記憶,最快)
|
6. **會話模式**:各聊天室獨立 / 所有聊天室共用 / 每則訊息獨立(不保留記憶,最快)
|
||||||
7. 勾「建立後立即啟動」→ 儲存,bot 就上線了
|
7. **可讀寫成員**(選填):填 Telegram username(逗號分隔),清單外的人一律以 read-only 沙盒執行,由 CLI 層硬擋;留空 = 所有人都依沙盒設定
|
||||||
|
8. **回覆規範**(選填):每則訊息開頭附加的規則,例如「只回答本專案相關問題」「@xxx 是後端工程師,回覆用 API 契約視角」;專案本身的規範仍放工作目錄的 `AGENTS.md` / `CLAUDE.md`
|
||||||
|
9. 勾「建立後立即啟動」→ 儲存,bot 就上線了
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -97,6 +101,10 @@ AI 要傳檔案給你:寫進工作目錄的 `.tgbot-outbox/`,bot 回覆後自動
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**編輯既有 bot** — 引擎、模型、可讀寫成員、回覆規範都可事後修改;執行中的 bot 儲存後會自動重啟套用。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
**③ PM2 分頁** — 整台機器所有 pm2 程序。Port 可直接點開對應網頁,右側可啟停/重啟/刪除任何程序。
|
**③ PM2 分頁** — 整台機器所有 pm2 程序。Port 可直接點開對應網頁,右側可啟停/重啟/刪除任何程序。
|
||||||
|
|
||||||

|

|
||||||
@@ -134,6 +142,7 @@ telegram-bot web 前景執行控制台(除錯用)
|
|||||||
|
|
||||||
- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應
|
- **私訊**:任何訊息都回應;**群組**:只回應 `@bot` 或回覆 bot 訊息的情況;訊息同時 tag 多個 bot 時只有第一個回應
|
||||||
- **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入
|
- **引用**:回覆/圈選引用某則訊息再 tag bot,引用內容會一起送給 AI;只引用不打字就直接把引用當輸入
|
||||||
|
- **附件**:圖片、PDF、文字/程式碼檔都會下載給 AI 讀(Telegram Bot 下載上限 20MB;docx/xlsx/zip 等二進位不支援,bot 會回覆說明)
|
||||||
- **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內)
|
- **bot 接力**:Telegram 不讓 bot 看到其他 bot 的訊息,所以「回覆 A bot 的回覆 + tag B bot(不打字)」時,B 會改拿同聊天室最近一則我們自家 bot 的發言當輸入(6 小時內)
|
||||||
- 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢
|
- 對話記憶用到 80% 上下文時自動開新會話(回覆會標示),避免越用越慢
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 129 KiB |
@@ -205,18 +205,28 @@ function startBot(config) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取出訊息附圖(photo 取最大尺寸;image/* 的 document 也支援)
|
// 取出訊息附件:photo 取最大尺寸;document 依 mime/副檔名分成 image / file(PDF、文字、程式碼)/ unsupported
|
||||||
function pickPhoto(message) {
|
const TELEGRAM_GETFILE_LIMIT = 20 * 1024 * 1024; // Bot API getFile 上限
|
||||||
|
const TEXT_EXTS = new Set(['.txt', '.md', '.log', '.json', '.yaml', '.yml', '.csv', '.tsv', '.xml', '.html', '.htm', '.css',
|
||||||
|
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.java', '.go', '.rs', '.c', '.cc', '.cpp', '.h', '.hpp', '.cs',
|
||||||
|
'.php', '.rb', '.sh', '.bat', '.ps1', '.sql', '.toml', '.ini', '.cfg', '.env', '.conf', '.vue', '.svelte', '.kt', '.swift']);
|
||||||
|
function pickAttachment(message) {
|
||||||
if (message.photo?.length) {
|
if (message.photo?.length) {
|
||||||
const largest = message.photo[message.photo.length - 1];
|
const largest = message.photo[message.photo.length - 1];
|
||||||
return { fileId: largest.file_id, uniqueId: largest.file_unique_id, ext: '.jpg' };
|
return { kind: 'image', fileId: largest.file_id, uniqueId: largest.file_unique_id, ext: '.jpg', name: '', size: largest.file_size || 0 };
|
||||||
}
|
}
|
||||||
const doc = message.document;
|
const doc = message.document;
|
||||||
if (doc && /^image\//.test(doc.mime_type || '')) {
|
if (!doc) return null;
|
||||||
const ext = path.extname(doc.file_name || '') || '.' + (doc.mime_type.split('/')[1] || 'png');
|
const mime = doc.mime_type || '';
|
||||||
return { fileId: doc.file_id, uniqueId: doc.file_unique_id, ext };
|
const name = doc.file_name || '';
|
||||||
|
const extFromName = path.extname(name).toLowerCase();
|
||||||
|
const base = { fileId: doc.file_id, uniqueId: doc.file_unique_id, name, mime, size: doc.file_size || 0 };
|
||||||
|
if (/^image\//.test(mime)) return { ...base, kind: 'image', ext: extFromName || '.' + (mime.split('/')[1] || 'png') };
|
||||||
|
if (mime === 'application/pdf' || extFromName === '.pdf') return { ...base, kind: 'file', ext: '.pdf' };
|
||||||
|
if (/^text\//.test(mime) || /^application\/(json|xml|x-yaml|yaml|javascript|typescript|x-sh|toml)$/.test(mime) || TEXT_EXTS.has(extFromName)) {
|
||||||
|
return { ...base, kind: 'file', ext: extFromName || '.txt' };
|
||||||
}
|
}
|
||||||
return null;
|
return { ...base, kind: 'unsupported', ext: extFromName };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用者引用/回覆的文字(圈選引用優先,其次是被回覆的整則訊息)
|
// 使用者引用/回覆的文字(圈選引用優先,其次是被回覆的整則訊息)
|
||||||
@@ -255,6 +265,7 @@ function startBot(config) {
|
|||||||
const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者';
|
const name = [from.first_name, from.last_name].filter(Boolean).join(' ') || '未知使用者';
|
||||||
const sender = from.username ? `${name}(@${from.username})` : name;
|
const sender = from.username ? `${name}(@${from.username})` : name;
|
||||||
const parts = [OUTBOX_RULE];
|
const parts = [OUTBOX_RULE];
|
||||||
|
if (config.promptRules) parts.push(config.promptRules);
|
||||||
const replied = message.reply_to_message;
|
const replied = message.reply_to_message;
|
||||||
// 回覆我自己的訊息通常已在會話記憶裡,不重複貼;但使用者沒打字只回覆時例外(那就是輸入本身)
|
// 回覆我自己的訊息通常已在會話記憶裡,不重複貼;但使用者沒打字只回覆時例外(那就是輸入本身)
|
||||||
if (replied && (includeOwnReply || replied.from?.id !== botId) && (replied.text || replied.caption)) {
|
if (replied && (includeOwnReply || replied.from?.id !== botId) && (replied.text || replied.caption)) {
|
||||||
@@ -278,6 +289,8 @@ function startBot(config) {
|
|||||||
`模型:${model || `(${engine.label} 預設)`}`,
|
`模型:${model || `(${engine.label} 預設)`}`,
|
||||||
`推理強度:${effort || '(預設)'}`,
|
`推理強度:${effort || '(預設)'}`,
|
||||||
`會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`,
|
`會話模式:${SESSION_MODE_LABELS[config.sessionMode] || config.sessionMode}`,
|
||||||
|
config.writeUsers.length ? `可讀寫成員:${config.writeUsers.map((u) => '@' + u).join('、')}(其他人唯讀)` : '可讀寫成員:所有人(依沙盒設定)',
|
||||||
|
config.promptRules ? `回覆規範:已設定(${config.promptRules.length} 字)` : '回覆規範:無',
|
||||||
config.sessionMode === 'stateless'
|
config.sessionMode === 'stateless'
|
||||||
? '會話:每則訊息獨立,不保留記憶'
|
? '會話:每則訊息獨立,不保留記憶'
|
||||||
: s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
|
: s ? `會話:${s.threadId}\n最後使用:${s.updatedAt}` : '會話:尚未建立(下一則訊息會開新會話)',
|
||||||
@@ -307,9 +320,14 @@ function startBot(config) {
|
|||||||
const chatId = message.chat.id;
|
const chatId = message.chat.id;
|
||||||
const sessionKey = sessionKeyOf(chatId);
|
const sessionKey = sessionKeyOf(chatId);
|
||||||
const text = message.text || message.caption || '';
|
const text = message.text || message.caption || '';
|
||||||
const photo = pickPhoto(message);
|
const attachment = pickAttachment(message);
|
||||||
|
const photo = attachment; // 有任何附件都視同有內容
|
||||||
if (!text && !photo) return;
|
if (!text && !photo) return;
|
||||||
if (!shouldRespond(message)) return;
|
if (!shouldRespond(message)) return;
|
||||||
|
// 讀寫權限:有設 writeUsers 時,清單外的人一律 read-only(沙盒層硬擋,不靠模型自律)
|
||||||
|
const senderName = (message.from?.username || '').toLowerCase();
|
||||||
|
const canWrite = config.writeUsers.length === 0 || config.writeUsers.some((u) => u.toLowerCase() === senderName);
|
||||||
|
const runConfig = canWrite ? config : { ...config, sandbox: 'read-only', networkAccess: false };
|
||||||
|
|
||||||
const content = stripMention(text);
|
const content = stripMention(text);
|
||||||
|
|
||||||
@@ -365,14 +383,29 @@ function startBot(config) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const images = [];
|
const images = [];
|
||||||
if (photo) {
|
const files = [];
|
||||||
cleanTmp();
|
let attachmentNote = '';
|
||||||
const dest = path.join(config.tmpDir, `${Date.now()}-${photo.uniqueId}${photo.ext}`);
|
if (attachment) {
|
||||||
await t.downloadFile(photo.fileId, dest);
|
const meta = [attachment.name && `原檔名 ${attachment.name}`, attachment.mime, attachment.size && `${Math.round(attachment.size / 1024)}KB`]
|
||||||
images.push(dest);
|
.filter(Boolean).join(',');
|
||||||
|
if (attachment.kind === 'unsupported') {
|
||||||
|
attachmentNote = `\n\n【附件】(${meta})格式不支援(docx/xlsx/zip 等二進位讀不了),回覆時告知使用者,建議貼成文字或轉 PDF`;
|
||||||
|
} else if (attachment.size > TELEGRAM_GETFILE_LIMIT) {
|
||||||
|
attachmentNote = `\n\n【附件】(${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);
|
||||||
|
} else {
|
||||||
|
files.push(dest);
|
||||||
|
attachmentNote = `\n\n【附件檔案】${meta ? `(${meta})` : ''}請先用讀檔工具讀取再處理:\n${dest}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fallback = '(使用者只傳了圖片,請描述並依上下文處理)';
|
let fallback = files.length ? '(使用者只傳了檔案,請讀取後依上下文處理)' : '(使用者只傳了圖片,請描述並依上下文處理)';
|
||||||
if (quotedOnly && (quoted || !sibling)) {
|
if (quotedOnly && (quoted || !sibling)) {
|
||||||
fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)';
|
fallback = '(使用者沒有輸入文字,只引用了上面的內容並 tag 我:請直接把引用內容當作這次的輸入來處理)';
|
||||||
} else if (quotedOnly && sibling) {
|
} else if (quotedOnly && sibling) {
|
||||||
@@ -380,7 +413,7 @@ function startBot(config) {
|
|||||||
`【引用的 bot 訊息(@${sibling.botUsername} 在本聊天室的最近發言)】\n${sibling.text}\n\n` +
|
`【引用的 bot 訊息(@${sibling.botUsername} 在本聊天室的最近發言)】\n${sibling.text}\n\n` +
|
||||||
'(使用者沒有輸入文字,只引用了訊息並 tag 我:請直接把上面引用的內容當作這次的輸入來處理)';
|
'(使用者沒有輸入文字,只引用了訊息並 tag 我:請直接把上面引用的內容當作這次的輸入來處理)';
|
||||||
}
|
}
|
||||||
const prompt = buildPrompt(message, content || fallback, { includeOwnReply: quotedOnly });
|
const prompt = buildPrompt(message, (content || fallback) + attachmentNote, { includeOwnReply: quotedOnly });
|
||||||
const existing = sessionKey ? sessions.get(sessionKey) : null;
|
const existing = sessionKey ? sessions.get(sessionKey) : null;
|
||||||
const turnStart = Date.now();
|
const turnStart = Date.now();
|
||||||
|
|
||||||
@@ -390,7 +423,7 @@ function startBot(config) {
|
|||||||
const MAX_ATTEMPTS = 2;
|
const MAX_ATTEMPTS = 2;
|
||||||
for (let attempt = 1; ; attempt++) {
|
for (let attempt = 1; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
result = await engine.run({ config, prompt, threadId: tid, images, onProgress });
|
result = await engine.run({ config: runConfig, prompt, threadId: tid, images, files, onProgress });
|
||||||
break;
|
break;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume
|
// 舊會話 resume 不了:1) 會話已被引擎清掉 2) 換模型後 codex 拒絕跨模型 resume
|
||||||
@@ -443,7 +476,7 @@ function startBot(config) {
|
|||||||
// claude 在非互動模式下被擋掉的操作(例如 workspace-write 不能執行指令),讓使用者知道
|
// claude 在非互動模式下被擋掉的操作(例如 workspace-write 不能執行指令),讓使用者知道
|
||||||
if (Array.isArray(result.denials) && result.denials.length > 0) {
|
if (Array.isArray(result.denials) && result.denials.length > 0) {
|
||||||
const tools = [...new Set(result.denials.map((d) => d.tool_name).filter(Boolean))].join('、');
|
const tools = [...new Set(result.denials.map((d) => d.tool_name).filter(Boolean))].join('、');
|
||||||
ctxNote += `\n\n⚠️ 有 ${result.denials.length} 個操作因沙盒權限被拒${tools ? `(${tools})` : ''};目前沙盒為 ${config.sandbox}。`;
|
ctxNote += `\n\n⚠️ 有 ${result.denials.length} 個操作因沙盒權限被拒${tools ? `(${tools})` : ''};目前沙盒為 ${runConfig.sandbox}${canWrite ? '' : '(你不在可讀寫成員清單內)'}。`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const footer = buildFooter({ ...display, usage: result.usage });
|
const footer = buildFooter({ ...display, usage: result.usage });
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const DEFAULTS = {
|
|||||||
autoResetCtxPercent: 80,
|
autoResetCtxPercent: 80,
|
||||||
// codex 專用:允許在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開)
|
// codex 專用:允許在沙盒內連網(workspace-write 預設禁網;要呼叫外部 API 如生圖服務才開)
|
||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
|
// 每則訊息開頭附加的回覆規範(例如:只回答專案相關問題、成員身分說明);空字串 = 不加
|
||||||
|
promptRules: '',
|
||||||
|
// 可讀寫的 Telegram username 清單(不含 @);非空時,清單外的人一律以 read-only 沙盒執行。
|
||||||
|
// 空陣列 = 所有人都依 sandbox 設定
|
||||||
|
writeUsers: [],
|
||||||
// 單次回應逾時(分鐘)
|
// 單次回應逾時(分鐘)
|
||||||
timeoutMinutes: 30,
|
timeoutMinutes: 30,
|
||||||
// pm2 程序名稱
|
// pm2 程序名稱
|
||||||
@@ -47,6 +52,12 @@ const DEFAULTS = {
|
|||||||
claudePath: '',
|
claudePath: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 'a, @b' / ['a','@b'] → ['a','b'](去 @、去空白、去重)
|
||||||
|
function normalizeUsers(v) {
|
||||||
|
const arr = Array.isArray(v) ? v : String(v || '').split(/[\s,,;;]+/);
|
||||||
|
return [...new Set(arr.map((u) => String(u || '').trim().replace(/^@/, '')).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
function firstExisting(...files) {
|
function firstExisting(...files) {
|
||||||
return files.find((f) => fs.existsSync(f)) || files[0];
|
return files.find((f) => fs.existsSync(f)) || files[0];
|
||||||
}
|
}
|
||||||
@@ -131,6 +142,8 @@ function loadConfig(explicitPath) {
|
|||||||
cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat';
|
cfg.sessionMode = raw.sharedSession ? 'shared' : 'per-chat';
|
||||||
}
|
}
|
||||||
cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0;
|
cfg.autoResetCtxPercent = Number(cfg.autoResetCtxPercent) || 0;
|
||||||
|
cfg.promptRules = typeof cfg.promptRules === 'string' ? cfg.promptRules.trim() : '';
|
||||||
|
cfg.writeUsers = normalizeUsers(cfg.writeUsers);
|
||||||
|
|
||||||
const minutes = Number(cfg.timeoutMinutes);
|
const minutes = Number(cfg.timeoutMinutes);
|
||||||
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
|
if (!(minutes > 0)) throw new Error(`timeoutMinutes 必須是正數:${cfg.timeoutMinutes}`);
|
||||||
@@ -160,5 +173,5 @@ function ensureStateDirs(cfg) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
CONFIG_BASENAME, LEGACY_CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES,
|
CONFIG_BASENAME, LEGACY_CONFIG_BASENAME, DEFAULTS, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES,
|
||||||
loadConfig, assertBotConfig, ensureStateDirs, findConfigPath,
|
loadConfig, assertBotConfig, ensureStateDirs, findConfigPath, normalizeUsers,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function permissionArgs(sandbox) {
|
|||||||
* 執行一輪 Claude 對話。介面同 engines/codex.js 的 run。
|
* 執行一輪 Claude 對話。介面同 engines/codex.js 的 run。
|
||||||
* @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null, denials: object[]}>}
|
* @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null, denials: object[]}>}
|
||||||
*/
|
*/
|
||||||
function run({ config, prompt, threadId, images = [], onProgress }) {
|
function run({ config, prompt, threadId, images = [], files = [], onProgress }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { cmd, shell } = resolveClaudeCommand(config);
|
const { cmd, shell } = resolveClaudeCommand(config);
|
||||||
|
|
||||||
@@ -83,8 +83,9 @@ function run({ config, prompt, threadId, images = [], onProgress }) {
|
|||||||
if (config.model) args.push('--model', config.model);
|
if (config.model) args.push('--model', config.model);
|
||||||
if (config.reasoningEffort) args.push('--effort', config.reasoningEffort);
|
if (config.reasoningEffort) args.push('--effort', config.reasoningEffort);
|
||||||
args.push(...permissionArgs(config.sandbox));
|
args.push(...permissionArgs(config.sandbox));
|
||||||
// 附圖:claude -p 沒有圖片參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍)
|
// 附圖/附件:claude -p 沒有檔案參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍)
|
||||||
if (images.length > 0) args.push('--add-dir', path.dirname(images[0]));
|
const attachDirs = [...new Set([...images, ...files].map((f) => path.dirname(f)))];
|
||||||
|
for (const d of attachDirs) args.push('--add-dir', d);
|
||||||
if (threadId) args.push('--resume', threadId);
|
if (threadId) args.push('--resume', threadId);
|
||||||
|
|
||||||
let fullPrompt = prompt;
|
let fullPrompt = prompt;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { TGBOT_HOME, BOT_SCRIPT, jlist, botConfigPathOf } = require('./pm2util');
|
const { TGBOT_HOME, BOT_SCRIPT, jlist, botConfigPathOf } = require('./pm2util');
|
||||||
const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME } = require('./config');
|
const { loadConfig, SANDBOX_MODES, ENGINE_IDS, SESSION_MODES, CONFIG_BASENAME, normalizeUsers } = require('./config');
|
||||||
|
|
||||||
const REG_FILE = path.join(TGBOT_HOME, 'registry.json');
|
const REG_FILE = path.join(TGBOT_HOME, 'registry.json');
|
||||||
const BOTS_DIR = path.join(TGBOT_HOME, 'bots');
|
const BOTS_DIR = path.join(TGBOT_HOME, 'bots');
|
||||||
@@ -80,6 +80,8 @@ async function listBots() {
|
|||||||
sessionMode: cfg?.sessionMode || 'per-chat',
|
sessionMode: cfg?.sessionMode || 'per-chat',
|
||||||
networkAccess: !!cfg?.networkAccess,
|
networkAccess: !!cfg?.networkAccess,
|
||||||
serviceTier: cfg?.serviceTier || '',
|
serviceTier: cfg?.serviceTier || '',
|
||||||
|
promptRules: cfg?.promptRules || '',
|
||||||
|
writeUsers: cfg?.writeUsers || [],
|
||||||
timeoutMinutes: cfg?.timeoutMinutes || null,
|
timeoutMinutes: cfg?.timeoutMinutes || null,
|
||||||
tokenMasked: maskToken(cfg?.telegramToken),
|
tokenMasked: maskToken(cfg?.telegramToken),
|
||||||
hasToken: !!cfg?.telegramToken,
|
hasToken: !!cfg?.telegramToken,
|
||||||
@@ -116,7 +118,7 @@ function validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, timeoutMinutes }) {
|
async function createBot({ name, token, workDir, engine, sandbox, model, reasoningEffort, sessionMode, networkAccess, promptRules, writeUsers, timeoutMinutes }) {
|
||||||
validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate: true });
|
validateBotInput({ name, token, workDir, sandbox, engine }, { isCreate: true });
|
||||||
const reg = loadRegistry();
|
const reg = loadRegistry();
|
||||||
if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`);
|
if (reg.bots[name]) throw new Error(`已有同名 bot:${name}`);
|
||||||
@@ -135,6 +137,8 @@ async function createBot({ name, token, workDir, engine, sandbox, model, reasoni
|
|||||||
reasoningEffort: reasoningEffort || '',
|
reasoningEffort: reasoningEffort || '',
|
||||||
sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat',
|
sessionMode: SESSION_MODES.includes(sessionMode) ? sessionMode : 'per-chat',
|
||||||
networkAccess: !!networkAccess,
|
networkAccess: !!networkAccess,
|
||||||
|
promptRules: typeof promptRules === 'string' ? promptRules.trim() : '',
|
||||||
|
writeUsers: normalizeUsers(writeUsers),
|
||||||
serviceTier: 'priority', // codex 速度固定 Fast 1.5x
|
serviceTier: 'priority', // codex 速度固定 Fast 1.5x
|
||||||
timeoutMinutes: Number(timeoutMinutes) || 30,
|
timeoutMinutes: Number(timeoutMinutes) || 30,
|
||||||
pm2Name: name,
|
pm2Name: name,
|
||||||
@@ -172,6 +176,8 @@ async function updateBot(name, patch) {
|
|||||||
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
|
if (patch.reasoningEffort !== undefined) raw.reasoningEffort = patch.reasoningEffort;
|
||||||
if (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; }
|
if (SESSION_MODES.includes(patch.sessionMode)) { raw.sessionMode = patch.sessionMode; delete raw.sharedSession; }
|
||||||
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess;
|
if (patch.networkAccess !== undefined) raw.networkAccess = !!patch.networkAccess;
|
||||||
|
if (patch.promptRules !== undefined) raw.promptRules = String(patch.promptRules || '').trim();
|
||||||
|
if (patch.writeUsers !== undefined) raw.writeUsers = normalizeUsers(patch.writeUsers);
|
||||||
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
|
if (patch.timeoutMinutes !== undefined && Number(patch.timeoutMinutes) > 0) {
|
||||||
raw.timeoutMinutes = Number(patch.timeoutMinutes);
|
raw.timeoutMinutes = Number(patch.timeoutMinutes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,11 @@
|
|||||||
.field { margin-bottom: 13px; }
|
.field { margin-bottom: 13px; }
|
||||||
.field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; }
|
.field label { display: block; font-size: 12px; color: #94a3b8; margin-bottom: 5px; }
|
||||||
.field label b { color: #ed4245; }
|
.field label b { color: #ed4245; }
|
||||||
|
.field textarea {
|
||||||
|
width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px;
|
||||||
|
color: #e2e8f0; padding: 9px 11px; font-size: 12.5px; font-family: inherit; line-height: 1.5; resize: vertical; min-height: 84px;
|
||||||
|
}
|
||||||
|
.field textarea:focus { outline: none; border-color: #5865f2; }
|
||||||
.field input, .field select {
|
.field input, .field select {
|
||||||
width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px;
|
width: 100%; background: #12141c; border: 1px solid #2e3345; border-radius: 8px;
|
||||||
color: #e2e8f0; padding: 9px 11px; font-size: 13px; font-family: inherit;
|
color: #e2e8f0; padding: 9px 11px; font-size: 13px; font-family: inherit;
|
||||||
@@ -249,6 +254,16 @@
|
|||||||
<label class="check"><input type="checkbox" id="f-network"> 允許 AI 連網</label>
|
<label class="check"><input type="checkbox" id="f-network"> 允許 AI 連網</label>
|
||||||
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開。目前只有 Codex 引擎有此選項</div>
|
<div class="hint">workspace-write 沙盒預設禁網;要呼叫外部 API(例如生圖服務)才需要打開。目前只有 Codex 引擎有此選項</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>可讀寫成員(Telegram username)</label>
|
||||||
|
<input id="f-writeusers" placeholder="例:JianMiau, someone(留空 = 所有人都依沙盒設定)" autocomplete="off">
|
||||||
|
<div class="hint">有填時,清單外的人一律以 read-only 沙盒執行(只能查詢、不能改檔或跑指令),由 CLI 層硬擋</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>回覆規範(每則訊息開頭附加)</label>
|
||||||
|
<textarea id="f-rules" placeholder="例:只回答與本專案相關的問題;@xxx 是後端工程師,回覆用 API 契約視角…"></textarea>
|
||||||
|
<div class="hint">選填。適合放「專案守門」「成員身分」這類每輪都要提醒的規則;專案本身的規範請放工作目錄的 AGENTS.md / CLAUDE.md</div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>逾時(分鐘)</label>
|
<label>逾時(分鐘)</label>
|
||||||
<input id="f-timeout" type="number" value="30" min="1">
|
<input id="f-timeout" type="number" value="30" min="1">
|
||||||
@@ -460,7 +475,7 @@ function renderBots(bots) {
|
|||||||
'<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' +
|
'<td><span class="mono" title="' + esc(b.workDir) + '">' + esc(shortPath(b.workDir)) + '</span></td>' +
|
||||||
'<td class="mono">' + esc(b.sandbox || '-') +
|
'<td class="mono">' + esc(b.sandbox || '-') +
|
||||||
((b.model || b.reasoningEffort || b.sessionMode !== 'per-chat' || b.networkAccess)
|
((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>'
|
? '<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>' +
|
: '') + '</td>' +
|
||||||
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
|
'<td class="mono">' + esc(b.tokenMasked || '-') + '</td>' +
|
||||||
'<td>' + fmtBytes(b.memory) + '</td>' +
|
'<td>' + fmtBytes(b.memory) + '</td>' +
|
||||||
@@ -532,6 +547,8 @@ function openCreate() {
|
|||||||
onEngineChange('', '');
|
onEngineChange('', '');
|
||||||
document.getElementById('f-session').value = 'per-chat';
|
document.getElementById('f-session').value = 'per-chat';
|
||||||
document.getElementById('f-network').checked = false;
|
document.getElementById('f-network').checked = false;
|
||||||
|
document.getElementById('f-writeusers').value = '';
|
||||||
|
document.getElementById('f-rules').value = '';
|
||||||
document.getElementById('f-timeout').value = '30';
|
document.getElementById('f-timeout').value = '30';
|
||||||
document.getElementById('f-autostart-wrap').style.display = '';
|
document.getElementById('f-autostart-wrap').style.display = '';
|
||||||
document.getElementById('f-autostart').checked = true;
|
document.getElementById('f-autostart').checked = true;
|
||||||
@@ -556,6 +573,8 @@ function openEdit(name) {
|
|||||||
onEngineChange(b.model || '', b.reasoningEffort || '');
|
onEngineChange(b.model || '', b.reasoningEffort || '');
|
||||||
document.getElementById('f-session').value = b.sessionMode || 'per-chat';
|
document.getElementById('f-session').value = b.sessionMode || 'per-chat';
|
||||||
document.getElementById('f-network').checked = !!b.networkAccess;
|
document.getElementById('f-network').checked = !!b.networkAccess;
|
||||||
|
document.getElementById('f-writeusers').value = (b.writeUsers || []).join(', ');
|
||||||
|
document.getElementById('f-rules').value = b.promptRules || '';
|
||||||
document.getElementById('f-timeout').value = b.timeoutMinutes || 30;
|
document.getElementById('f-timeout').value = b.timeoutMinutes || 30;
|
||||||
document.getElementById('f-autostart-wrap').style.display = 'none';
|
document.getElementById('f-autostart-wrap').style.display = 'none';
|
||||||
document.getElementById('bot-modal').classList.add('show');
|
document.getElementById('bot-modal').classList.add('show');
|
||||||
@@ -579,6 +598,8 @@ async function saveBot() {
|
|||||||
reasoningEffort: document.getElementById('f-effort').value,
|
reasoningEffort: document.getElementById('f-effort').value,
|
||||||
sessionMode: document.getElementById('f-session').value,
|
sessionMode: document.getElementById('f-session').value,
|
||||||
networkAccess: document.getElementById('f-network').checked,
|
networkAccess: document.getElementById('f-network').checked,
|
||||||
|
writeUsers: document.getElementById('f-writeusers').value,
|
||||||
|
promptRules: document.getElementById('f-rules').value,
|
||||||
timeoutMinutes: document.getElementById('f-timeout').value,
|
timeoutMinutes: document.getElementById('f-timeout').value,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|||||||