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[]; /** Provisioned interaction bindings; instance identity is configuration, never code. */ interactionBindings?: readonly DiscordInteractionBinding[]; } export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop'; export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin'; export interface DiscordInteractionBinding { instanceId: string; guildId: string; channelId: string; /** Pairing roster keyed by Discord user ID. */ pairedUsers: Readonly>; } const operationRoles: Readonly< Record > = { bind: ['admin'], attach: ['operator', 'admin'], send: ['operator', 'admin'], approve: ['admin'], stop: ['admin'], }; /** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */ export function resolveDiscordInteractionBinding( bindings: readonly DiscordInteractionBinding[], guildId: string, channelId: string, userId: string, operation: DiscordInteractionOperation, ): DiscordInteractionBinding | null { const binding = bindings.find( (candidate) => candidate.guildId === guildId && candidate.channelId === channelId, ); if (!binding) return null; const role = binding.pairedUsers[userId]; return role && operationRoles[operation].includes(role) ? binding : null; } /** Parses provisioned binding roster JSON and rejects malformed or empty data. */ export function parseDiscordInteractionBindings( value: string | undefined, ): DiscordInteractionBinding[] { if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled'); const parsed: unknown = JSON.parse(value); if (!Array.isArray(parsed) || parsed.length === 0) { throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array'); } return parsed.map((binding: unknown): DiscordInteractionBinding => { if (typeof binding !== 'object' || binding === null) throw new Error('Invalid Discord interaction binding'); const candidate = binding as Partial; if ( !candidate.instanceId || !candidate.guildId || !candidate.channelId || !candidate.pairedUsers || typeof candidate.pairedUsers !== 'object' ) { throw new Error('Invalid Discord interaction binding'); } return { instanceId: candidate.instanceId, guildId: candidate.guildId, channelId: candidate.channelId, pairedUsers: candidate.pairedUsers, }; }); } export interface DiscordIngressPayload { correlationId: string; messageId: string; guildId: string; channelId: string; userId: string; conversationId: string; content: string; threadId?: string; attachments?: readonly DiscordAttachment[]; } export interface DiscordAttachment { id: string; name: string; url: string; contentType: string | null; } 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, payload.threadId ?? '', JSON.stringify(payload.attachments ?? []), ].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 parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; const bindingChannelId = parentChannelId ?? channelId; const binding = resolveDiscordInteractionBinding( this.config.interactionBindings ?? [], message.guildId, bindingChannelId, message.author.id, 'send', ); if (!binding) return; const conversationId = this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`; this.channelConversations.set(channelId, conversationId); const envelope = createDiscordIngressEnvelope( { correlationId: randomUUID(), messageId: message.id, guildId: message.guildId, channelId: bindingChannelId, userId: message.author.id, conversationId, content, threadId: parentChannelId ? channelId : undefined, attachments: Array.from(message.attachments.values()).map((attachment) => ({ id: attachment.id, name: attachment.name, url: attachment.url, contentType: attachment.contentType, })), }, this.config.serviceToken, ); this.socket.emit( content.startsWith('/approve ') ? 'discord:approve' : content.startsWith('/stop ') ? 'discord:stop' : 'message', envelope, ); } private isAllowedMessage(message: DiscordMessage): boolean { const guildId = message.guildId; const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; // Threads inherit their authorization boundary from their configured parent. const authorizationChannelId = parentChannelId ?? message.channelId; return ( guildId !== null && includesId(this.config.allowedGuildIds, guildId) && includesId(this.config.allowedChannelIds, authorizationChannelId) && 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; } }