/** * Отправка сообщений через Telegram Bot API без зависимостей (воркер). * Без токена (локальная разработка) сообщение печатается в консоль. */ export type InlineButton = { text: string; callback_data: string } | { text: string; web_app: { url: string } } | { text: string; url: string }; export interface OutgoingMessage { chatId: bigint | number | string; text: string; buttons?: InlineButton[][]; } /** Telegram принимает web_app-кнопки только с https — в разработке такие кнопки опускаются */ export function webAppButton(text: string, url: string): InlineButton | null { return url.startsWith('https://') ? { text, web_app: { url } } : null; } export function compactRows(rows: (InlineButton | null)[][]): InlineButton[][] { return rows.map((r) => r.filter((b): b is InlineButton => !!b)).filter((r) => r.length > 0); } export class TelegramSender { constructor( private token: string, private label: string, ) {} /** false — получатель недоступен (заблокировал бота / бот ему не писал) */ async send(msg: OutgoingMessage): Promise { if (!this.token) { const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? ''; console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`); return true; } const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ chat_id: String(msg.chatId), text: msg.text, parse_mode: 'HTML', link_preview_options: { is_disabled: true }, reply_markup: msg.buttons?.length ? { inline_keyboard: msg.buttons } : undefined, }), }); if (!res.ok) { const body = await res.text(); // 403 — пользователь заблокировал бота, 400 chat not found — бот ему ещё не писал // (например, тестовые пользователи seed); повторять бессмысленно if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) { console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`); return false; } throw new Error(`Telegram ${res.status}: ${body}`); } return true; } /** Файл с подписью: картинки — как фото (видно сразу), остальное — документом. false — получатель недоступен */ async sendFile(msg: { chatId: bigint | number | string; caption: string; body: Buffer; fileName: string; mime: string }): Promise { const asPhoto = /^image\/(jpeg|png|webp)$/.test(msg.mime) && msg.body.length <= 10 * 1024 * 1024; if (!this.token) { console.log(`[${this.label} → ${msg.chatId}] 📎 ${msg.fileName} (${msg.body.length} B) ${msg.caption.replace(/<[^>]+>/g, '')}`); return true; } const form = new FormData(); form.set('chat_id', String(msg.chatId)); form.set('caption', msg.caption.slice(0, 1024)); form.set('parse_mode', 'HTML'); form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(msg.body)], { type: msg.mime }), msg.fileName); const res = await fetch(`https://api.telegram.org/bot${this.token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { method: 'POST', body: form }); if (!res.ok) { const body = await res.text(); if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) return false; throw new Error(`Telegram ${res.status}: ${body}`); } return true; } /** Скачать файл, который прислали боту (до 20 МБ — ограничение Bot API) */ async download(fileId: string): Promise { if (!this.token) return null; const info = await fetch(`https://api.telegram.org/bot${this.token}/getFile?file_id=${encodeURIComponent(fileId)}`); if (!info.ok) return null; const json = (await info.json()) as { ok: boolean; result?: { file_path?: string } }; if (!json.result?.file_path) return null; const file = await fetch(`https://api.telegram.org/file/bot${this.token}/${json.result.file_path}`); return file.ok ? file : null; } } export const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(//g, '>');