Skip to content

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.

What my codebase looked like
src/
├── TelegramHandler.java # Telegram-specific logic
├── WebSocketHandler.java # WebSocket-specific logic
├── SlackHandler.java # Slack-specific logic
└── AgentService.java # Core logic, but platform-aware

The 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:

Problems with copy-paste approach
1. Duplicated logic across handlers
2. Bug fixes required changes in multiple files
3. Each channel had slightly different behavior
4. Testing meant mocking every platform API

I 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:

Channel.java
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:

TelegramChannel.java
@Component
public 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:

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:

ChatChannelController.java
@Controller
public 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:

Channel access points
Chat UI: http://localhost:8080/chat
Onboarding: http://localhost:8080/onboarding
Telegram: Search for your bot and start chatting
Job Dashboard: http://localhost:8081

Mistakes 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.

Wrong: Platform-aware agent (DON'T DO THIS)
public String process(Update telegramUpdate) {
// Agent knows about Telegram internals
String text = telegramUpdate.getMessage().getText();
// ...
}
Correct: Platform-agnostic agent
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.

Frontend fallback logic
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:

ChannelManager.java
@Component
public 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.

Rate limiting per channel
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:

AspectBeforeAfter
Adding new channelRewrite agent logicImplement interface
TestingMock platform APIs everywhereTest against interface
Code duplicationCopy-paste handlersShared core logic
Platform behaviorInconsistent across channelsConsistent 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:

  1. Created a Channel interface for all platforms to implement
  2. Moved Telegram-specific code to TelegramChannel with long-polling
  3. Built ChatChannel with WebSocket and REST fallback
  4. Normalized all messages to ConversationMessage format
  5. 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