feat(plugins): add Telegram channel plugin
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
2026-03-13 12:05:42 -05:00
parent 9eb48e1d9b
commit 96902bab44
5 changed files with 383 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
# @mosaic/telegram-plugin
`@mosaic/telegram-plugin` connects a Telegram bot to the Mosaic gateway chat namespace so Telegram chats can participate in the same conversation flow as the web, TUI, and Discord channels.
## Required Environment Variables
- `TELEGRAM_BOT_TOKEN`: Bot token issued by BotFather
- `TELEGRAM_GATEWAY_URL`: Base URL for the Mosaic gateway, for example `http://localhost:3000`
## What It Does
- Launches a Telegram bot with `telegraf`
- Connects to `${TELEGRAM_GATEWAY_URL}/chat` with `socket.io-client`
- Maps Telegram `chat.id` values to Mosaic `conversationId` values
- Forwards inbound Telegram text messages to the gateway as user messages
- Buffers `agent:start` / `agent:text` / `agent:end` socket events and sends the completed response back to the Telegram chat
## Getting a Bot Token
1. Open Telegram and start a chat with `@BotFather`
2. Run `/newbot`
3. Follow the prompts to name the bot and choose a username
4. Copy the generated token and assign it to `TELEGRAM_BOT_TOKEN`

View File

@@ -18,5 +18,9 @@
"devDependencies": {
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"dependencies": {
"socket.io-client": "^4.8.0",
"telegraf": "^4.16.3"
}
}

View File

@@ -1 +1,187 @@
export const VERSION = '0.0.0';
import { Telegraf } from 'telegraf';
import { io, type Socket } from 'socket.io-client';
interface TelegramPluginConfig {
token: string;
gatewayUrl: string;
}
interface TelegramUser {
is_bot?: boolean;
}
interface TelegramChat {
id: number;
}
interface TelegramTextMessage {
chat: TelegramChat;
from?: TelegramUser;
text: string;
}
class TelegramPlugin {
readonly name = 'telegram';
private bot: Telegraf;
private socket: Socket | null = null;
private config: TelegramPluginConfig;
/** Map Telegram chat ID → Mosaic conversation ID */
private chatConversations = new Map<string, string>();
/** Track in-flight responses to avoid duplicate streaming */
private pendingResponses = new Map<string, string>();
constructor(config: TelegramPluginConfig) {
this.config = config;
this.bot = new Telegraf(this.config.token);
}
async start(): Promise<void> {
// Connect to gateway WebSocket
this.socket = io(`${this.config.gatewayUrl}/chat`, {
transports: ['websocket'],
});
this.socket.on('connect', () => {
console.log('[telegram] Connected to gateway');
});
this.socket.on('disconnect', (reason: string) => {
console.error(`[telegram] Disconnected from gateway: ${reason}`);
this.pendingResponses.clear();
});
this.socket.on('connect_error', (err: Error) => {
console.error(`[telegram] Gateway connection error: ${err.message}`);
});
// Handle streaming text from gateway
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
const pending = this.pendingResponses.get(data.conversationId);
if (pending !== undefined) {
this.pendingResponses.set(data.conversationId, pending + data.text);
}
});
// When agent finishes, send the accumulated response
this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId);
if (text) {
this.pendingResponses.delete(data.conversationId);
this.sendToTelegram(data.conversationId, text).catch((err) => {
console.error(`[telegram] Error sending response for ${data.conversationId}:`, err);
});
}
});
this.socket.on('agent:start', (data: { conversationId: string }) => {
this.pendingResponses.set(data.conversationId, '');
});
// Set up Telegram message handler
this.bot.on('message', (ctx) => {
const message = this.getTextMessage(ctx.message);
if (message) {
this.handleTelegramMessage(message);
}
});
await this.bot.launch();
}
async stop(): Promise<void> {
this.bot.stop('SIGTERM');
this.socket?.disconnect();
}
private handleTelegramMessage(message: TelegramTextMessage): void {
// Ignore bot messages
if (message.from?.is_bot) return;
const content = message.text.trim();
if (!content) return;
// Get or create conversation for this Telegram chat
const chatId = String(message.chat.id);
let conversationId = this.chatConversations.get(chatId);
if (!conversationId) {
conversationId = `telegram-${chatId}`;
this.chatConversations.set(chatId, conversationId);
}
// Send to gateway
if (!this.socket?.connected) {
console.error(`[telegram] Cannot forward message: not connected to gateway. chat=${chatId}`);
return;
}
this.socket.emit('message', {
conversationId,
content,
role: 'user',
});
}
private getTextMessage(message: unknown): TelegramTextMessage | null {
if (!message || typeof message !== 'object') return null;
const candidate = message as Partial<TelegramTextMessage>;
if (typeof candidate.text !== 'string') return null;
if (!candidate.chat || typeof candidate.chat.id !== 'number') return null;
return {
chat: candidate.chat,
from: candidate.from,
text: candidate.text,
};
}
private async sendToTelegram(conversationId: string, text: string): Promise<void> {
// Find the Telegram chat for this conversation
const chatId = Array.from(this.chatConversations.entries()).find(
([, convId]) => convId === conversationId,
)?.[0];
if (!chatId) {
console.error(`[telegram] No chat found for conversation ${conversationId}`);
return;
}
// Chunk responses for Telegram's 4096-char limit
const chunks = this.chunkText(text, 4000);
for (const chunk of chunks) {
try {
await this.bot.telegram.sendMessage(chatId, chunk);
} catch (err) {
console.error(`[telegram] Failed to send message to chat ${chatId}:`, err);
}
}
}
private chunkText(text: string, maxLength: number): string[] {
if (text.length <= maxLength) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
// Try to break at a newline
let breakPoint = remaining.lastIndexOf('\n', maxLength);
if (breakPoint <= 0) breakPoint = maxLength;
chunks.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trimStart();
}
return chunks;
}
}
export { TelegramPlugin };
export type { TelegramPluginConfig };
export const VERSION = '0.0.5';