singularity-forge/packages/daemon/src/channel-manager.ts
ace-pm 6b0ac484ba refactor: update log prefixes and string values from gsd- to sf- namespace
Updates channel prefixes, log messages, comments, and configuration values
across daemon, mcp-server, and related packages to complete the rebrand from
gsd to sf-run naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:37:12 +02:00

223 lines
6.6 KiB
TypeScript

/**
* ChannelManager — manages per-project Discord text channels under a
* 'SF Projects' category, with archive support.
*
* Pure helper `sanitizeChannelName` exported separately for testability.
*/
import {
ChannelType,
PermissionFlagsBits,
type Guild,
type CategoryChannel,
type TextChannel,
type GuildBasedChannel,
} from 'discord.js';
import type { Logger } from './logger.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_CATEGORY_NAME = 'SF Projects';
const ARCHIVE_CATEGORY_NAME = 'SF Archive';
const CHANNEL_PREFIX = 'sf-';
const MAX_CHANNEL_NAME_LENGTH = 100; // Discord's limit
// ---------------------------------------------------------------------------
// Pure helpers — exported for testability
// ---------------------------------------------------------------------------
/**
* Sanitize a project directory path into a valid Discord channel name.
*
* - Takes the basename of the path
* - Lowercases
* - Replaces non-alphanumeric (except hyphens) with hyphens
* - Collapses consecutive hyphens
* - Trims leading/trailing hyphens
* - Prefixes with 'sf-'
* - Caps total length at 100 chars (Discord limit)
*
* Returns 'sf-unnamed' for empty/whitespace-only inputs.
*/
export function sanitizeChannelName(projectDir: string): string {
// Extract basename — handle both forward and back slashes
const parts = projectDir.replace(/\\/g, '/').split('/');
let basename = parts[parts.length - 1] ?? '';
// Trim whitespace
basename = basename.trim();
// Fallback for empty basename
if (!basename) {
return 'sf-unnamed';
}
// Lowercase
let name = basename.toLowerCase();
// Replace non-alphanumeric (except hyphens) with hyphens
name = name.replace(/[^a-z0-9-]/g, '-');
// Collapse consecutive hyphens
name = name.replace(/-{2,}/g, '-');
// Trim leading/trailing hyphens
name = name.replace(/^-+|-+$/g, '');
// Fallback if nothing remains after sanitization
if (!name) {
return 'sf-unnamed';
}
// Prefix
const prefixed = `${CHANNEL_PREFIX}${name}`;
// Cap at max length
if (prefixed.length > MAX_CHANNEL_NAME_LENGTH) {
// Truncate and remove any trailing hyphen from the cut
return prefixed.slice(0, MAX_CHANNEL_NAME_LENGTH).replace(/-+$/, '');
}
return prefixed;
}
// ---------------------------------------------------------------------------
// ChannelManager class
// ---------------------------------------------------------------------------
export interface ChannelManagerOptions {
guild: Guild;
logger: Logger;
categoryName?: string;
}
export class ChannelManager {
private readonly guild: Guild;
private readonly logger: Logger;
private readonly categoryName: string;
private categoryCache: CategoryChannel | null = null;
private archiveCategoryCache: CategoryChannel | null = null;
constructor(opts: ChannelManagerOptions) {
this.guild = opts.guild;
this.logger = opts.logger;
this.categoryName = opts.categoryName ?? DEFAULT_CATEGORY_NAME;
}
/**
* Find or create the project category channel.
* Caches the result — subsequent calls return the cached category.
*/
async resolveCategory(): Promise<CategoryChannel> {
if (this.categoryCache) {
return this.categoryCache;
}
const existing = this.findCategoryByName(this.categoryName);
if (existing) {
this.categoryCache = existing;
this.logger.debug('category resolved from cache', { name: this.categoryName, id: existing.id });
return existing;
}
// Create the category
const created = await this.guild.channels.create({
name: this.categoryName,
type: ChannelType.GuildCategory,
});
this.categoryCache = created as CategoryChannel;
this.logger.info('category created', { name: this.categoryName, id: created.id });
return this.categoryCache;
}
/**
* Create a text channel for a project under the SF Projects category.
* Channel name is derived from the project directory path.
*/
async createProjectChannel(projectDir: string): Promise<TextChannel> {
const name = sanitizeChannelName(projectDir);
const category = await this.resolveCategory();
const channel = await this.guild.channels.create({
name,
type: ChannelType.GuildText,
parent: category.id,
});
this.logger.info('project channel created', {
name,
channelId: channel.id,
categoryId: category.id,
projectDir,
});
return channel as TextChannel;
}
/**
* Archive a channel by moving it to the 'SF Archive' category and
* setting permission overwrite to deny ViewChannel for @everyone.
*/
async archiveChannel(channelId: string): Promise<void> {
const archive = await this.resolveArchiveCategory();
const channel = this.guild.channels.cache.get(channelId);
if (!channel) {
this.logger.warn('archive target not found', { channelId });
return;
}
if (!('edit' in channel) || typeof channel.edit !== 'function') {
this.logger.warn('archive target is not editable', { channelId, type: channel.type });
return;
}
await channel.edit({
parent: archive.id,
permissionOverwrites: [
{
id: this.guild.id, // @everyone role ID matches guild ID
deny: [PermissionFlagsBits.ViewChannel],
},
],
});
this.logger.info('channel archived', { channelId, archiveCategoryId: archive.id });
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private findCategoryByName(name: string): CategoryChannel | null {
const match = this.guild.channels.cache.find(
(ch: GuildBasedChannel) => ch.type === ChannelType.GuildCategory && ch.name === name,
);
return (match as CategoryChannel) ?? null;
}
private async resolveArchiveCategory(): Promise<CategoryChannel> {
if (this.archiveCategoryCache) {
return this.archiveCategoryCache;
}
const existing = this.findCategoryByName(ARCHIVE_CATEGORY_NAME);
if (existing) {
this.archiveCategoryCache = existing;
return existing;
}
const created = await this.guild.channels.create({
name: ARCHIVE_CATEGORY_NAME,
type: ChannelType.GuildCategory,
});
this.archiveCategoryCache = created as CategoryChannel;
this.logger.info('archive category created', { name: ARCHIVE_CATEGORY_NAME, id: created.id });
return this.archiveCategoryCache;
}
}