import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; import { io, type Socket } from 'socket.io-client'; export interface DiscordPluginConfig { token: string; gatewayUrl: string; /** Which guild to bind to (single-guild only for v0.1.0) */ guildId?: string; } export class DiscordPlugin { private client: Client; private socket: Socket | null = null; private config: DiscordPluginConfig; /** Map Discord channel ID → Mosaic conversation ID */ private channelConversations = new Map(); /** Track in-flight responses to avoid duplicate streaming */ private pendingResponses = new Map(); constructor(config: DiscordPluginConfig) { this.config = config; this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], }); } async start(): Promise { // Connect to gateway WebSocket this.socket = io(`${this.config.gatewayUrl}/chat`, { transports: ['websocket'], }); this.socket.on('connect', () => { console.log('[discord] Connected to gateway'); }); this.socket.on('disconnect', (reason: string) => { console.error(`[discord] Disconnected from gateway: ${reason}`); this.pendingResponses.clear(); }); this.socket.on('connect_error', (err: Error) => { console.error(`[discord] 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.sendToDiscord(data.conversationId, text).catch((err) => { console.error(`[discord] Error sending response for ${data.conversationId}:`, err); }); } }); this.socket.on('agent:start', (data: { conversationId: string }) => { this.pendingResponses.set(data.conversationId, ''); }); // Set up Discord message handler this.client.on('messageCreate', (message) => this.handleDiscordMessage(message)); this.client.on('ready', () => { console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); }); await this.client.login(this.config.token); } async stop(): Promise { this.socket?.disconnect(); await this.client.destroy(); } async createProjectChannel(project: { id: string; name: string; description?: string; }): Promise<{ channelId: string } | null> { if (!this.config.guildId) return null; const guild = this.client.guilds.cache.get(this.config.guildId); if (!guild) return null; // Slugify project name for channel: lowercase, replace spaces/special chars with hyphens const channelName = `mosaic-${project.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '')}`; const channel = await guild.channels.create({ name: channelName, type: ChannelType.GuildText, topic: project.description ?? `Mosaic project: ${project.name}`, }); // Register the channel mapping so messages route correctly this.channelConversations.set(channel.id, `discord-${channel.id}`); return { channelId: channel.id }; } private handleDiscordMessage(message: DiscordMessage): void { // Ignore bot messages if (message.author.bot) return; // Not ready yet if (!this.client.user) return; // Check guild binding if (this.config.guildId && message.guildId !== this.config.guildId) return; // Respond to DMs always, or mentions in channels const isDM = !message.guildId; const isMention = message.mentions.has(this.client.user); if (!isDM && !isMention) return; // Strip bot mention from message content const content = message.content .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') .trim(); if (!content) return; // Get or create conversation for this Discord channel const channelId = message.channelId; let conversationId = this.channelConversations.get(channelId); if (!conversationId) { conversationId = `discord-${channelId}`; this.channelConversations.set(channelId, conversationId); } // Send to gateway if (!this.socket?.connected) { console.error( `[discord] Cannot forward message: not connected to gateway. channel=${channelId}`, ); return; } this.socket.emit('message', { conversationId, content, }); } private async sendToDiscord(conversationId: string, text: string): Promise { // Find the Discord channel for this conversation const channelId = Array.from(this.channelConversations.entries()).find( ([, convId]) => convId === conversationId, )?.[0]; if (!channelId) { console.error(`[discord] No channel found for conversation ${conversationId}`); return; } const channel = this.client.channels.cache.get(channelId); if (!channel || !('send' in channel)) { console.error( `[discord] Channel ${channelId} not sendable for conversation ${conversationId}`, ); return; } // Chunk responses for Discord's 2000-char limit const chunks = this.chunkText(text, 1900); for (const chunk of chunks) { try { await (channel as { send: (content: string) => Promise }).send(chunk); } catch (err) { console.error(`[discord] Failed to send message to channel ${channelId}:`, 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; } }