Files
ai/gateway/src/channels/telegram-handler.ts

282 lines
8.2 KiB
TypeScript

import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { Authenticator } from '../auth/authenticator.js';
import type { AgentHarness, HarnessFactory } from '../harness/agent-harness.js';
import type { InboundMessage } from '../types/messages.js';
import { randomUUID } from 'crypto';
import type { ChannelAdapter, ChannelCapabilities } from '../workspace/index.js';
export interface TelegramHandlerConfig {
authenticator: Authenticator;
telegramBotToken: string;
createHarness: HarnessFactory;
}
interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
from: {
id: number;
first_name: string;
username?: string;
};
chat: {
id: number;
type: string;
};
text?: string;
photo?: Array<{
file_id: string;
file_size: number;
}>;
};
}
interface TelegramSession {
harness: AgentHarness;
lastActivity: number;
}
/**
* Telegram webhook handler
*/
export class TelegramHandler {
private config: TelegramHandlerConfig;
private sessions = new Map<string, TelegramSession>();
private chatIds = new Map<string, number>(); // sessionId -> chatId
constructor(config: TelegramHandlerConfig) {
this.config = config;
}
/**
* Register Telegram webhook routes
*/
register(app: FastifyInstance): void {
app.post('/webhook/telegram', async (request: FastifyRequest, reply: FastifyReply) => {
await this.handleWebhook(request, reply, app);
});
}
/**
* Handle Telegram webhook
*/
private async handleWebhook(
request: FastifyRequest,
reply: FastifyReply,
app: FastifyInstance
): Promise<void> {
const logger = app.log;
try {
const update = request.body as TelegramUpdate;
if (!update.message?.text) {
// Ignore non-text messages for now
reply.code(200).send({ ok: true });
return;
}
const telegramUserId = update.message.from.id.toString();
const chatId = update.message.chat.id;
const text = update.message.text;
logger.info({ telegramUserId, chatId, text }, 'Received Telegram message');
// Authenticate
const authContext = await this.config.authenticator.authenticateTelegram(telegramUserId);
if (!authContext) {
logger.warn({ telegramUserId }, 'Telegram user not authenticated');
await this.sendTelegramMessage(
chatId,
'Please link your Telegram account to Dexorder first.'
);
reply.code(200).send({ ok: true });
return;
}
// Store chatId for this session
this.chatIds.set(authContext.sessionId, chatId);
// Create Telegram channel adapter
const telegramAdapter: ChannelAdapter = {
sendSnapshot: () => {
// Telegram doesn't support sync protocol
},
sendPatch: () => {
// Telegram doesn't support sync protocol
},
sendText: (msg) => {
this.sendTelegramMessage(chatId, msg.text).catch((err) => {
logger.error({ error: err }, 'Failed to send Telegram text');
});
},
sendChunk: () => {
// Telegram doesn't support streaming; full response sent after handleMessage resolves
},
sendImage: (msg) => {
this.sendTelegramPhoto(chatId, msg.data, msg.mimeType, msg.caption).catch((err) => {
logger.error({ error: err }, 'Failed to send Telegram image');
});
},
getCapabilities: (): ChannelCapabilities => ({
supportsSync: false,
supportsImages: true,
supportsMarkdown: true,
supportsStreaming: false,
supportsTradingViewEmbed: false,
}),
};
// Get or create harness
let session = this.sessions.get(authContext.sessionId);
if (!session) {
const harness = this.config.createHarness({
userId: authContext.userId,
sessionId: authContext.sessionId,
license: authContext.license,
mcpServerUrl: authContext.mcpServerUrl,
logger,
channelAdapter: telegramAdapter,
channelType: authContext.channelType,
channelUserId: authContext.channelUserId,
});
await harness.initialize();
session = { harness, lastActivity: Date.now() };
this.sessions.set(authContext.sessionId, session);
} else {
// Update channel adapter and activity timestamp for existing session
session.harness.setChannelAdapter(telegramAdapter);
session.lastActivity = Date.now();
}
// Process message
const inboundMessage: InboundMessage = {
messageId: randomUUID(),
userId: authContext.userId,
sessionId: authContext.sessionId,
content: text,
attachments: [], // TODO: Add image support for Telegram
timestamp: new Date(),
};
const response = await session.harness.handleMessage(inboundMessage);
// Send response back to Telegram
await this.sendTelegramMessage(chatId, response.content);
reply.code(200).send({ ok: true });
} catch (error) {
logger.error({ error }, 'Error handling Telegram webhook');
reply.code(500).send({ ok: false, error: 'Internal server error' });
}
}
/**
* Send text message to Telegram chat
*/
private async sendTelegramMessage(chatId: number, text: string): Promise<void> {
const url = `https://api.telegram.org/bot${this.config.telegramBotToken}/sendMessage`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'Markdown',
}),
});
if (!response.ok) {
throw new Error(`Telegram API error: ${response.statusText}`);
}
} catch (error) {
console.error('Failed to send Telegram message:', error);
throw error;
}
}
/**
* Send photo to Telegram chat
* Converts base64 image data to a buffer and sends via sendPhoto API
*/
private async sendTelegramPhoto(
chatId: number,
base64Data: string,
mimeType: string,
caption?: string
): Promise<void> {
const url = `https://api.telegram.org/bot${this.config.telegramBotToken}/sendPhoto`;
try {
// Convert base64 to buffer
const imageBuffer = Buffer.from(base64Data, 'base64');
// Determine filename from mimeType
const extension = mimeType.split('/')[1] || 'png';
const filename = `image.${extension}`;
// Create FormData for multipart upload
const formData = new FormData();
formData.append('chat_id', chatId.toString());
formData.append('photo', new Blob([imageBuffer], { type: mimeType }), filename);
if (caption) {
formData.append('caption', caption);
}
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Telegram API error: ${response.statusText} - ${errorText}`);
}
} catch (error) {
console.error('Failed to send Telegram photo:', error);
throw error;
}
}
/**
* Clean up sessions that have been idle longer than maxAgeMs.
* Triggers Iceberg flush for each expired session via harness.cleanup().
*/
async cleanupSessions(maxAgeMs = 30 * 60 * 1000): Promise<void> {
const now = Date.now();
const expired: string[] = [];
for (const [sessionId, session] of this.sessions) {
if (now - session.lastActivity > maxAgeMs) {
expired.push(sessionId);
}
}
for (const sessionId of expired) {
const session = this.sessions.get(sessionId);
if (session) {
await session.harness.cleanup().catch(() => {});
this.sessions.delete(sessionId);
this.chatIds.delete(sessionId);
}
}
}
/**
* Flush and clean up all active sessions.
* Called during graceful shutdown.
*/
async endAllSessions(): Promise<void> {
const cleanups = Array.from(this.sessions.values()).map(s => s.harness.cleanup());
await Promise.allSettled(cleanups);
this.sessions.clear();
this.chatIds.clear();
}
}