diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs index 97a9d3f..45e902f 100644 --- a/lib/alerts/telegram.mjs +++ b/lib/alerts/telegram.mjs @@ -4,6 +4,8 @@ import { createHash } from 'crypto'; const TELEGRAM_API = 'https://api.telegram.org'; +/** Telegram Bot API limit for sendMessage text (bytes/characters). */ +const TELEGRAM_MAX_TEXT = 4096; // ─── Alert Tiers ──────────────────────────────────────────────────────────── // FLASH: Immediate action required — market-moving, time-critical (e.g. war escalation, flash crash) @@ -48,42 +50,70 @@ export class TelegramAlerter { // ─── Core Messaging ───────────────────────────────────────────────────── /** - * Send a message via Telegram Bot API. + * Send a message via Telegram Bot API. Splits at TELEGRAM_MAX_TEXT so long messages + * (e.g. /brief) are sent in multiple messages instead of being truncated or failing. * @param {string} message - markdown-formatted message - * @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId } + * @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId, chatId } * @returns {Promise<{ok: boolean, messageId?: number}>} */ async sendMessage(message, opts = {}) { if (!this.isConfigured) return { ok: false }; + const chatId = opts.chatId ?? this.chatId; + const parseMode = opts.parseMode || 'Markdown'; + const chunks = this._chunkText(message, TELEGRAM_MAX_TEXT); try { - const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: opts.chatId ?? this.chatId, - text: message, - parse_mode: opts.parseMode || 'Markdown', - disable_web_page_preview: opts.disablePreview !== false, - ...(opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}), - }), - signal: AbortSignal.timeout(15000), - }); + let lastResult = { ok: false, messageId: undefined }; + for (let i = 0; i < chunks.length; i++) { + const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: chunks[i], + parse_mode: parseMode, + disable_web_page_preview: opts.disablePreview !== false, + ...(opts.replyToMessageId && i === 0 ? { reply_to_message_id: opts.replyToMessageId } : {}), + }), + signal: AbortSignal.timeout(15000), + }); - if (!res.ok) { - const err = await res.text().catch(() => ''); - console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`); - return { ok: false }; + if (!res.ok) { + const err = await res.text().catch(() => ''); + console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`); + return lastResult; + } + + const data = await res.json(); + lastResult = { ok: true, messageId: data.result?.message_id }; } - - const data = await res.json(); - return { ok: true, messageId: data.result?.message_id }; + return lastResult; } catch (err) { console.error('[Telegram] Send error:', err.message); return { ok: false }; } } + /** + * Split text into chunks of at most maxLen. Prefer breaking at newlines to avoid + * splitting mid-Markdown. + */ + _chunkText(text, maxLen = TELEGRAM_MAX_TEXT) { + if (!text || text.length <= maxLen) return text ? [text] : []; + const chunks = []; + let start = 0; + while (start < text.length) { + let end = Math.min(start + maxLen, text.length); + if (end < text.length) { + const lastNewline = text.lastIndexOf('\n', end - 1); + if (lastNewline > start) end = lastNewline + 1; + } + chunks.push(text.slice(start, end)); + start = end; + } + return chunks; + } + // Backward-compatible alias async sendAlert(message) { const result = await this.sendMessage(message);