feat: add conversational Telegram AI chat
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user