feat(gateway): Discord channel auto-creation on project bootstrap (#200)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #200.
This commit is contained in:
2026-03-17 02:32:14 +00:00
committed by jason.woltje
parent 791c8f505e
commit 7a52652be6
4 changed files with 67 additions and 1 deletions

View File

@@ -2,4 +2,10 @@ export interface IChannelPlugin {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
/** 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>;
}

View File

@@ -24,6 +24,14 @@ class DiscordChannelPluginAdapter implements IChannelPlugin {
async stop(): Promise<void> {
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 {

View File

@@ -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 };

View File

@@ -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;