From d0fe4c5b3544058f0998aaf5b61db21b186017a6 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 2 Feb 2026 20:59:17 -0800 Subject: [PATCH] feat: add Telegram bot frontend with message handling Co-Authored-By: Claude Opus 4.5 --- src/frontends/telegram/bot.ts | 96 +++++++++++++++++++++++++ src/frontends/telegram/handlers.test.ts | 29 ++++++++ src/frontends/telegram/handlers.ts | 17 +++++ src/frontends/telegram/index.ts | 2 + 4 files changed, 144 insertions(+) create mode 100644 src/frontends/telegram/bot.ts create mode 100644 src/frontends/telegram/handlers.test.ts create mode 100644 src/frontends/telegram/handlers.ts create mode 100644 src/frontends/telegram/index.ts diff --git a/src/frontends/telegram/bot.ts b/src/frontends/telegram/bot.ts new file mode 100644 index 0000000..c1f1199 --- /dev/null +++ b/src/frontends/telegram/bot.ts @@ -0,0 +1,96 @@ +import { Bot, Context } from 'grammy'; +import type { NativeAgent } from '../../backends/index.js'; +import type { TelegramConfig } from '../../config/index.js'; +import { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js'; + +export interface TelegramBotConfig { + telegram: TelegramConfig; + agent: NativeAgent; +} + +export function createTelegramBot(config: TelegramBotConfig): Bot { + const bot = new Bot(config.telegram.bot_token); + const handleMessage = createMessageHandler(config.agent); + const handleReset = createResetHandler(config.agent); + const allowedChatIds = config.telegram.allowed_chat_ids; + + // Middleware to check chat ID + bot.use(async (ctx, next) => { + const chatId = ctx.chat?.id; + if (chatId === undefined || !isAllowedChat(chatId, allowedChatIds)) { + console.log(`Rejected message from unauthorized chat: ${chatId}`); + return; + } + await next(); + }); + + // Command handlers + bot.command('start', async (ctx) => { + await ctx.reply('Flynn is ready. Send me a message!'); + }); + + bot.command('reset', async (ctx) => { + handleReset(); + await ctx.reply('Conversation reset.'); + }); + + bot.command('status', async (ctx) => { + await ctx.reply('Flynn is running.'); + }); + + // Message handler + bot.on('message:text', async (ctx) => { + const text = ctx.message.text; + + // Show typing indicator + await ctx.replyWithChatAction('typing'); + + try { + const response = await handleMessage(text); + + // Telegram has a 4096 character limit per message + if (response.length <= 4096) { + await ctx.reply(response, { parse_mode: 'Markdown' }); + } else { + // Split into chunks + const chunks = splitMessage(response, 4096); + for (const chunk of chunks) { + await ctx.reply(chunk, { parse_mode: 'Markdown' }); + } + } + } catch (error) { + console.error('Error processing message:', error); + await ctx.reply('Sorry, an error occurred while processing your message.'); + } + }); + + return bot; +} + +function splitMessage(text: string, maxLength: number): string[] { + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining); + break; + } + + // Try to split at a newline + let splitIndex = remaining.lastIndexOf('\n', maxLength); + if (splitIndex === -1 || splitIndex < maxLength / 2) { + // Fall back to splitting at space + splitIndex = remaining.lastIndexOf(' ', maxLength); + } + if (splitIndex === -1 || splitIndex < maxLength / 2) { + // Hard split + splitIndex = maxLength; + } + + chunks.push(remaining.slice(0, splitIndex)); + remaining = remaining.slice(splitIndex).trimStart(); + } + + return chunks; +} diff --git a/src/frontends/telegram/handlers.test.ts b/src/frontends/telegram/handlers.test.ts new file mode 100644 index 0000000..8bff60c --- /dev/null +++ b/src/frontends/telegram/handlers.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createMessageHandler, isAllowedChat } from './handlers.js'; +import type { NativeAgent } from '../../backends/native/agent.js'; + +describe('isAllowedChat', () => { + it('returns true for allowed chat ID', () => { + expect(isAllowedChat(123, [123, 456])).toBe(true); + }); + + it('returns false for disallowed chat ID', () => { + expect(isAllowedChat(789, [123, 456])).toBe(false); + }); +}); + +describe('createMessageHandler', () => { + it('processes message and returns response', async () => { + const mockAgent: NativeAgent = { + process: vi.fn().mockResolvedValue('Agent response'), + reset: vi.fn(), + getHistory: vi.fn(), + } as unknown as NativeAgent; + + const handler = createMessageHandler(mockAgent); + const response = await handler('Hello'); + + expect(response).toBe('Agent response'); + expect(mockAgent.process).toHaveBeenCalledWith('Hello'); + }); +}); diff --git a/src/frontends/telegram/handlers.ts b/src/frontends/telegram/handlers.ts new file mode 100644 index 0000000..8115458 --- /dev/null +++ b/src/frontends/telegram/handlers.ts @@ -0,0 +1,17 @@ +import type { NativeAgent } from '../../backends/index.js'; + +export function isAllowedChat(chatId: number, allowedIds: number[]): boolean { + return allowedIds.includes(chatId); +} + +export function createMessageHandler(agent: NativeAgent): (text: string) => Promise { + return async (text: string): Promise => { + return agent.process(text); + }; +} + +export function createResetHandler(agent: NativeAgent): () => void { + return (): void => { + agent.reset(); + }; +} diff --git a/src/frontends/telegram/index.ts b/src/frontends/telegram/index.ts new file mode 100644 index 0000000..fcec8bf --- /dev/null +++ b/src/frontends/telegram/index.ts @@ -0,0 +1,2 @@ +export { createTelegramBot, type TelegramBotConfig } from './bot.js'; +export { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';