feat: add Telegram bot frontend with message handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-02 20:59:17 -08:00
parent 69309e58bc
commit d0fe4c5b35
4 changed files with 144 additions and 0 deletions
+96
View File
@@ -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;
}
+29
View File
@@ -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');
});
});
+17
View File
@@ -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<string> {
return async (text: string): Promise<string> => {
return agent.process(text);
};
}
export function createResetHandler(agent: NativeAgent): () => void {
return (): void => {
agent.reset();
};
}
+2
View File
@@ -0,0 +1,2 @@
export { createTelegramBot, type TelegramBotConfig } from './bot.js';
export { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';