import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; 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; /** Shared service credential injected by the approved secret mechanism. */ serviceToken: string; /** Which guild to bind to (single-guild only for v0.1.0). */ guildId?: string; allowedGuildIds: readonly string[]; allowedChannelIds: readonly string[]; allowedUserIds: readonly string[]; } export interface DiscordIngressPayload { correlationId: string; messageId: string; guildId: string; channelId: string; userId: string; conversationId: string; content: string; } export interface DiscordIngressEnvelope { payload: DiscordIngressPayload; signature: string; } export interface DiscordIngressAllowlists { guildIds: readonly string[]; channelIds: readonly string[]; userIds: readonly string[]; } function signedPayload(payload: DiscordIngressPayload): string { return [ payload.correlationId, payload.messageId, payload.guildId, payload.channelId, payload.userId, payload.conversationId, payload.content, ].join('\n'); } function signPayload(payload: DiscordIngressPayload, serviceToken: string): string { return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex'); } function isSignatureValid(actual: string, expected: string): boolean { const actualBuffer = Buffer.from(actual, 'hex'); const expectedBuffer = Buffer.from(expected, 'hex'); return ( actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer) ); } function includesId(allowedIds: readonly string[], id: string): boolean { return allowedIds.includes(id); } /** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */ export function createDiscordIngressEnvelope( payload: DiscordIngressPayload, serviceToken: string, ): DiscordIngressEnvelope { return { payload, signature: signPayload(payload, serviceToken) }; } /** * Verifies service-origin integrity and applies default-deny Discord identity allowlists. * Returns null instead of a partially trusted payload on every failure path. */ export function verifyDiscordIngressEnvelope( envelope: DiscordIngressEnvelope, serviceToken: string, allowlists?: DiscordIngressAllowlists, ): DiscordIngressPayload | null { const expectedSignature = signPayload(envelope.payload, serviceToken); if (!isSignatureValid(envelope.signature, expectedSignature)) return null; if ( allowlists && (!includesId(allowlists.guildIds, envelope.payload.guildId) || !includesId(allowlists.channelIds, envelope.payload.channelId) || !includesId(allowlists.userIds, envelope.payload.userId)) ) { return null; } return envelope.payload; } export class DiscordPlugin { private client: Client; private socket: Socket | null = null; /** Map Discord channel ID → Mosaic conversation ID. */ private channelConversations = new Map(); /** Track in-flight responses to avoid duplicate streaming. */ private pendingResponses = new Map(); constructor(private readonly config: DiscordPluginConfig) { this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], }); } async start(): Promise { this.socket = io(`${this.config.gatewayUrl}/chat`, { auth: { discordServiceToken: this.config.serviceToken }, 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}`); }); 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); } }); 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: unknown) => { console.error(`[discord] Error sending response for ${data.conversationId}:`, err); }); } }); this.socket.on('agent:start', (data: { conversationId: string }) => { this.pendingResponses.set(data.conversationId, ''); }); this.client.on('messageCreate', (message: DiscordMessage) => 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; 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}`, }); this.channelConversations.set(channel.id, `discord-${channel.id}`); return { channelId: channel.id }; } private handleDiscordMessage(message: DiscordMessage): void { if (message.author.bot || !this.client.user) return; if (!message.guildId || !this.isAllowedMessage(message)) return; const isMention = message.mentions.has(this.client.user); if (!isMention) return; const content = message.content .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') .trim(); if (!content) return; if (!this.socket?.connected) { console.error( `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`, ); return; } const channelId = message.channelId; const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`; this.channelConversations.set(channelId, conversationId); const envelope = createDiscordIngressEnvelope( { correlationId: randomUUID(), messageId: message.id, guildId: message.guildId, channelId, userId: message.author.id, conversationId, content, }, this.config.serviceToken, ); this.socket.emit('message', envelope); } private isAllowedMessage(message: DiscordMessage): boolean { const guildId = message.guildId; return ( guildId !== null && includesId(this.config.allowedGuildIds, guildId) && includesId(this.config.allowedChannelIds, message.channelId) && includesId(this.config.allowedUserIds, message.author.id) ); } private async sendToDiscord(conversationId: string, text: string): Promise { 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; } for (const chunk of this.chunkText(text, 1900)) { try { await (channel as { send: (content: string) => Promise }).send(chunk); } catch (err: unknown) { 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; } let breakPoint = remaining.lastIndexOf('\n', maxLength); if (breakPoint <= 0) breakPoint = maxLength; chunks.push(remaining.slice(0, breakPoint)); remaining = remaining.slice(breakPoint).trimStart(); } return chunks; } }