diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs index be93968..97a9d3f 100644 --- a/lib/alerts/telegram.mjs +++ b/lib/alerts/telegram.mjs @@ -38,6 +38,7 @@ export class TelegramAlerter { this._lastUpdateId = 0; // For polling bot commands this._commandHandlers = {}; // Registered command callbacks this._pollingInterval = null; + this._botUsername = null; } get isConfigured() { @@ -60,7 +61,7 @@ export class TelegramAlerter { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - chat_id: this.chatId, + chat_id: opts.chatId ?? this.chatId, text: message, parse_mode: opts.parseMode || 'Markdown', disable_web_page_preview: opts.disablePreview !== false, @@ -286,6 +287,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,9 +329,10 @@ export class TelegramAlerter { const msg = update.message; if (!msg?.text) continue; - // Only process messages from the configured chat + const chatType = msg.chat?.type; const chatId = String(msg.chat?.id); - if (chatId !== String(this.chatId)) continue; + // Commands can come from private chats or the configured chat/group. + if (chatType !== 'private' && chatId !== String(this.chatId)) continue; await this._handleMessage(msg); } @@ -342,8 +347,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 +360,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 +370,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 +379,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 +387,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 +395,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 +406,80 @@ 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 globally, then force explicit private/group scopes. + await this._setMyCommands(botCommands); + await this._setMyCommands(botCommands, { type: 'all_private_chats' }); + await this._setMyCommands(botCommands, { type: 'all_group_chats' }); + } + + 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)}`); + } + } + + _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 ───────────────────────────────────────────────────── /**