diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs index be93968..4c3ac3a 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) @@ -38,6 +40,7 @@ export class TelegramAlerter { this._lastUpdateId = 0; // For polling bot commands this._commandHandlers = {}; // Registered command callbacks this._pollingInterval = null; + this._botUsername = null; } get isConfigured() { @@ -47,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: 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); @@ -286,6 +317,9 @@ export class TelegramAlerter { if (this._pollingInterval) return; // Already polling console.log('[Telegram] Bot command polling started'); + this._initializeBotCommands().catch((err) => { + console.error('[Telegram] Command initialization failed:', err.message); + }); this._pollingInterval = setInterval(() => this._pollUpdates(), intervalMs); // Initial poll this._pollUpdates(); @@ -325,8 +359,8 @@ export class TelegramAlerter { const msg = update.message; if (!msg?.text) continue; - // Only process messages from the configured chat const chatId = String(msg.chat?.id); + // Restrict command execution to the configured chat/group only. if (chatId !== String(this.chatId)) continue; await this._handleMessage(msg); @@ -342,8 +376,11 @@ export class TelegramAlerter { async _handleMessage(msg) { const text = msg.text.trim(); const parts = text.split(/\s+/); - const command = parts[0].toLowerCase(); + const rawCommand = parts[0].toLowerCase(); + const command = this._normalizeCommand(rawCommand); + if (!command) return; const args = parts.slice(1).join(' '); + const replyChatId = msg.chat?.id; // Built-in commands if (command === '/help') { @@ -352,7 +389,7 @@ export class TelegramAlerter { .join('\n'); await this.sendMessage( `🤖 *CRUCIX BOT COMMANDS*\n\n${helpText}\n\n_Tip: Commands are case-insensitive_`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -362,7 +399,7 @@ export class TelegramAlerter { this._muteUntil = Date.now() + hours * 60 * 60 * 1000; await this.sendMessage( `🔇 Alerts muted for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -371,7 +408,7 @@ export class TelegramAlerter { this._muteUntil = null; await this.sendMessage( `🔔 Alerts resumed. You'll receive the next signal evaluation.`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -379,7 +416,7 @@ export class TelegramAlerter { if (command === '/alerts') { const recent = this._alertHistory.slice(-10); if (recent.length === 0) { - await this.sendMessage('No recent alerts.', { replyToMessageId: msg.message_id }); + await this.sendMessage('No recent alerts.', { chatId: replyChatId, replyToMessageId: msg.message_id }); return; } const lines = recent.map(a => @@ -387,7 +424,7 @@ export class TelegramAlerter { ); await this.sendMessage( `📋 *Recent Alerts (last ${recent.length})*\n\n${lines.join('\n')}`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -398,19 +435,86 @@ export class TelegramAlerter { try { const response = await handler(args, msg.message_id); if (response) { - await this.sendMessage(response, { replyToMessageId: msg.message_id }); + await this.sendMessage(response, { chatId: replyChatId, replyToMessageId: msg.message_id }); } } catch (err) { console.error(`[Telegram] Command ${command} error:`, err.message); await this.sendMessage( `❌ Command failed: ${err.message}`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); } } // Unknown commands are silently ignored to avoid spamming } + async _initializeBotCommands() { + await this._loadBotIdentity(); + + const botCommands = Object.entries(COMMANDS).map(([command, description]) => ({ + command: command.replace('/', ''), + description: description.substring(0, 256), + })); + + // Register commands only for the configured chat to avoid global discovery. + await this._setMyCommands(botCommands, this._buildConfiguredChatScope()); + } + + async _loadBotIdentity() { + const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/getMe`, { + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + const err = await res.text().catch(() => ''); + throw new Error(`getMe failed (${res.status}): ${err.substring(0, 200)}`); + } + const data = await res.json(); + if (!data.ok || !data.result?.username) { + throw new Error('getMe returned invalid bot profile'); + } + this._botUsername = String(data.result.username).toLowerCase(); + } + + async _setMyCommands(commands, scope = null) { + const body = { commands }; + if (scope) body.scope = scope; + + const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/setMyCommands`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + const err = await res.text().catch(() => ''); + throw new Error(`setMyCommands failed (${res.status}): ${err.substring(0, 200)}`); + } + const data = await res.json(); + if (!data.ok) { + throw new Error(`setMyCommands rejected: ${JSON.stringify(data).substring(0, 200)}`); + } + } + + _buildConfiguredChatScope() { + const chatId = Number(this.chatId); + if (!Number.isSafeInteger(chatId)) { + throw new Error(`TELEGRAM_CHAT_ID must be a numeric chat id, got: ${this.chatId}`); + } + return { type: 'chat', chat_id: chatId }; + } + + _normalizeCommand(rawCommand) { + if (!rawCommand.startsWith('/')) return null; + + const atIdx = rawCommand.indexOf('@'); + if (atIdx === -1) return rawCommand; + + const command = rawCommand.substring(0, atIdx); + const mentionedBot = rawCommand.substring(atIdx + 1).toLowerCase(); + if (!this._botUsername || mentionedBot === this._botUsername) return command; + return null; + } + // ─── Semantic Dedup ───────────────────────────────────────────────────── /**