feat: add conversational Telegram AI chat
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 7s
Build / test-and-image (pull_request) Successful in 53s

This commit is contained in:
2026-07-05 20:39:11 +02:00
parent 9f3d7dc6a9
commit de1d9aee70
9 changed files with 404 additions and 12 deletions

View File

@@ -23,6 +23,8 @@ const COMMANDS = {
'/status': 'Get current system health, last sweep time, source status',
'/sweep': 'Trigger a manual sweep cycle',
'/brief': 'Get a compact text summary of the latest intelligence',
'/ask': 'Ask the configured AI about current intelligence',
'/reset': 'Clear the AI conversation history',
'/portfolio': 'Show current positions and P&L (if Alpaca connected)',
'/alerts': 'Show recent alert history',
'/mute': 'Mute alerts for 1h (or /mute 2h, /mute 4h)',
@@ -39,7 +41,9 @@ export class TelegramAlerter {
this._muteUntil = null; // Mute timestamp
this._lastUpdateId = 0; // For polling bot commands
this._commandHandlers = {}; // Registered command callbacks
this._messageHandler = null; // Conversational free-text callback
this._pollingInterval = null;
this._pollInProgress = false;
this._botUsername = null;
this._pollFailureCount = 0;
this._lastPollErrorLogAt = 0;
@@ -61,7 +65,7 @@ export class TelegramAlerter {
async sendMessage(message, opts = {}) {
if (!this.isConfigured) return { ok: false };
const chatId = opts.chatId ?? this.chatId;
const parseMode = opts.parseMode || 'Markdown';
const parseMode = Object.hasOwn(opts, 'parseMode') ? opts.parseMode : 'Markdown';
const chunks = this._chunkText(message, TELEGRAM_MAX_TEXT);
try {
@@ -73,7 +77,7 @@ export class TelegramAlerter {
body: JSON.stringify({
chat_id: chatId,
text: chunks[i],
parse_mode: parseMode,
...(parseMode ? { parse_mode: parseMode } : {}),
disable_web_page_preview: opts.disablePreview !== false,
...(opts.replyToMessageId && i === 0 ? { reply_to_message_id: opts.replyToMessageId } : {}),
}),
@@ -309,6 +313,25 @@ export class TelegramAlerter {
this._commandHandlers[command.toLowerCase()] = handler;
}
onMessage(handler) {
this._messageHandler = handler;
}
async sendChatAction(chatId, action = 'typing') {
if (!this.isConfigured) return false;
try {
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendChatAction`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, action }),
signal: AbortSignal.timeout(10000),
});
return res.ok;
} catch {
return false;
}
}
/**
* Start polling for incoming messages/commands.
* Call this once during server startup.
@@ -339,6 +362,8 @@ export class TelegramAlerter {
}
async _pollUpdates() {
if (this._pollInProgress) return;
this._pollInProgress = true;
try {
const params = new URLSearchParams({
offset: String(this._lastUpdateId + 1),
@@ -378,6 +403,8 @@ export class TelegramAlerter {
console.error(`[Telegram] Poll degraded (${this._pollFailureCount} consecutive failures):`, err.message);
}
}
} finally {
this._pollInProgress = false;
}
}
@@ -386,10 +413,15 @@ export class TelegramAlerter {
const parts = text.split(/\s+/);
const rawCommand = parts[0].toLowerCase();
const command = this._normalizeCommand(rawCommand);
if (!command) return;
const args = parts.slice(1).join(' ');
const replyChatId = msg.chat?.id;
if (!command) {
if (!this._messageHandler) return;
await this._runMessageHandler(this._messageHandler, text, msg, { parseMode: null });
return;
}
// Built-in commands
if (command === '/help') {
const helpText = Object.entries(COMMANDS)
@@ -440,10 +472,15 @@ export class TelegramAlerter {
// Delegate to registered handlers
const handler = this._commandHandlers[command];
if (handler) {
const stopTyping = this._startTyping(replyChatId);
try {
const response = await handler(args, msg.message_id);
const response = await handler(args, msg.message_id, msg);
if (response) {
await this.sendMessage(response, { chatId: replyChatId, replyToMessageId: msg.message_id });
const text = typeof response === 'string' ? response : response.text;
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
? response.parseMode
: undefined;
await this.sendMessage(text, { chatId: replyChatId, replyToMessageId: msg.message_id, ...(parseMode !== undefined ? { parseMode } : {}) });
}
} catch (err) {
console.error(`[Telegram] Command ${command} error:`, err.message);
@@ -451,11 +488,47 @@ export class TelegramAlerter {
`❌ Command failed: ${err.message}`,
{ chatId: replyChatId, replyToMessageId: msg.message_id }
);
} finally {
stopTyping();
}
}
// Unknown commands are silently ignored to avoid spamming
}
async _runMessageHandler(handler, text, msg, { parseMode = null } = {}) {
const replyChatId = msg.chat?.id;
const stopTyping = this._startTyping(replyChatId);
try {
const response = await handler(text, msg);
if (!response) return;
const responseText = typeof response === 'string' ? response : response.text;
const responseParseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
? response.parseMode
: parseMode;
await this.sendMessage(responseText, {
chatId: replyChatId,
replyToMessageId: msg.message_id,
parseMode: responseParseMode,
});
} catch (err) {
console.error('[Telegram] AI chat error:', err.message);
await this.sendMessage('AI chat failed. Please try again or use /status to check the LLM configuration.', {
chatId: replyChatId,
replyToMessageId: msg.message_id,
parseMode: null,
});
} finally {
stopTyping();
}
}
_startTyping(chatId) {
this.sendChatAction(chatId);
const interval = setInterval(() => this.sendChatAction(chatId), 4000);
interval.unref?.();
return () => clearInterval(interval);
}
async _initializeBotCommands() {
await this._loadBotIdentity();