Fix telegram (#29)

Fix telegram
This commit is contained in:
Calesthio
2026-03-19 08:00:24 -07:00
committed by GitHub

View File

@@ -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 ─────────────────────────────────────────────────────
/**