Files
stack/plugins/discord/src/index.ts
Jason Woltje 7a52652be6
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(gateway): Discord channel auto-creation on project bootstrap (#200)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-17 02:32:14 +00:00

214 lines
6.5 KiB
TypeScript

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<string, string>();
/** Track in-flight responses to avoid duplicate streaming */
private pendingResponses = new Map<string, string>();
constructor(config: DiscordPluginConfig) {
this.config = config;
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
}
async start(): Promise<void> {
// 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<void> {
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<void> {
// 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<unknown> }).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;
}
}