feat: add controlled Telegram terminal agent
This commit is contained in:
@@ -25,6 +25,10 @@ const COMMANDS = {
|
||||
'/brief': 'Get a compact text summary of the latest intelligence',
|
||||
'/ask': 'Ask the configured AI about current intelligence',
|
||||
'/reset': 'Clear the AI conversation history',
|
||||
'/tools': 'List allowlisted Intelligence Terminal tools',
|
||||
'/trace': 'Show the last tool audit trace',
|
||||
'/confirm': 'Confirm a pending agent action',
|
||||
'/cancel': 'Cancel a pending agent action',
|
||||
'/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)',
|
||||
@@ -42,6 +46,7 @@ export class TelegramAlerter {
|
||||
this._lastUpdateId = 0; // For polling bot commands
|
||||
this._commandHandlers = {}; // Registered command callbacks
|
||||
this._messageHandler = null; // Conversational free-text callback
|
||||
this._callbackHandler = null;
|
||||
this._pollingInterval = null;
|
||||
this._pollInProgress = false;
|
||||
this._botUsername = null;
|
||||
@@ -79,6 +84,7 @@ export class TelegramAlerter {
|
||||
text: chunks[i],
|
||||
...(parseMode ? { parse_mode: parseMode } : {}),
|
||||
disable_web_page_preview: opts.disablePreview !== false,
|
||||
...(opts.replyMarkup && i === chunks.length - 1 ? { reply_markup: opts.replyMarkup } : {}),
|
||||
...(opts.replyToMessageId && i === 0 ? { reply_to_message_id: opts.replyToMessageId } : {}),
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
@@ -317,6 +323,10 @@ export class TelegramAlerter {
|
||||
this._messageHandler = handler;
|
||||
}
|
||||
|
||||
onCallback(handler) {
|
||||
this._callbackHandler = handler;
|
||||
}
|
||||
|
||||
async sendChatAction(chatId, action = 'typing') {
|
||||
if (!this.isConfigured) return false;
|
||||
try {
|
||||
@@ -332,6 +342,34 @@ export class TelegramAlerter {
|
||||
}
|
||||
}
|
||||
|
||||
async answerCallbackQuery(callbackQueryId, text = '') {
|
||||
try {
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/answerCallbackQuery`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ callback_query_id: callbackQueryId, ...(text ? { text: String(text).slice(0, 200) } : {}) }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
muteAlerts(hours = 1) {
|
||||
const boundedHours = Math.max(0.25, Math.min(24, Number(hours) || 1));
|
||||
this._muteUntil = Date.now() + boundedHours * 60 * 60 * 1000;
|
||||
return this._muteUntil;
|
||||
}
|
||||
|
||||
unmuteAlerts() {
|
||||
this._muteUntil = null;
|
||||
}
|
||||
|
||||
getMuteStatus() {
|
||||
return { muted: this._isMuted(), until: this._muteUntil ? new Date(this._muteUntil).toISOString() : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for incoming messages/commands.
|
||||
* Call this once during server startup.
|
||||
@@ -369,7 +407,7 @@ export class TelegramAlerter {
|
||||
offset: String(this._lastUpdateId + 1),
|
||||
timeout: '0',
|
||||
limit: '10',
|
||||
allowed_updates: JSON.stringify(['message']),
|
||||
allowed_updates: JSON.stringify(['message', 'callback_query']),
|
||||
});
|
||||
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/getUpdates?${params}`, {
|
||||
@@ -384,6 +422,11 @@ export class TelegramAlerter {
|
||||
|
||||
for (const update of data.result) {
|
||||
this._lastUpdateId = Math.max(this._lastUpdateId, update.update_id);
|
||||
if (update.callback_query) {
|
||||
const callbackChatId = String(update.callback_query.message?.chat?.id);
|
||||
if (callbackChatId === String(this.chatId)) await this._handleCallbackQuery(update.callback_query);
|
||||
continue;
|
||||
}
|
||||
const msg = update.message;
|
||||
if (!msg?.text) continue;
|
||||
|
||||
@@ -436,7 +479,7 @@ export class TelegramAlerter {
|
||||
|
||||
if (command === '/mute') {
|
||||
const hours = parseFloat(args) || 1;
|
||||
this._muteUntil = Date.now() + hours * 60 * 60 * 1000;
|
||||
this.muteAlerts(hours);
|
||||
await this.sendMessage(
|
||||
`🔇 Alerts muted for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`,
|
||||
{ chatId: replyChatId, replyToMessageId: msg.message_id }
|
||||
@@ -445,7 +488,7 @@ export class TelegramAlerter {
|
||||
}
|
||||
|
||||
if (command === '/unmute') {
|
||||
this._muteUntil = null;
|
||||
this.unmuteAlerts();
|
||||
await this.sendMessage(
|
||||
`🔔 Alerts resumed. You'll receive the next signal evaluation.`,
|
||||
{ chatId: replyChatId, replyToMessageId: msg.message_id }
|
||||
@@ -480,7 +523,8 @@ export class TelegramAlerter {
|
||||
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 } : {}) });
|
||||
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
|
||||
await this.sendMessage(text, { chatId: replyChatId, replyToMessageId: msg.message_id, ...(parseMode !== undefined ? { parseMode } : {}), ...(replyMarkup ? { replyMarkup } : {}) });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] Command ${command} error:`, err.message);
|
||||
@@ -505,10 +549,12 @@ export class TelegramAlerter {
|
||||
const responseParseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode')
|
||||
? response.parseMode
|
||||
: parseMode;
|
||||
const replyMarkup = typeof response === 'object' ? response.replyMarkup : null;
|
||||
await this.sendMessage(responseText, {
|
||||
chatId: replyChatId,
|
||||
replyToMessageId: msg.message_id,
|
||||
parseMode: responseParseMode,
|
||||
...(replyMarkup ? { replyMarkup } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Telegram] AI chat error:', err.message);
|
||||
@@ -522,6 +568,25 @@ export class TelegramAlerter {
|
||||
}
|
||||
}
|
||||
|
||||
async _handleCallbackQuery(query) {
|
||||
if (!this._callbackHandler || !query?.data) return;
|
||||
const chatId = query.message?.chat?.id;
|
||||
const stopTyping = this._startTyping(chatId);
|
||||
await this.answerCallbackQuery(query.id, 'Processing...');
|
||||
try {
|
||||
const response = await this._callbackHandler(query.data, query);
|
||||
if (!response) return;
|
||||
const text = typeof response === 'string' ? response : response.text;
|
||||
const parseMode = typeof response === 'object' && Object.hasOwn(response, 'parseMode') ? response.parseMode : null;
|
||||
await this.sendMessage(text, { chatId, replyToMessageId: query.message?.message_id, parseMode });
|
||||
} catch (error) {
|
||||
console.error('[Telegram] Callback error:', error.message);
|
||||
await this.sendMessage('The requested action failed.', { chatId, replyToMessageId: query.message?.message_id, parseMode: null });
|
||||
} finally {
|
||||
stopTyping();
|
||||
}
|
||||
}
|
||||
|
||||
_startTyping(chatId) {
|
||||
this.sendChatAction(chatId);
|
||||
const interval = setInterval(() => this.sendChatAction(chatId), 4000);
|
||||
|
||||
Reference in New Issue
Block a user