Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -173,6 +174,8 @@ OmegaBot is online
│ ├── commands
│ │ ├── changelog
│ │ │ └── changelog.ts
│ │ ├── config
│ │ │ └── config.ts
│ │ ├── faq
│ │ │ ├── subcommands
│ │ │ │ ├── add.ts
Expand Down Expand Up @@ -204,6 +207,10 @@ OmegaBot is online
│ ├── config
│ │ └── env.ts
│ ├── services
│ │ ├── config
│ │ │ ├── guildConfigStore.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── discord
│ │ │ ├── commandLoader.ts
│ │ │ ├── fetchChannelMessages.ts
Expand Down
124 changes: 124 additions & 0 deletions src/commands/config/config.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
83 changes: 83 additions & 0 deletions src/services/config/guildConfigStore.ts
Original file line number Diff line number Diff line change
@@ -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:
* {
* "<guildId>": { welcomeEnabled: true, welcomeChannelId: "..." },
* "<guildId2>": { ... }
* }
*/
type StoreShape = Record<string, Omit<GuildConfig, "guildId">>;

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<GuildConfig, "guildId"> = { ...current, ...patch };

store[guildId] = next;
writeStore(store);

return { guildId, ...next };
}
14 changes: 14 additions & 0 deletions src/services/config/index.ts
Original file line number Diff line number Diff line change
@@ -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";
43 changes: 43 additions & 0 deletions src/services/config/types.ts
Original file line number Diff line number Diff line change
@@ -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<Omit<GuildConfig, "guildId">>;

/**
* Default config for new guilds.
* Applied automatically when no saved config exists.
*/
export const DEFAULT_GUILD_CONFIG: Omit<GuildConfig, "guildId"> = {
welcomeEnabled: true,
welcomeChannelId: null,
};
Loading