From 7a52652be6349f132c2b532e29c573bcdba5049e Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 17 Mar 2026 02:32:14 +0000 Subject: [PATCH] feat(gateway): Discord channel auto-creation on project bootstrap (#200) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- apps/gateway/src/plugin/plugin.interface.ts | 6 ++++ apps/gateway/src/plugin/plugin.module.ts | 8 +++++ .../workspace/project-bootstrap.service.ts | 24 +++++++++++++++ plugins/discord/src/index.ts | 30 ++++++++++++++++++- 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/plugin/plugin.interface.ts b/apps/gateway/src/plugin/plugin.interface.ts index 80860d2..463c478 100644 --- a/apps/gateway/src/plugin/plugin.interface.ts +++ b/apps/gateway/src/plugin/plugin.interface.ts @@ -2,4 +2,10 @@ export interface IChannelPlugin { readonly name: string; start(): Promise; stop(): Promise; + /** Called when a new project is bootstrapped. Return channelId if a channel was created. */ + onProjectCreated?(project: { + id: string; + name: string; + description?: string; + }): Promise<{ channelId: string } | null>; } diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index f1bae19..2355f6d 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -24,6 +24,14 @@ class DiscordChannelPluginAdapter implements IChannelPlugin { async stop(): Promise { await this.plugin.stop(); } + + async onProjectCreated(project: { + id: string; + name: string; + description?: string; + }): Promise<{ channelId: string } | null> { + return this.plugin.createProjectChannel(project); + } } class TelegramChannelPluginAdapter implements IChannelPlugin { diff --git a/apps/gateway/src/workspace/project-bootstrap.service.ts b/apps/gateway/src/workspace/project-bootstrap.service.ts index 5be60d3..c803239 100644 --- a/apps/gateway/src/workspace/project-bootstrap.service.ts +++ b/apps/gateway/src/workspace/project-bootstrap.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import type { Brain } from '@mosaic/brain'; import { BRAIN } from '../brain/brain.tokens.js'; +import { PluginService } from '../plugin/plugin.service.js'; import { WorkspaceService } from './workspace.service.js'; export interface BootstrapProjectParams { @@ -23,6 +24,7 @@ export class ProjectBootstrapService { constructor( @Inject(BRAIN) private readonly brain: Brain, private readonly workspace: WorkspaceService, + private readonly pluginService: PluginService, ) {} /** @@ -67,6 +69,28 @@ export class ProjectBootstrapService { status: 'active', }); + // 4. Notify plugins so they can set up project-specific resources (e.g. Discord channel) + try { + for (const plugin of this.pluginService.getPlugins()) { + if (plugin.onProjectCreated) { + const result = await plugin.onProjectCreated({ + id: project.id, + name: params.name, + description: params.description, + }); + if (result?.channelId) { + await this.brain.projects.update(project.id, { + metadata: { discordChannelId: result.channelId }, + }); + } + } + } + } catch (err) { + this.logger.warn( + `Plugin project notification failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + this.logger.log(`Project ${project.id} bootstrapped at ${workspacePath}`); return { projectId: project.id, workspacePath }; diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index 7407045..72fb27f 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -1,4 +1,4 @@ -import { Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; +import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; import { io, type Socket } from 'socket.io-client'; export interface DiscordPluginConfig { @@ -86,6 +86,34 @@ export class DiscordPlugin { 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;