How to Build a Multi-Channel AI Assistant with Telegram and WebSocket Support
Purpose
I built an AI assistant that only worked on one platform. Users wanted to interact with it through Telegram, web chat, and potentially Slack. Each request meant writing a new integration from scratch. This post shows how to solve this with a channel abstraction layer that normalizes messages from different platforms.
The Problem I Faced
My AI assistant started with a web chat interface. It worked fine. Then users asked:
- “Can I use it in Telegram?”
- “What about a mobile app?”
- “Can we integrate it with Slack?”
Each request meant rewriting the same logic for a different platform. My code became a mess of platform-specific handlers scattered everywhere.
src/├── TelegramHandler.java # Telegram-specific logic├── WebSocketHandler.java # WebSocket-specific logic├── SlackHandler.java # Slack-specific logic└── AgentService.java # Core logic, but platform-awareThe AgentService knew too much about each platform. Adding a new channel meant touching multiple files. Testing was a nightmare because I had to mock platform APIs everywhere.
My First Attempt: Copy-Paste Integrations
I started by copying the WebSocket handler and adapting it for Telegram. It worked, but problems appeared quickly:
1. Duplicated logic across handlers2. Bug fixes required changes in multiple files3. Each channel had slightly different behavior4. Testing meant mocking every platform APII realized I needed an abstraction layer. But I wasn’t sure how to design it.
The Channel Abstraction Pattern
The solution is to make channels implement a common interface. The core agent logic doesn’t know or care where messages come from.
+-------------+ +-------------+ +-------------+| Telegram |---->| Channel |---->| Agent || (plugin) | | Interface | | (core) |+-------------+ +-------------+ +-------------+ | ^ | |+-------------+ +-------------+| WebSocket | | REST || Chat UI | | Polling |+-------------+ +-------------+The agent receives a Message and returns a Response. It doesn’t know if that message came from Telegram, WebSocket, or a REST API. Each channel handles its own platform details.
How I Implemented It
The Channel Interface
I started with a simple interface that captures what all channels need:
public interface Channel { String getName(); void sendMessage(String userId, String message); void start(); void stop(); boolean isAvailable();}This interface handles the essentials:
- getName(): Identify the channel in logs and debugging
- sendMessage(): Send a response to a user
- start()/stop(): Lifecycle management
- isAvailable(): Health check for the channel
The Telegram Channel
For Telegram, I used the Telegrambots library with long-polling:
@Componentpublic class TelegramChannel implements Channel {
private final TelegramBot bot; private final AgentService agentService;
@Override public String getName() { return "telegram"; }
@Override public void sendMessage(String userId, String message) { SendMessage send = new SendMessage(userId, message); bot.execute(send); }
@Override public void start() { bot.setUpdatesListener(updates -> { updates.forEach(this::handleUpdate); return UpdatesListener.CONFIRMED_UPDATES_ALL; }); }
private void handleUpdate(Update update) { if (update.hasMessage() && update.getMessage().hasText()) { Message message = update.getMessage(); String userId = message.getChatId().toString(); String text = message.getText();
// Normalize to common message format ConversationMessage msg = new ConversationMessage(userId, text);
// Let agent process it String response = agentService.process(msg);
// Send response through this channel sendMessage(userId, response); } }}The key insight: Telegram-specific code stays in the Telegram channel. The AgentService receives a normalized ConversationMessage and returns a plain string.
Configuration is simple in application.yaml:
telegram: bot-token: ${TELEGRAM_BOT_TOKEN} allowed-username: ${TELEGRAM_ALLOWED_USERNAME}The WebSocket Chat Channel
For real-time web chat, I implemented a WebSocket channel with a REST fallback:
@Controllerpublic class ChatChannelController {
private final AgentService agentService; private final SimpMessagingTemplate messagingTemplate;
@MessageMapping("/chat") @SendTo("/topic/messages") public ChatMessage handleChat(ChatMessage message) { String response = agentService.process(message.getContent()); return new ChatMessage("assistant", response); }
// REST fallback for polling when WebSocket unavailable @GetMapping("/api/chat") public List<ChatMessage> getRecentMessages(@RequestParam String since) { return messageRepository.findAfter(Instant.parse(since)); }}The REST fallback was crucial. Some networks block WebSocket connections. Having a polling option meant the chat worked even in restrictive environments.
Access Points
After implementing both channels, users could access the assistant through:
Chat UI: http://localhost:8080/chatOnboarding: http://localhost:8080/onboardingTelegram: Search for your bot and start chattingJob Dashboard: http://localhost:8081Mistakes I Made Along the Way
Mistake 1: Leaking Platform Details into the Agent
At first, my agent service knew about Telegram’s Update objects and WebSocket’s ChatMessage objects. This defeated the whole purpose. The agent should only know about normalized messages.
public String process(Update telegramUpdate) { // Agent knows about Telegram internals String text = telegramUpdate.getMessage().getText(); // ...}public String process(ConversationMessage message) { // Agent knows nothing about source platform String text = message.getContent(); // ...}Mistake 2: No Fallback for WebSocket
I initially only implemented WebSocket for the chat channel. Then a user reported it didn’t work on their corporate network. WebSocket was blocked. I had to add REST polling as a fallback.
async function connectToChat() { try { // Try WebSocket first await connectWebSocket(); } catch (error) { console.log('WebSocket failed, falling back to REST polling'); startRestPolling(); }}
function startRestPolling() { setInterval(async () => { const messages = await fetch(`/api/chat?since=${lastMessageTime}`); // Process new messages }, 1000);}Mistake 3: Hardcoding Channel Priority
I hardcoded the logic for which channel to use first. This made testing difficult. Instead, I should have made channel priority configurable:
@Componentpublic class ChannelManager {
private final List<Channel> channels;
public ChannelManager(List<Channel> channels) { // Sort by configured priority this.channels = channels.stream() .sorted(Comparator.comparingInt(this::getPriority)) .toList(); }
private int getPriority(Channel channel) { return config.getPriority(channel.getName()); }}Mistake 4: Ignoring Rate Limits
Each platform has different rate limits. Telegram allows 30 messages per second. My WebSocket server had no limits. I learned this the hard way when my bot got temporarily banned from Telegram.
public abstract class AbstractChannel implements Channel {
private final RateLimiter rateLimiter;
protected AbstractChannel(int permitsPerSecond) { this.rateLimiter = RateLimiter.create(permitsPerSecond); }
@Override public void sendMessage(String userId, String message) { rateLimiter.acquire(); // Wait for permit doSend(userId, message); }
protected abstract void doSend(String userId, String message);}Benefits of the Channel Pattern
After refactoring to the channel pattern, I noticed significant improvements:
| Aspect | Before | After |
|---|---|---|
| Adding new channel | Rewrite agent logic | Implement interface |
| Testing | Mock platform APIs everywhere | Test against interface |
| Code duplication | Copy-paste handlers | Shared core logic |
| Platform behavior | Inconsistent across channels | Consistent behavior |
When This Pattern Works Best
The channel abstraction pattern is ideal when:
- You need to support multiple communication platforms
- You want consistent agent behavior across channels
- You need to add channels without touching core logic
- You want to test agent logic independently of platforms
It may be overkill when:
- You only need one platform (just integrate directly)
- Your agent logic is trivial (no complex message processing)
- You need platform-specific features that don’t normalize well
Summary
I solved the multi-channel problem by implementing a channel abstraction layer. Each platform (Telegram, WebSocket, REST) implements a common interface. The agent core knows nothing about platforms. It just receives normalized messages and returns responses.
The key changes I made:
- Created a
Channelinterface for all platforms to implement - Moved Telegram-specific code to
TelegramChannelwith long-polling - Built
ChatChannelwith WebSocket and REST fallback - Normalized all messages to
ConversationMessageformat - Added per-channel rate limiting
This approach reduced the code needed to add a new channel from “rewrite everything” to “implement an interface.” Testing became simpler because I could mock the channel interface instead of platform-specific APIs.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments