feat: add controlled Telegram terminal agent
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 6s
Build / test-and-image (pull_request) Successful in 50s

This commit is contained in:
2026-07-05 21:10:14 +02:00
parent 85fd619086
commit dcfd3fd2bf
10 changed files with 684 additions and 20 deletions

View File

@@ -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);