建立 telegram-codex-bot:串接 Codex CLI 的 Telegram bot(npm 包)
摘要: 用 Telegram 操控本機 Codex CLI 的 bot,附 web 控制台,封裝為 npm 包。 內容: - 零依賴(僅 Node 內建模組);bot 以 codex exec --json 驅動,支援會話延續 (exec resume)、workspace-write 沙盒、圖片輸入、進度回報與 ctx%/tokens footer - web 控制台(pm2 託管,預設 127.0.0.1:3799):Bot 管理分頁(新增/編輯/啟停, token 自動驗證、工作目錄用原生視窗選、模型/推理強度/速度下拉,清單來自 ~/.codex/models_cache.json)+ PM2 檢視分頁(狀態/port/log/啟停) - CLI:start(環境檢查後把控制台掛上 pm2)/ stop / restart / delete / status / logs / web / doctor - Windows 相容:解析 codex.cmd shim 直接以 node 執行、taskkill 整樹砍程序、 資料夾選擇視窗以 TopMost 透明 owner 置中 影響: 新專案初始版本;bot 設定存於 ~/.tgcodex/bots/<name>/,含明文 token 的實例 設定不進版控(.gitignore 已涵蓋 tgcodex.config.json 與 .tgcodex/)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
// Telegram Bot API 薄封裝:零依賴,直接用內建 fetch。
|
||||
// 含 Markdown→Telegram HTML 轉換(安全子集)、4000 字切分、HTML 解析失敗自動退回純文字。
|
||||
|
||||
function createTelegram(token) {
|
||||
const API = `https://api.telegram.org/bot${token}`;
|
||||
|
||||
async function tg(method, params) {
|
||||
const res = await fetch(`${API}/${method}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(`${method} 失敗:${data.description}`);
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// Markdown → Telegram HTML(只轉安全子集:code block、inline code、連結、粗體、標題)。
|
||||
// 單星號斜體與底線刻意不轉:會誤傷清單符號 * 與 username 的底線。
|
||||
const SENTINEL = String.fromCharCode(0);
|
||||
function mdToTgHtml(md) {
|
||||
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const stash = [];
|
||||
const put = (html) => SENTINEL + (stash.push(html) - 1) + SENTINEL;
|
||||
let text = md.replace(/```\w*\n?([\s\S]*?)```/g, (_, code) => put(`<pre>${esc(code.replace(/\n$/, ''))}</pre>`));
|
||||
text = text.replace(/`([^`\n]+)`/g, (_, code) => put(`<code>${esc(code)}</code>`));
|
||||
text = esc(text);
|
||||
text = text.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>');
|
||||
text = text.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
|
||||
return text.replace(new RegExp(`${SENTINEL}(\\d+)${SENTINEL}`, 'g'), (_, i) => stash[+i]);
|
||||
}
|
||||
|
||||
// 送出訊息;HTML 解析失敗(切分把標籤切壞等)自動退回純文字。
|
||||
async function sendMessage(chatId, text, { replyTo, html = false } = {}) {
|
||||
const params = { chat_id: chatId, text };
|
||||
if (replyTo) params.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
||||
if (html) {
|
||||
try {
|
||||
return await tg('sendMessage', { ...params, parse_mode: 'HTML' });
|
||||
} catch { /* fallback to plain */ }
|
||||
}
|
||||
return tg('sendMessage', params);
|
||||
}
|
||||
|
||||
// 超過 4096 上限就切段(留餘裕切 4000);只有第一段帶 reply。
|
||||
async function sendSplit(chatId, text, { replyTo, html = false } = {}) {
|
||||
const chunks = text.match(/[\s\S]{1,4000}/g) || ['(空回應)'];
|
||||
let first = null;
|
||||
for (const chunk of chunks) {
|
||||
const msg = await sendMessage(chatId, chunk, { replyTo, html });
|
||||
if (!first) first = msg;
|
||||
replyTo = undefined;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
// 編輯既有訊息;過長則第一段 edit、其餘續傳新訊息。
|
||||
async function editOrSplit(chatId, messageId, text, { html = false } = {}) {
|
||||
const edit = async (t) => {
|
||||
const params = { chat_id: chatId, message_id: messageId, text: t };
|
||||
if (html) {
|
||||
try {
|
||||
return await tg('editMessageText', { ...params, parse_mode: 'HTML' });
|
||||
} catch (err) {
|
||||
// 「訊息沒變」不算錯;其他 HTML 失敗退回純文字
|
||||
if (String(err.message).includes('message is not modified')) return;
|
||||
return tg('editMessageText', params);
|
||||
}
|
||||
}
|
||||
return tg('editMessageText', params);
|
||||
};
|
||||
if (text.length <= 4000) return edit(text);
|
||||
const chunks = text.match(/[\s\S]{1,4000}/g) || [];
|
||||
await edit(chunks[0]);
|
||||
for (let i = 1; i < chunks.length; i++) await sendMessage(chatId, chunks[i], { html });
|
||||
}
|
||||
|
||||
// 表情回應當狀態指示(👀 收到、👍 完成);很多聊天型別不支援,失敗直接吞。
|
||||
function react(chatId, messageId, emoji) {
|
||||
return tg('setMessageReaction', {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
reaction: emoji ? [{ type: 'emoji', emoji }] : [],
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function downloadFile(fileId, destPath) {
|
||||
const fs = require('fs');
|
||||
const info = await tg('getFile', { file_id: fileId });
|
||||
const res = await fetch(`https://api.telegram.org/file/bot${token}/${info.file_path}`);
|
||||
if (!res.ok) throw new Error(`下載檔案失敗:HTTP ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
fs.writeFileSync(destPath, buf);
|
||||
return destPath;
|
||||
}
|
||||
|
||||
return { tg, mdToTgHtml, sendMessage, sendSplit, editOrSplit, react, downloadFile };
|
||||
}
|
||||
|
||||
module.exports = { createTelegram };
|
||||
Reference in New Issue
Block a user