'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, '>'); const stash = []; const put = (html) => SENTINEL + (stash.push(html) - 1) + SENTINEL; let text = md.replace(/```\w*\n?([\s\S]*?)```/g, (_, code) => put(`
${esc(code.replace(/\n$/, ''))}
`)); text = text.replace(/`([^`\n]+)`/g, (_, code) => put(`${esc(code)}`)); text = esc(text); text = text.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '$1'); text = text.replace(/\*\*([^*\n]+)\*\*/g, '$1'); text = text.replace(/^#{1,6}\s+(.+)$/gm, '$1'); 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 };