diff --git a/README.md b/README.md index cc9973ee..94b50b7a 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,11 @@ OmegaBot is a modular Discord bot designed to support development projects with ## Current features -- Modular slash-command system (auto-loaded from dist/commands) +- Modular slash-command system (auto-loaded from `dist/commands`) - Centralized interaction routing with safe error handling - Structured logging (pino) - Welcome / onboarding system triggered on member join (`guildMemberAdd`) +- Per-guild configuration system backed by persistent storage ### Core commands @@ -151,13 +152,13 @@ OmegaBot is online │ │ └── OmegaBot.yml │ └── pull_request_template.md ├── .husky -│ ├── pre-commit │ └── pre-push ├── assets │ ├── banner.png │ └── omegabot.png ├── data │ ├── faqs.json +│ ├── guild-config.json │ ├── last-seen.json │ └── timezones.json ├── docs @@ -173,6 +174,8 @@ OmegaBot is online │ ├── commands │ │ ├── changelog │ │ │ └── changelog.ts +│ │ ├── config +│ │ │ └── config.ts │ │ ├── faq │ │ │ ├── subcommands │ │ │ │ ├── add.ts @@ -204,6 +207,10 @@ OmegaBot is online │ ├── config │ │ └── env.ts │ ├── services +│ │ ├── config +│ │ │ ├── guildConfigStore.ts +│ │ │ ├── index.ts +│ │ │ └── types.ts │ │ ├── discord │ │ │ ├── commandLoader.ts │ │ │ ├── fetchChannelMessages.ts diff --git a/src/commands/config/config.ts b/src/commands/config/config.ts new file mode 100644 index 00000000..f65023e9 --- /dev/null +++ b/src/commands/config/config.ts @@ -0,0 +1,124 @@ +// src/commands/config/config.ts + +import { + SlashCommandBuilder, + PermissionFlagsBits, + ChannelType, + type ChatInputCommandInteraction, +} from "discord.js"; +import { + setGuildConfig, + getGuildConfig, +} from "../../services/config/guildConfigStore.js"; +import { logger } from "../../utils/logger.js"; + +/** + * Guild configuration command. + * + * This is intentionally small and focused: + * - Set/clear the welcome channel for onboarding messages + * + * Notes: + * - Requires "Manage Server" to prevent random users changing guild config + * - Only allows guild text channels (not DMs, not threads) + */ +export const data = new SlashCommandBuilder() + .setName("config") + .setDescription("Configure OmegaBot settings for this server") + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommandGroup((group) => + group + .setName("welcome-channel") + .setDescription("Configure where welcome messages are posted") + .addSubcommand((sub) => + sub + .setName("set") + .setDescription("Set the channel used for welcome messages") + .addChannelOption((opt) => + opt + .setName("channel") + .setDescription("Channel to post welcome messages in") + .setRequired(true) + // Only allow normal guild text channels. + .addChannelTypes(ChannelType.GuildText), + ), + ) + .addSubcommand((sub) => + sub.setName("clear").setDescription("Clear the configured welcome channel"), + ), + ); + +export async function execute(interaction: ChatInputCommandInteraction) { + // Only valid inside a guild. + if (!interaction.guildId) { + await interaction.reply({ + content: "This command can only be used in a server (not in DMs).", + ephemeral: true, + }); + return; + } + + const group = interaction.options.getSubcommandGroup(); + const sub = interaction.options.getSubcommand(); + + if (group !== "welcome-channel") { + await interaction.reply({ content: "Unknown config group.", ephemeral: true }); + return; + } + + if (sub === "set") { + const channel = interaction.options.getChannel("channel", true); + + // Extra safety: ensure it’s a guild text channel. + if (channel.type !== ChannelType.GuildText) { + await interaction.reply({ + content: "Please choose a normal text channel (not a thread or DM).", + ephemeral: true, + }); + return; + } + + const updated = setGuildConfig(interaction.guildId, { + welcomeChannelId: channel.id, + welcomeEnabled: true, + }); + + logger.info( + { guildId: interaction.guildId, welcomeChannelId: channel.id }, + "Updated guild welcome channel", + ); + + await interaction.reply({ + content: `✅ Welcome messages will be posted in <#${updated.welcomeChannelId}>.`, + ephemeral: true, + }); + return; + } + + if (sub === "clear") { + const current = getGuildConfig(interaction.guildId); + + // If nothing is set, still respond clearly. + if (!current.welcomeChannelId) { + await interaction.reply({ + content: + "Welcome channel is already not set. I will use the system channel or first text channel as a fallback.", + ephemeral: true, + }); + return; + } + + // Clear configured channel and fall back to system/first text channel. + setGuildConfig(interaction.guildId, { + welcomeChannelId: null, + }); + + logger.info({ guildId: interaction.guildId }, "Cleared guild welcome channel"); + + await interaction.reply({ + content: + "✅ Cleared the welcome channel. I will use the system channel or first text channel as a fallback.", + ephemeral: true, + }); + } +} diff --git a/src/services/config/guildConfigStore.ts b/src/services/config/guildConfigStore.ts new file mode 100644 index 00000000..f8371327 --- /dev/null +++ b/src/services/config/guildConfigStore.ts @@ -0,0 +1,83 @@ +// src/services/config/guildConfigStore.ts + +import fs from "fs"; +import path from "path"; +import { + DEFAULT_GUILD_CONFIG, + type GuildConfig, + type GuildConfigPatch, +} from "./types.js"; + +/** + * Simple JSON-backed store for guild configuration. + * + * Design goals: + * - Extremely easy to run locally + * - No external dependencies (DB) + * - Durable across restarts + * + * Notes: + * - This implementation reads/writes the file per call. + * That’s fine for small bots. If you scale, add caching + periodic flush. + */ + +const DATA_DIR = path.join(process.cwd(), "data"); +const FILE_PATH = path.join(DATA_DIR, "guild-config.json"); + +/** + * Stored JSON shape: + * { + * "": { welcomeEnabled: true, welcomeChannelId: "..." }, + * "": { ... } + * } + */ +type StoreShape = Record>; + +function ensureDataDir(): void { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } +} + +function readStore(): StoreShape { + try { + const raw = fs.readFileSync(FILE_PATH, "utf8"); + const parsed = JSON.parse(raw) as StoreShape; + return parsed ?? {}; + } catch { + // Missing file, invalid JSON, etc. + return {}; + } +} + +function writeStore(store: StoreShape): void { + ensureDataDir(); + fs.writeFileSync(FILE_PATH, JSON.stringify(store, null, 2), "utf8"); +} + +/** + * Get the effective config for a guild. + * Always returns a fully-populated config object (defaults applied). + */ +export function getGuildConfig(guildId: string): GuildConfig { + const store = readStore(); + const saved = store[guildId] ?? {}; + return { guildId, ...DEFAULT_GUILD_CONFIG, ...saved }; +} + +/** + * Update the stored config for a guild by patching values. + * Returns the updated effective config (defaults applied). + */ +export function setGuildConfig(guildId: string, patch: GuildConfigPatch): GuildConfig { + const store = readStore(); + + // Apply defaults for new guilds, then patch on top. + const current = store[guildId] ?? DEFAULT_GUILD_CONFIG; + const next: Omit = { ...current, ...patch }; + + store[guildId] = next; + writeStore(store); + + return { guildId, ...next }; +} diff --git a/src/services/config/index.ts b/src/services/config/index.ts new file mode 100644 index 00000000..7df69e30 --- /dev/null +++ b/src/services/config/index.ts @@ -0,0 +1,14 @@ +// src/services/config/index.ts + +/** + * Public API barrel for config services. + * + * Keeps imports cleaner: + * import { getGuildConfig } from "../config/index.js"; + * instead of: + * import { getGuildConfig } from "../config/guildConfigStore.js"; + */ + +export { getGuildConfig, setGuildConfig } from "./guildConfigStore.js"; +export { DEFAULT_GUILD_CONFIG } from "./types.js"; +export type { GuildConfig, GuildConfigPatch } from "./types.js"; diff --git a/src/services/config/types.ts b/src/services/config/types.ts new file mode 100644 index 00000000..7c3ca528 --- /dev/null +++ b/src/services/config/types.ts @@ -0,0 +1,43 @@ +// src/services/config/types.ts + +/** + * GuildConfig + * + * Per-guild configuration for OmegaBot. + * This is intentionally small and focused so we can expand safely over time. + */ +export type GuildConfig = { + /** Discord guild (server) id */ + guildId: string; + + /* ------------------------------------------------------------------ */ + /* Welcome / onboarding */ + /* ------------------------------------------------------------------ */ + + /** + * Master enable/disable for welcome messages in this guild. + * Defaults to true. + */ + welcomeEnabled: boolean; + + /** + * Preferred channel for welcome messages. + * If null, we fall back to system channel, then first text channel. + */ + welcomeChannelId: string | null; +}; + +/** + * Patch type used to update config values without requiring the full object. + * guildId is excluded intentionally (immutable key). + */ +export type GuildConfigPatch = Partial>; + +/** + * Default config for new guilds. + * Applied automatically when no saved config exists. + */ +export const DEFAULT_GUILD_CONFIG: Omit = { + welcomeEnabled: true, + welcomeChannelId: null, +}; diff --git a/src/services/welcome/welcomeHandler.ts b/src/services/welcome/welcomeHandler.ts index f7752063..03464a7e 100644 --- a/src/services/welcome/welcomeHandler.ts +++ b/src/services/welcome/welcomeHandler.ts @@ -1,58 +1,52 @@ // src/services/welcome/welcomeHandler.ts -import type { - Guild, - GuildMember, - TextChannel, - NewsChannel, - ThreadChannel, -} from "discord.js"; +import type { Guild, GuildMember, TextBasedChannel } from "discord.js"; import { logger } from "../../utils/logger.js"; import { buildWelcomeMessage } from "./welcomeMessage.js"; +import { getGuildConfig } from "../config/index.js"; /** - * Channels we can safely call `.send()` on in a guild context. + * Type guard to ensure a text-based channel supports `.send()`. * - * Note: - * `TextBasedChannel` is broader and can include DM-ish channel types where - * `.send()` is not guaranteed in the type system. + * This avoids `any` and satisfies ESLint while keeping runtime safety. */ -type SendableGuildChannel = TextChannel | NewsChannel | ThreadChannel; +function isSendableChannel( + channel: TextBasedChannel, +): channel is TextBasedChannel & { send: (content: unknown) => Promise } { + return typeof (channel as { send?: unknown }).send === "function"; +} /** * Resolve the best channel to post onboarding messages. * * Order: - * 1) WELCOME_CHANNEL_ID (if set) - * 2) Guild system channel (if set) - * 3) First writable guild text channel we can find + * 1) Guild config (welcomeEnabled / welcomeChannelId) + * 2) Guild system channel + * 3) First available text-based channel */ -async function resolveWelcomeChannel(guild: Guild): Promise { - const envChannelId = process.env.WELCOME_CHANNEL_ID; +async function resolveWelcomeChannel(guild: Guild): Promise { + const cfg = getGuildConfig(guild.id); - if (envChannelId) { - const ch = await guild.channels.fetch(envChannelId).catch(() => null); + // Allow per-guild disabling of welcome messages. + if (!cfg.welcomeEnabled) return null; - if (ch && ch.isTextBased() && "send" in ch) { - return ch as SendableGuildChannel; - } + // Prefer configured welcome channel. + if (cfg.welcomeChannelId) { + const ch = await guild.channels.fetch(cfg.welcomeChannelId).catch(() => null); + if (ch?.isTextBased()) return ch; } - const system = guild.systemChannel; - if (system && system.isTextBased() && "send" in system) { - return system as SendableGuildChannel; + // Fallback to system channel. + if (guild.systemChannel?.isTextBased()) { + return guild.systemChannel; } + // Final fallback: first text-based channel found. const channels = await guild.channels.fetch().catch(() => null); if (!channels) return null; for (const [, ch] of channels) { - if (!ch) continue; - - // We only want channels that can accept .send() - if (ch.isTextBased() && "send" in ch) { - return ch as SendableGuildChannel; - } + if (ch?.isTextBased()) return ch; } return null; @@ -60,16 +54,24 @@ async function resolveWelcomeChannel(guild: Guild): Promise { try { const channel = await resolveWelcomeChannel(member.guild); if (!channel) { + logger.info( + { guildId: member.guild.id }, + "Welcome handler skipped (no channel resolved or disabled)", + ); + return; + } + + if (!isSendableChannel(channel)) { logger.warn( { guildId: member.guild.id }, - "Welcome handler could not find a sendable channel", + "Resolved welcome channel is not sendable", ); return; }