Files
telegram-codex-bot/src/telegram.js
T

132 lines
5.6 KiB
JavaScript
Raw Normal View History

'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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(() => {});
}
// 上傳檔案到 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 });
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, sendFile };
}
module.exports = { createTelegram };