15 Commits

Author SHA1 Message Date
syntaxbullet
c2b1fb6db1 feat: implement database-backed game settings with a new schema, service, and migration script.
Some checks failed
Deploy to Production / test (push) Failing after 26s
2026-02-12 16:42:40 +01:00
syntaxbullet
d15d53e839 docs: update guild settings documentation with migrated files
List all files that have been updated to use getGuildConfig().
2026-02-12 16:10:59 +01:00
syntaxbullet
58374d1746 refactor: migrate all code to use getGuildConfig() for guild settings
- Update all commands and events to fetch guild config once per execution
- Pass config to service methods that need it (ModerationService.issueWarning)
- Update terminal service to use guildSettingsService for persistence
- Remove direct imports of config for guild-specific settings

This consolidates configuration to database-backed guild settings,
eliminating the dual config system.
2026-02-12 16:09:37 +01:00
syntaxbullet
ae6a068197 docs: add guild settings system documentation
Document guild settings architecture, service layer, admin commands,
API endpoints, and migration strategy from file-based config.
2026-02-12 15:10:58 +01:00
syntaxbullet
43d32918ab feat(api): add guild settings API endpoints
Add REST endpoints for managing per-guild configuration:
- GET /api/guilds/:guildId/settings
- PUT/PATCH /api/guilds/:guildId/settings
- DELETE /api/guilds/:guildId/settings
2026-02-12 15:09:29 +01:00
syntaxbullet
0bc254b728 feat(commands): add /settings admin command for guild configuration
Manage guild settings via Discord with subcommands:
- show: Display current settings
- set: Update individual settings (roles, channels, text, numbers, booleans)
- reset: Clear a setting to default
- colors: Manage color roles (list/add/remove)
2026-02-12 15:04:55 +01:00
syntaxbullet
610d97bde3 feat(scripts): add config migration script for guild settings
Add script to migrate existing config.json values to database with
bun run db:migrate-config command.
2026-02-12 15:02:05 +01:00
syntaxbullet
babccfd08a feat(config): add getGuildConfig() for database-backed guild settings
Add function to fetch guild-specific config from database with:
- 60-second cache TTL
- Fallback to file-based config for migration period
- Cache invalidation helper
2026-02-12 15:00:21 +01:00
syntaxbullet
ee7d63df3e feat(service): add guild settings service layer
Implement service for managing per-guild configuration with methods for
getting, upserting, updating, and deleting settings. Includes helpers
for color role management.
2026-02-12 14:58:41 +01:00
syntaxbullet
5f107d03a7 feat(db): add guild_settings table for per-guild configuration
Store guild-specific settings (roles, channels, moderation options) in
database instead of config file, enabling per-guild configuration and
runtime updates without redeployment.
2026-02-12 14:57:24 +01:00
syntaxbullet
1ff24b0f7f docs: add feature flags system documentation
Document feature flag architecture, usage, admin commands, and best practices for beta testing features in production.
2026-02-12 14:54:51 +01:00
syntaxbullet
a5e3534260 feat(commands): add /featureflags admin command
Add comprehensive feature flag management with subcommands:
- list: Show all feature flags
- create/delete: Manage flags
- enable/disable: Toggle flags
- grant/revoke: Manage access for users/roles/guilds
- access: View access records for a flag
2026-02-12 14:50:36 +01:00
syntaxbullet
228005322e feat(commands): add beta feature flag support to command system
- Add beta and featureFlag properties to Command interface
- Add beta access check in CommandHandler before command execution
- Show beta feature message to non-whitelisted users
2026-02-12 14:45:58 +01:00
syntaxbullet
67a3aa4b0f feat(service): add feature flags service layer
Implement service for managing feature flags and access control with
methods for checking access, creating/enabling flags, and managing
whitelisted users/guilds/roles.
2026-02-12 14:43:11 +01:00
syntaxbullet
64804f7066 feat(db): add feature flags schema for beta feature testing
Add feature_flags and feature_flag_access tables to support controlled
beta testing of new features in production without a separate test environment.
2026-02-12 14:41:12 +01:00
35 changed files with 4835 additions and 140 deletions

View File

@@ -1,6 +1,7 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
import { config, saveConfig } from "@shared/lib/config";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { items } from "@db/schema";
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
@@ -49,7 +50,7 @@ export const createColor = createCommand({
// 2. Create Role
const role = await interaction.guild?.roles.create({
name: name,
color: colorInput as any, // Discord.js types are a bit strict on ColorResolvable, but string generally works or needs parsing
color: colorInput as any,
reason: `Created via /createcolor by ${interaction.user.tag}`
});
@@ -57,11 +58,9 @@ export const createColor = createCommand({
throw new Error("Failed to create role.");
}
// 3. Update Config
if (!config.colorRoles.includes(role.id)) {
config.colorRoles.push(role.id);
saveConfig(config);
}
// 3. Add to guild settings
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
invalidateGuildConfigCache(interaction.guildId!);
// 4. Create Item
await DrizzleClient.insert(items).values({

View File

@@ -0,0 +1,297 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js";
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { UserError } from "@shared/lib/errors";
export const featureflags = createCommand({
data: new SlashCommandBuilder()
.setName("featureflags")
.setDescription("Manage feature flags for beta testing")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(sub =>
sub.setName("list")
.setDescription("List all feature flags")
)
.addSubcommand(sub =>
sub.setName("create")
.setDescription("Create a new feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
)
.addStringOption(opt =>
opt.setName("description")
.setDescription("Description of the feature flag")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("delete")
.setDescription("Delete a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(sub =>
sub.setName("enable")
.setDescription("Enable a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(sub =>
sub.setName("disable")
.setDescription("Disable a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(sub =>
sub.setName("grant")
.setDescription("Grant access to a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
.addUserOption(opt =>
opt.setName("user")
.setDescription("User to grant access to")
.setRequired(false)
)
.addRoleOption(opt =>
opt.setName("role")
.setDescription("Role to grant access to")
.setRequired(false)
)
)
.addSubcommand(sub =>
sub.setName("revoke")
.setDescription("Revoke access from a feature flag")
.addIntegerOption(opt =>
opt.setName("id")
.setDescription("Access record ID to revoke")
.setRequired(true)
)
)
.addSubcommand(sub =>
sub.setName("access")
.setDescription("List access records for a feature flag")
.addStringOption(opt =>
opt.setName("name")
.setDescription("Name of the feature flag")
.setRequired(true)
.setAutocomplete(true)
)
),
autocomplete: async (interaction) => {
const focused = interaction.options.getFocused(true);
if (focused.name === "name") {
const flags = await featureFlagsService.listFlags();
const filtered = flags
.filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase()))
.slice(0, 25);
await interaction.respond(
filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name }))
);
}
},
execute: async (interaction) => {
await interaction.deferReply();
const subcommand = interaction.options.getSubcommand();
try {
switch (subcommand) {
case "list":
await handleList(interaction);
break;
case "create":
await handleCreate(interaction);
break;
case "delete":
await handleDelete(interaction);
break;
case "enable":
await handleEnable(interaction);
break;
case "disable":
await handleDisable(interaction);
break;
case "grant":
await handleGrant(interaction);
break;
case "revoke":
await handleRevoke(interaction);
break;
case "access":
await handleAccess(interaction);
break;
}
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
throw error;
}
}
},
});
async function handleList(interaction: ChatInputCommandInteraction) {
const flags = await featureFlagsService.listFlags();
if (flags.length === 0) {
await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] });
return;
}
const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue)
.addFields(
flags.map(f => ({
name: f.name,
value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`,
inline: false,
}))
);
await interaction.editReply({ embeds: [embed] });
}
async function handleCreate(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const description = interaction.options.getString("description");
const flag = await featureFlagsService.createFlag(name, description ?? undefined);
if (!flag) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] });
return;
}
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)]
});
}
async function handleDelete(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.deleteFlag(name);
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)]
});
}
async function handleEnable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, true);
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)]
});
}
async function handleDisable(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const flag = await featureFlagsService.setFlagEnabled(name, false);
await interaction.editReply({
embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)]
});
}
async function handleGrant(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const user = interaction.options.getUser("user");
const role = interaction.options.getRole("role");
if (!user && !role) {
await interaction.editReply({
embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")]
});
return;
}
const access = await featureFlagsService.grantAccess(name, {
userId: user?.id,
roleId: role?.id,
guildId: interaction.guildId!,
});
if (!access) {
await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] });
return;
}
let target: string;
if (user) {
target = userMention(user.id);
} else if (role) {
target = roleMention(role.id);
} else {
target = "Unknown";
}
await interaction.editReply({
embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)]
});
}
async function handleRevoke(interaction: ChatInputCommandInteraction) {
const id = interaction.options.getInteger("id", true);
const access = await featureFlagsService.revokeAccess(id);
await interaction.editReply({
embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)]
});
}
async function handleAccess(interaction: ChatInputCommandInteraction) {
const name = interaction.options.getString("name", true);
const accessRecords = await featureFlagsService.listAccess(name);
if (accessRecords.length === 0) {
await interaction.editReply({
embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)]
});
return;
}
const fields = accessRecords.map(a => {
let target = "Unknown";
if (a.userId) target = `User: ${userMention(a.userId.toString())}`;
else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`;
else if (a.guildId) target = `Guild: ${a.guildId.toString()}`;
return {
name: `ID: ${a.id}`,
value: target,
inline: true,
};
});
const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue)
.addFields(fields);
await interaction.editReply({ embeds: [embed] });
}

View File

@@ -0,0 +1,247 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder, PermissionFlagsBits, Colors, ChatInputCommandInteraction } from "discord.js";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { UserError } from "@shared/lib/errors";
export const settings = createCommand({
data: new SlashCommandBuilder()
.setName("settings")
.setDescription("Manage guild settings")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addSubcommand(sub =>
sub.setName("show")
.setDescription("Show current guild settings"))
.addSubcommand(sub =>
sub.setName("set")
.setDescription("Set a guild setting")
.addStringOption(opt =>
opt.setName("key")
.setDescription("Setting to change")
.setRequired(true)
.addChoices(
{ name: "Student Role", value: "studentRole" },
{ name: "Visitor Role", value: "visitorRole" },
{ name: "Welcome Channel", value: "welcomeChannel" },
{ name: "Welcome Message", value: "welcomeMessage" },
{ name: "Feedback Channel", value: "feedbackChannel" },
{ name: "Terminal Channel", value: "terminalChannel" },
{ name: "Terminal Message", value: "terminalMessage" },
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
{ name: "DM on Warn", value: "moderationDmOnWarn" },
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
))
.addRoleOption(opt =>
opt.setName("role")
.setDescription("Role value"))
.addChannelOption(opt =>
opt.setName("channel")
.setDescription("Channel value"))
.addStringOption(opt =>
opt.setName("text")
.setDescription("Text value"))
.addIntegerOption(opt =>
opt.setName("number")
.setDescription("Number value"))
.addBooleanOption(opt =>
opt.setName("boolean")
.setDescription("Boolean value (true/false)")))
.addSubcommand(sub =>
sub.setName("reset")
.setDescription("Reset a setting to default")
.addStringOption(opt =>
opt.setName("key")
.setDescription("Setting to reset")
.setRequired(true)
.addChoices(
{ name: "Student Role", value: "studentRole" },
{ name: "Visitor Role", value: "visitorRole" },
{ name: "Welcome Channel", value: "welcomeChannel" },
{ name: "Welcome Message", value: "welcomeMessage" },
{ name: "Feedback Channel", value: "feedbackChannel" },
{ name: "Terminal Channel", value: "terminalChannel" },
{ name: "Terminal Message", value: "terminalMessage" },
{ name: "Moderation Log Channel", value: "moderationLogChannel" },
{ name: "DM on Warn", value: "moderationDmOnWarn" },
{ name: "Auto Timeout Threshold", value: "moderationAutoTimeoutThreshold" },
)))
.addSubcommand(sub =>
sub.setName("colors")
.setDescription("Manage color roles")
.addStringOption(opt =>
opt.setName("action")
.setDescription("Action to perform")
.setRequired(true)
.addChoices(
{ name: "List", value: "list" },
{ name: "Add", value: "add" },
{ name: "Remove", value: "remove" },
))
.addRoleOption(opt =>
opt.setName("role")
.setDescription("Role to add/remove")
.setRequired(false))),
execute: async (interaction) => {
await interaction.deferReply({ ephemeral: true });
const subcommand = interaction.options.getSubcommand();
const guildId = interaction.guildId!;
try {
switch (subcommand) {
case "show":
await handleShow(interaction, guildId);
break;
case "set":
await handleSet(interaction, guildId);
break;
case "reset":
await handleReset(interaction, guildId);
break;
case "colors":
await handleColors(interaction, guildId);
break;
}
} catch (error) {
if (error instanceof UserError) {
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
} else {
throw error;
}
}
},
});
async function handleShow(interaction: ChatInputCommandInteraction, guildId: string) {
const settings = await getGuildConfig(guildId);
const colorRolesDisplay = settings.colorRoles?.length
? settings.colorRoles.map(id => `<@&${id}>`).join(", ")
: "None";
const embed = createBaseEmbed("Guild Settings", undefined, Colors.Blue)
.addFields(
{ name: "Student Role", value: settings.studentRole ? `<@&${settings.studentRole}>` : "Not set", inline: true },
{ name: "Visitor Role", value: settings.visitorRole ? `<@&${settings.visitorRole}>` : "Not set", inline: true },
{ name: "\u200b", value: "\u200b", inline: true },
{ name: "Welcome Channel", value: settings.welcomeChannelId ? `<#${settings.welcomeChannelId}>` : "Not set", inline: true },
{ name: "Feedback Channel", value: settings.feedbackChannelId ? `<#${settings.feedbackChannelId}>` : "Not set", inline: true },
{ name: "Moderation Log", value: settings.moderation?.cases?.logChannelId ? `<#${settings.moderation.cases.logChannelId}>` : "Not set", inline: true },
{ name: "Terminal Channel", value: settings.terminal?.channelId ? `<#${settings.terminal.channelId}>` : "Not set", inline: true },
{ name: "DM on Warn", value: settings.moderation?.cases?.dmOnWarn !== false ? "Enabled" : "Disabled", inline: true },
{ name: "Auto Timeout", value: settings.moderation?.cases?.autoTimeoutThreshold ? `${settings.moderation.cases.autoTimeoutThreshold} warnings` : "Disabled", inline: true },
{ name: "Color Roles", value: colorRolesDisplay, inline: false },
);
if (settings.welcomeMessage) {
embed.addFields({ name: "Welcome Message", value: settings.welcomeMessage.substring(0, 1024), inline: false });
}
await interaction.editReply({ embeds: [embed] });
}
async function handleSet(interaction: ChatInputCommandInteraction, guildId: string) {
const key = interaction.options.getString("key", true);
const role = interaction.options.getRole("role");
const channel = interaction.options.getChannel("channel");
const text = interaction.options.getString("text");
const number = interaction.options.getInteger("number");
const boolean = interaction.options.getBoolean("boolean");
let value: string | number | boolean | null = null;
if (role) value = role.id;
else if (channel) value = channel.id;
else if (text) value = text;
else if (number !== null) value = number;
else if (boolean !== null) value = boolean;
if (value === null) {
await interaction.editReply({
embeds: [createErrorEmbed("Please provide a role, channel, text, number, or boolean value")]
});
return;
}
await guildSettingsService.updateSetting(guildId, key, value);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Setting "${key}" updated`)]
});
}
async function handleReset(interaction: ChatInputCommandInteraction, guildId: string) {
const key = interaction.options.getString("key", true);
await guildSettingsService.updateSetting(guildId, key, null);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Setting "${key}" reset to default`)]
});
}
async function handleColors(interaction: ChatInputCommandInteraction, guildId: string) {
const action = interaction.options.getString("action", true);
const role = interaction.options.getRole("role");
switch (action) {
case "list": {
const settings = await getGuildConfig(guildId);
const colorRoles = settings.colorRoles ?? [];
if (colorRoles.length === 0) {
await interaction.editReply({
embeds: [createBaseEmbed("Color Roles", "No color roles configured.", Colors.Blue)]
});
return;
}
const embed = createBaseEmbed("Color Roles", undefined, Colors.Blue)
.addFields({
name: `Configured Roles (${colorRoles.length})`,
value: colorRoles.map(id => `<@&${id}>`).join("\n"),
});
await interaction.editReply({ embeds: [embed] });
break;
}
case "add": {
if (!role) {
await interaction.editReply({
embeds: [createErrorEmbed("Please specify a role to add.")]
});
return;
}
await guildSettingsService.addColorRole(guildId, role.id);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Added <@&${role.id}> to color roles.`)]
});
break;
}
case "remove": {
if (!role) {
await interaction.editReply({
embeds: [createErrorEmbed("Please specify a role to remove.")]
});
return;
}
await guildSettingsService.removeColorRole(guildId, role.id);
invalidateGuildConfigCache(guildId);
await interaction.editReply({
embeds: [createSuccessEmbed(`Removed <@&${role.id}> from color roles.`)]
});
break;
}
}
}

View File

@@ -6,7 +6,7 @@ import {
getModerationErrorEmbed,
getUserWarningEmbed
} from "@/modules/moderation/moderation.view";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
export const warn = createCommand({
data: new SlashCommandBuilder()
@@ -50,6 +50,9 @@ export const warn = createCommand({
return;
}
// Fetch guild config for moderation settings
const guildConfig = await getGuildConfig(interaction.guildId!);
// Issue the warning via service
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
userId: targetUser.id,
@@ -59,7 +62,11 @@ export const warn = createCommand({
reason,
guildName: interaction.guild?.name || undefined,
dmTarget: targetUser,
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id)
timeoutTarget: await interaction.guild?.members.fetch(targetUser.id),
config: {
dmOnWarn: guildConfig.moderation?.cases?.dmOnWarn,
autoTimeoutThreshold: guildConfig.moderation?.cases?.autoTimeoutThreshold,
},
});
// Send success message to moderator

View File

@@ -1,6 +1,6 @@
import { createCommand } from "@shared/lib/utils";
import { SlashCommandBuilder } from "discord.js";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
import { createErrorEmbed } from "@/lib/embeds";
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
@@ -9,8 +9,10 @@ export const feedback = createCommand({
.setName("feedback")
.setDescription("Submit feedback, feature requests, or bug reports"),
execute: async (interaction) => {
const guildConfig = await getGuildConfig(interaction.guildId!);
// Check if feedback channel is configured
if (!config.feedbackChannelId) {
if (!guildConfig.feedbackChannelId) {
await interaction.reply({
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
ephemeral: true

View File

@@ -6,7 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
import type { ItemUsageData } from "@shared/lib/types";
import { UserError } from "@shared/lib/errors";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
export const use = createCommand({
data: new SlashCommandBuilder()
@@ -21,6 +21,9 @@ export const use = createCommand({
execute: async (interaction) => {
await interaction.deferReply();
const guildConfig = await getGuildConfig(interaction.guildId!);
const colorRoles = guildConfig.colorRoles ?? [];
const itemId = interaction.options.getNumber("item", true);
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
if (!user) {
@@ -42,7 +45,7 @@ export const use = createCommand({
await member.roles.add(effect.roleId);
} else if (effect.type === 'COLOR_ROLE') {
// Remove existing color roles
const rolesToRemove = config.colorRoles.filter(r => member.roles.cache.has(r));
const rolesToRemove = colorRoles.filter(r => member.roles.cache.has(r));
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
await member.roles.add(effect.roleId);
}

View File

@@ -1,20 +1,26 @@
import { Events } from "discord.js";
import type { Event } from "@shared/lib/types";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
import { userService } from "@shared/modules/user/user.service";
// Visitor role
const event: Event<Events.GuildMemberAdd> = {
name: Events.GuildMemberAdd,
execute: async (member) => {
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
const guildConfig = await getGuildConfig(member.guild.id);
try {
const user = await userService.getUserById(member.id);
if (user && user.class) {
console.log(`🔄 Returning student detected: ${member.user.tag}`);
await member.roles.remove(config.visitorRole);
await member.roles.add(config.studentRole);
if (guildConfig.visitorRole) {
await member.roles.remove(guildConfig.visitorRole);
}
if (guildConfig.studentRole) {
await member.roles.add(guildConfig.studentRole);
}
if (user.class.roleId) {
await member.roles.add(user.class.roleId);
@@ -22,8 +28,10 @@ const event: Event<Events.GuildMemberAdd> = {
}
console.log(`Restored student role to ${member.user.tag}`);
} else {
await member.roles.add(config.visitorRole);
console.log(`Assigned visitor role to ${member.user.tag}`);
if (guildConfig.visitorRole) {
await member.roles.add(guildConfig.visitorRole);
console.log(`Assigned visitor role to ${member.user.tag}`);
}
}
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
} catch (error) {

View File

@@ -1,6 +1,7 @@
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
import { AuroraClient } from "@/lib/BotClient";
import { userService } from "@shared/modules/user/user.service";
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
import { createErrorEmbed } from "@lib/embeds";
import { logger } from "@shared/lib/logger";
@@ -25,6 +26,37 @@ export class CommandHandler {
return;
}
// Check beta feature access
if (command.beta) {
const flagName = command.featureFlag || interaction.commandName;
let memberRoles: string[] = [];
if (interaction.member && 'roles' in interaction.member) {
const roles = interaction.member.roles;
if (typeof roles === 'object' && 'cache' in roles) {
memberRoles = [...roles.cache.keys()];
} else if (Array.isArray(roles)) {
memberRoles = roles;
}
}
const hasAccess = await featureFlagsService.hasAccess(flagName, {
guildId: interaction.guildId!,
userId: interaction.user.id,
memberRoles,
});
if (!hasAccess) {
const errorEmbed = createErrorEmbed(
"This feature is currently in beta testing and not available to all users. " +
"Stay tuned for the official release!",
"Beta Feature"
);
await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral });
return;
}
}
// Ensure user exists in database
try {
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);

View File

@@ -1,6 +1,6 @@
import type { Interaction } from "discord.js";
import { TextChannel, MessageFlags } from "discord.js";
import { config } from "@shared/lib/config";
import { getGuildConfig } from "@shared/lib/config";
import { AuroraClient } from "@/lib/BotClient";
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
@@ -33,7 +33,13 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
throw new UserError("An error occurred processing your feedback. Please try again.");
}
if (!config.feedbackChannelId) {
if (!interaction.guildId) {
throw new UserError("This action can only be performed in a server.");
}
const guildConfig = await getGuildConfig(interaction.guildId);
if (!guildConfig.feedbackChannelId) {
throw new UserError("Feedback channel is not configured. Please contact an administrator.");
}
@@ -52,7 +58,7 @@ export const handleFeedbackInteraction = async (interaction: Interaction) => {
};
// Get feedback channel
const channel = await AuroraClient.channels.fetch(config.feedbackChannelId).catch(() => null) as TextChannel | null;
const channel = await AuroraClient.channels.fetch(guildConfig.feedbackChannelId).catch(() => null) as TextChannel | null;
if (!channel) {
throw new UserError("Feedback channel not found. Please contact an administrator.");

View File

@@ -1,4 +1,5 @@
import { temporaryRoleService } from "@shared/modules/system/temp-role.service";
import { terminalService } from "@shared/modules/terminal/terminal.service";
export const schedulerService = {
start: () => {
@@ -10,7 +11,6 @@ export const schedulerService = {
}, 60 * 1000);
// 2. Terminal Update Loop (every 60s)
const { terminalService } = require("@shared/modules/terminal/terminal.service");
setInterval(() => {
terminalService.update();
}, 60 * 1000);

View File

@@ -1,5 +1,5 @@
import { ButtonInteraction, MessageFlags } from "discord.js";
import { config } from "@shared/lib/config";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { getEnrollmentSuccessMessage } from "./enrollment.view";
import { classService } from "@shared/modules/class/class.service";
import { userService } from "@shared/modules/user/user.service";
@@ -11,7 +11,8 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
throw new UserError("This action can only be performed in a server.");
}
const { studentRole, visitorRole } = config;
const guildConfig = await getGuildConfig(interaction.guildId);
const { studentRole, visitorRole, welcomeChannelId, welcomeMessage } = guildConfig;
if (!studentRole || !visitorRole) {
throw new UserError("No student or visitor role configured for enrollment.");
@@ -67,10 +68,10 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction
});
// 5. Send Welcome Message (if configured)
if (config.welcomeChannelId) {
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
if (welcomeChannelId) {
const welcomeChannel = interaction.guild.channels.cache.get(welcomeChannelId);
if (welcomeChannel && welcomeChannel.isTextBased()) {
const rawMessage = config.welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
const rawMessage = welcomeMessage || "Welcome to Aurora, {user}! You have been enrolled as a **{class}**.";
const processedMessage = rawMessage
.replace(/{user}/g, member.toString())

168
docs/feature-flags.md Normal file
View File

@@ -0,0 +1,168 @@
# Feature Flag System
The feature flag system enables controlled beta testing of new features in production without requiring a separate test environment.
## Overview
Feature flags allow you to:
- Test new features with a limited audience before full rollout
- Enable/disable features without code changes or redeployment
- Control access per guild, user, or role
- Eliminate environment drift between test and production
## Architecture
### Database Schema
**`feature_flags` table:**
| Column | Type | Description |
|--------|------|-------------|
| `id` | serial | Primary key |
| `name` | varchar(100) | Unique flag identifier |
| `enabled` | boolean | Whether the flag is active |
| `description` | text | Human-readable description |
| `created_at` | timestamp | Creation time |
| `updated_at` | timestamp | Last update time |
**`feature_flag_access` table:**
| Column | Type | Description |
|--------|------|-------------|
| `id` | serial | Primary key |
| `flag_id` | integer | References feature_flags.id |
| `guild_id` | bigint | Guild whitelist (nullable) |
| `user_id` | bigint | User whitelist (nullable) |
| `role_id` | bigint | Role whitelist (nullable) |
| `created_at` | timestamp | Creation time |
### Service Layer
The `featureFlagsService` (`shared/modules/feature-flags/feature-flags.service.ts`) provides:
```typescript
// Check if a flag is globally enabled
await featureFlagsService.isFlagEnabled("trading_system");
// Check if a user has access to a flagged feature
await featureFlagsService.hasAccess("trading_system", {
guildId: "123456789",
userId: "987654321",
memberRoles: ["role1", "role2"]
});
// Create a new feature flag
await featureFlagsService.createFlag("new_feature", "Description");
// Enable/disable a flag
await featureFlagsService.setFlagEnabled("new_feature", true);
// Grant access to users/roles/guilds
await featureFlagsService.grantAccess("new_feature", { userId: "123" });
await featureFlagsService.grantAccess("new_feature", { roleId: "456" });
await featureFlagsService.grantAccess("new_feature", { guildId: "789" });
// List all flags or access records
await featureFlagsService.listFlags();
await featureFlagsService.listAccess("new_feature");
```
## Usage
### Marking a Command as Beta
Add `beta: true` to any command definition:
```typescript
export const newFeature = createCommand({
data: new SlashCommandBuilder()
.setName("newfeature")
.setDescription("A new experimental feature"),
beta: true, // Marks this command as a beta feature
execute: async (interaction) => {
// Implementation
},
});
```
By default, the command name is used as the feature flag name. To use a custom flag name:
```typescript
export const trade = createCommand({
data: new SlashCommandBuilder()
.setName("trade")
.setDescription("Trade items with another user"),
beta: true,
featureFlag: "trading_system", // Custom flag name
execute: async (interaction) => {
// Implementation
},
});
```
### Access Control Flow
When a user attempts to use a beta command:
1. **Check if flag exists and is enabled** - Returns false if flag doesn't exist or is disabled
2. **Check guild whitelist** - User's guild has access if `guild_id` matches
3. **Check user whitelist** - User has access if `user_id` matches
4. **Check role whitelist** - User has access if any of their roles match
If none of these conditions are met, the user sees:
> **Beta Feature**
> This feature is currently in beta testing and not available to all users. Stay tuned for the official release!
## Admin Commands
The `/featureflags` command (Administrator only) provides full management:
### Subcommands
| Command | Description |
|---------|-------------|
| `/featureflags list` | List all feature flags with status |
| `/featureflags create <name> [description]` | Create a new flag (disabled by default) |
| `/featureflags delete <name>` | Delete a flag and all access records |
| `/featureflags enable <name>` | Enable a flag globally |
| `/featureflags disable <name>` | Disable a flag globally |
| `/featureflags grant <name> [user\|role]` | Grant access to a user or role |
| `/featureflags revoke <id>` | Revoke access by record ID |
| `/featureflags access <name>` | List all access records for a flag |
### Example Workflow
```
1. Create the flag:
/featureflags create trading_system "Item trading between users"
2. Grant access to beta testers:
/featureflags grant trading_system user:@beta_tester
/featureflags grant trading_system role:@Beta Testers
3. Enable the flag:
/featureflags enable trading_system
4. View access list:
/featureflags access trading_system
5. When ready for full release:
- Remove beta: true from the command
- Delete the flag: /featureflags delete trading_system
```
## Best Practices
1. **Descriptive Names**: Use snake_case names that clearly describe the feature
2. **Document Flags**: Always add a description when creating flags
3. **Role-Based Access**: Prefer role-based access over user-based for easier management
4. **Clean Up**: Delete flags after features are fully released
5. **Testing**: Always test with a small group before wider rollout
## Implementation Files
| File | Purpose |
|------|---------|
| `shared/db/schema/feature-flags.ts` | Database schema |
| `shared/modules/feature-flags/feature-flags.service.ts` | Service layer |
| `shared/lib/types.ts` | Command interface with beta properties |
| `bot/lib/handlers/CommandHandler.ts` | Beta access check |
| `bot/commands/admin/featureflags.ts` | Admin command |

199
docs/guild-settings.md Normal file
View File

@@ -0,0 +1,199 @@
# Guild Settings System
The guild settings system enables per-guild configuration stored in the database, eliminating environment-specific config files and enabling runtime updates without redeployment.
## Overview
Guild settings allow you to:
- Store per-guild configuration in the database
- Update settings at runtime without code changes
- Support multiple guilds with different configurations
- Maintain backward compatibility with file-based config
## Architecture
### Database Schema
**`guild_settings` table:**
| Column | Type | Description |
|--------|------|-------------|
| `guild_id` | bigint | Primary key (Discord guild ID) |
| `student_role_id` | bigint | Student role ID |
| `visitor_role_id` | bigint | Visitor role ID |
| `color_role_ids` | jsonb | Array of color role IDs |
| `welcome_channel_id` | bigint | Welcome message channel |
| `welcome_message` | text | Custom welcome message |
| `feedback_channel_id` | bigint | Feedback channel |
| `terminal_channel_id` | bigint | Terminal channel |
| `terminal_message_id` | bigint | Terminal message ID |
| `moderation_log_channel_id` | bigint | Moderation log channel |
| `moderation_dm_on_warn` | jsonb | DM user on warn |
| `moderation_auto_timeout_threshold` | jsonb | Auto timeout after N warnings |
| `feature_overrides` | jsonb | Feature flag overrides |
| `created_at` | timestamp | Creation time |
| `updated_at` | timestamp | Last update time |
### Service Layer
The `guildSettingsService` (`shared/modules/guild-settings/guild-settings.service.ts`) provides:
```typescript
// Get settings for a guild (returns null if not configured)
await guildSettingsService.getSettings(guildId);
// Create or update settings
await guildSettingsService.upsertSettings({
guildId: "123456789",
studentRoleId: "987654321",
visitorRoleId: "111222333",
});
// Update a single setting
await guildSettingsService.updateSetting(guildId, "welcomeChannel", "456789123");
// Delete all settings for a guild
await guildSettingsService.deleteSettings(guildId);
// Color role helpers
await guildSettingsService.addColorRole(guildId, roleId);
await guildSettingsService.removeColorRole(guildId, roleId);
```
## Usage
### Getting Guild Configuration
Use `getGuildConfig()` instead of direct `config` imports for guild-specific settings:
```typescript
import { getGuildConfig } from "@shared/lib/config";
// In a command or interaction
const guildConfig = await getGuildConfig(interaction.guildId);
// Access settings
const studentRole = guildConfig.studentRole;
const welcomeChannel = guildConfig.welcomeChannelId;
```
### Fallback Behavior
`getGuildConfig()` returns settings in this order:
1. **Database settings** (if guild is configured in DB)
2. **File config fallback** (during migration period)
This ensures backward compatibility while migrating from file-based config.
### Cache Invalidation
Settings are cached for 60 seconds. After updating settings, invalidate the cache:
```typescript
import { invalidateGuildConfigCache } from "@shared/lib/config";
await guildSettingsService.upsertSettings({ guildId, ...settings });
invalidateGuildConfigCache(guildId);
```
## Admin Commands
The `/settings` command (Administrator only) provides full management:
### Subcommands
| Command | Description |
|---------|-------------|
| `/settings show` | Display current guild settings |
| `/settings set <key> [value]` | Update a setting |
| `/settings reset <key>` | Reset a setting to default |
| `/settings colors <action> [role]` | Manage color roles |
### Settable Keys
| Key | Type | Description |
|-----|------|-------------|
| `studentRole` | Role | Role for enrolled students |
| `visitorRole` | Role | Role for visitors |
| `welcomeChannel` | Channel | Channel for welcome messages |
| `welcomeMessage` | Text | Custom welcome message |
| `feedbackChannel` | Channel | Channel for feedback |
| `terminalChannel` | Channel | Terminal channel |
| `terminalMessage` | Text | Terminal message ID |
| `moderationLogChannel` | Channel | Moderation log channel |
| `moderationDmOnWarn` | Boolean | DM users on warn |
| `moderationAutoTimeoutThreshold` | Number | Auto timeout threshold |
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/guilds/:guildId/settings` | Get guild settings |
| PUT | `/api/guilds/:guildId/settings` | Create/replace settings |
| PATCH | `/api/guilds/:guildId/settings` | Partial update |
| DELETE | `/api/guilds/:guildId/settings` | Delete settings |
## Migration
To migrate existing config.json settings to the database:
```bash
bun run db:migrate-config
```
This will:
1. Read values from `config.json`
2. Create a database record for `DISCORD_GUILD_ID`
3. Store all guild-specific settings
## Migration Strategy for Code
Update code references incrementally:
```typescript
// Before
import { config } from "@shared/lib/config";
const role = config.studentRole;
// After
import { getGuildConfig } from "@shared/lib/config";
const guildConfig = await getGuildConfig(guildId);
const role = guildConfig.studentRole;
```
### Files to Update
Files using guild-specific config that should be updated:
- `bot/events/guildMemberAdd.ts`
- `bot/modules/user/enrollment.interaction.ts`
- `bot/modules/feedback/feedback.interaction.ts`
- `bot/commands/feedback/feedback.ts`
- `bot/commands/inventory/use.ts`
- `bot/commands/admin/create_color.ts`
- `shared/modules/moderation/moderation.service.ts`
- `shared/modules/terminal/terminal.service.ts`
## Files Updated to Use Database Config
All code has been migrated to use `getGuildConfig()`:
- `bot/events/guildMemberAdd.ts` - Role assignment on join
- `bot/modules/user/enrollment.interaction.ts` - Enrollment flow
- `bot/modules/feedback/feedback.interaction.ts` - Feedback submission
- `bot/commands/feedback/feedback.ts` - Feedback command
- `bot/commands/inventory/use.ts` - Color role handling
- `bot/commands/admin/create_color.ts` - Color role creation
- `bot/commands/admin/warn.ts` - Warning with DM and auto-timeout
- `shared/modules/moderation/moderation.service.ts` - Accepts config param
- `shared/modules/terminal/terminal.service.ts` - Terminal location persistence
- `shared/modules/economy/lootdrop.service.ts` - Terminal updates
## Implementation Files
| File | Purpose |
|------|---------|
| `shared/db/schema/guild-settings.ts` | Database schema |
| `shared/modules/guild-settings/guild-settings.service.ts` | Service layer |
| `shared/lib/config.ts` | Config loader with getGuildConfig() |
| `bot/commands/admin/settings.ts` | Admin command |
| `web/src/routes/guild-settings.routes.ts` | API routes |
| `shared/scripts/migrate-config-to-db.ts` | Migration script |

View File

@@ -18,6 +18,9 @@
"db:push:local": "drizzle-kit push",
"dev": "bun --watch bot/index.ts",
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
"remote": "bash shared/scripts/remote.sh",
"logs": "bash shared/scripts/logs.sh",
"db:backup": "bash shared/scripts/db-backup.sh",

View File

@@ -0,0 +1,25 @@
CREATE TABLE "feature_flag_access" (
"id" serial PRIMARY KEY NOT NULL,
"flag_id" integer NOT NULL,
"guild_id" bigint,
"user_id" bigint,
"role_id" bigint,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "feature_flags" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "feature_flags_name_unique" UNIQUE("name")
);
--> statement-breakpoint
ALTER TABLE "items" ALTER COLUMN "rarity" SET DEFAULT 'C';--> statement-breakpoint
ALTER TABLE "feature_flag_access" ADD CONSTRAINT "feature_flag_access_flag_id_feature_flags_id_fk" FOREIGN KEY ("flag_id") REFERENCES "public"."feature_flags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_ffa_flag_id" ON "feature_flag_access" USING btree ("flag_id");--> statement-breakpoint
CREATE INDEX "idx_ffa_guild_id" ON "feature_flag_access" USING btree ("guild_id");--> statement-breakpoint
CREATE INDEX "idx_ffa_user_id" ON "feature_flag_access" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_ffa_role_id" ON "feature_flag_access" USING btree ("role_id");

View File

@@ -0,0 +1,17 @@
CREATE TABLE "guild_settings" (
"guild_id" bigint PRIMARY KEY NOT NULL,
"student_role_id" bigint,
"visitor_role_id" bigint,
"color_role_ids" jsonb DEFAULT '[]'::jsonb,
"welcome_channel_id" bigint,
"welcome_message" text,
"feedback_channel_id" bigint,
"terminal_channel_id" bigint,
"terminal_message_id" bigint,
"moderation_log_channel_id" bigint,
"moderation_dm_on_warn" jsonb DEFAULT 'true'::jsonb,
"moderation_auto_timeout_threshold" jsonb,
"feature_overrides" jsonb DEFAULT '{}'::jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,20 @@
"when": 1767716705797,
"tag": "0002_fancy_forge",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1770903573324,
"tag": "0003_new_senator_kelly",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1770904612078,
"tag": "0004_bored_kat_farrell",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,49 @@
import {
pgTable,
serial,
varchar,
boolean,
text,
timestamp,
bigint,
integer,
index,
} from 'drizzle-orm/pg-core';
import { relations, type InferSelectModel } from 'drizzle-orm';
export type FeatureFlag = InferSelectModel<typeof featureFlags>;
export type FeatureFlagAccess = InferSelectModel<typeof featureFlagAccess>;
export const featureFlags = pgTable('feature_flags', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull().unique(),
enabled: boolean('enabled').default(false).notNull(),
description: text('description'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const featureFlagAccess = pgTable('feature_flag_access', {
id: serial('id').primaryKey(),
flagId: integer('flag_id').notNull().references(() => featureFlags.id, { onDelete: 'cascade' }),
guildId: bigint('guild_id', { mode: 'bigint' }),
userId: bigint('user_id', { mode: 'bigint' }),
roleId: bigint('role_id', { mode: 'bigint' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
index('idx_ffa_flag_id').on(table.flagId),
index('idx_ffa_guild_id').on(table.guildId),
index('idx_ffa_user_id').on(table.userId),
index('idx_ffa_role_id').on(table.roleId),
]);
export const featureFlagsRelations = relations(featureFlags, ({ many }) => ({
access: many(featureFlagAccess),
}));
export const featureFlagAccessRelations = relations(featureFlagAccess, ({ one }) => ({
flag: one(featureFlags, {
fields: [featureFlagAccess.flagId],
references: [featureFlags.id],
}),
}));

View File

@@ -0,0 +1,88 @@
import {
pgTable,
text,
timestamp,
jsonb,
} from 'drizzle-orm/pg-core';
import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle-orm';
export type GameSettings = InferSelectModel<typeof gameSettings>;
export type GameSettingsInsert = InferInsertModel<typeof gameSettings>;
export interface LevelingConfig {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
export interface EconomyConfig {
daily: {
amount: string;
streakBonus: string;
weeklyBonus: string;
cooldownMs: number;
};
transfers: {
allowSelfTransfer: boolean;
minAmount: string;
};
exam: {
multMin: number;
multMax: number;
};
}
export interface InventoryConfig {
maxStackSize: string;
maxSlots: number;
}
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig {
entryFee: string;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: 'easy' | 'medium' | 'hard' | 'random';
}
export interface ModerationConfig {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
}
export const gameSettings = pgTable('game_settings', {
id: text('id').primaryKey().default('default'),
leveling: jsonb('leveling').$type<LevelingConfig>().notNull(),
economy: jsonb('economy').$type<EconomyConfig>().notNull(),
inventory: jsonb('inventory').$type<InventoryConfig>().notNull(),
lootdrop: jsonb('lootdrop').$type<LootdropConfig>().notNull(),
trivia: jsonb('trivia').$type<TriviaConfig>().notNull(),
moderation: jsonb('moderation').$type<ModerationConfig>().notNull(),
commands: jsonb('commands').$type<Record<string, boolean>>().default({}),
system: jsonb('system').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const gameSettingsRelations = relations(gameSettings, () => ({}));

View File

@@ -0,0 +1,31 @@
import {
pgTable,
bigint,
timestamp,
text,
jsonb,
} from 'drizzle-orm/pg-core';
import { relations, type InferSelectModel, type InferInsertModel } from 'drizzle-orm';
export type GuildSettings = InferSelectModel<typeof guildSettings>;
export type GuildSettingsInsert = InferInsertModel<typeof guildSettings>;
export const guildSettings = pgTable('guild_settings', {
guildId: bigint('guild_id', { mode: 'bigint' }).primaryKey(),
studentRoleId: bigint('student_role_id', { mode: 'bigint' }),
visitorRoleId: bigint('visitor_role_id', { mode: 'bigint' }),
colorRoleIds: jsonb('color_role_ids').$type<string[]>().default([]),
welcomeChannelId: bigint('welcome_channel_id', { mode: 'bigint' }),
welcomeMessage: text('welcome_message'),
feedbackChannelId: bigint('feedback_channel_id', { mode: 'bigint' }),
terminalChannelId: bigint('terminal_channel_id', { mode: 'bigint' }),
terminalMessageId: bigint('terminal_message_id', { mode: 'bigint' }),
moderationLogChannelId: bigint('moderation_log_channel_id', { mode: 'bigint' }),
moderationDmOnWarn: jsonb('moderation_dm_on_warn').$type<boolean>().default(true),
moderationAutoTimeoutThreshold: jsonb('moderation_auto_timeout_threshold').$type<number>(),
featureOverrides: jsonb('feature_overrides').$type<Record<string, boolean>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const guildSettingsRelations = relations(guildSettings, () => ({}));

View File

@@ -4,3 +4,6 @@ export * from './inventory';
export * from './economy';
export * from './quests';
export * from './moderation';
export * from './feature-flags';
export * from './guild-settings';
export * from './game-settings';

View File

@@ -5,48 +5,159 @@ import { z } from 'zod';
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
export interface GameConfigType {
leveling: {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
}
},
economy: {
daily: {
amount: bigint;
streakBonus: bigint;
weeklyBonus: bigint;
cooldownMs: number;
},
transfers: {
allowSelfTransfer: boolean;
minAmount: bigint;
},
exam: {
multMin: number;
multMax: number;
}
},
inventory: {
maxStackSize: bigint;
maxSlots: number;
},
commands: Record<string, boolean>;
lootdrop: {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
}
export interface GuildConfig {
studentRole?: string;
visitorRole?: string;
colorRoles: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminal?: {
channelId: string;
messageId: string;
};
moderation: {
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
}
const guildConfigCache = new Map<string, { config: GuildConfig; timestamp: number }>();
const CACHE_TTL_MS = 60000;
export async function getGuildConfig(guildId: string): Promise<GuildConfig> {
const cached = guildConfigCache.get(guildId);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.config;
}
try {
const { guildSettingsService } = await import('@shared/modules/guild-settings/guild-settings.service');
const dbSettings = await guildSettingsService.getSettings(guildId);
if (dbSettings) {
const config: GuildConfig = {
studentRole: dbSettings.studentRoleId,
visitorRole: dbSettings.visitorRoleId,
colorRoles: dbSettings.colorRoleIds ?? [],
welcomeChannelId: dbSettings.welcomeChannelId,
welcomeMessage: dbSettings.welcomeMessage,
feedbackChannelId: dbSettings.feedbackChannelId,
terminal: dbSettings.terminalChannelId ? {
channelId: dbSettings.terminalChannelId,
messageId: dbSettings.terminalMessageId ?? "",
} : undefined,
moderation: {
cases: {
dmOnWarn: dbSettings.moderationDmOnWarn ?? true,
logChannelId: dbSettings.moderationLogChannelId,
autoTimeoutThreshold: dbSettings.moderationAutoTimeoutThreshold,
},
},
};
guildConfigCache.set(guildId, { config, timestamp: Date.now() });
return config;
}
} catch (error) {
console.error("Failed to load guild config from database:", error);
}
return {
studentRole: undefined,
visitorRole: undefined,
colorRoles: [],
welcomeChannelId: undefined,
welcomeMessage: undefined,
feedbackChannelId: undefined,
terminal: undefined,
moderation: { cases: { dmOnWarn: true } },
};
}
export function invalidateGuildConfigCache(guildId: string) {
guildConfigCache.delete(guildId);
}
export interface LevelingConfig {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
}
export interface EconomyConfig {
daily: {
amount: bigint;
streakBonus: bigint;
weeklyBonus: bigint;
cooldownMs: number;
};
transfers: {
allowSelfTransfer: boolean;
minAmount: bigint;
};
exam: {
multMin: number;
multMax: number;
};
}
export interface InventoryConfig {
maxStackSize: bigint;
maxSlots: number;
}
export interface LootdropConfig {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
}
export interface TriviaConfig {
entryFee: bigint;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: 'easy' | 'medium' | 'hard' | 'random';
}
export interface ModerationConfig {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
}
export interface GameConfigType {
leveling: LevelingConfig;
economy: EconomyConfig;
inventory: InventoryConfig;
commands: Record<string, boolean>;
lootdrop: LootdropConfig;
trivia: TriviaConfig;
moderation: ModerationConfig;
system: Record<string, unknown>;
studentRole: string;
visitorRole: string;
colorRoles: string[];
@@ -57,31 +168,8 @@ export interface GameConfigType {
channelId: string;
messageId: string;
};
moderation: {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
cases: {
dmOnWarn: boolean;
logChannelId?: string;
autoTimeoutThreshold?: number;
};
};
trivia: {
entryFee: bigint;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: 'easy' | 'medium' | 'hard' | 'random';
};
system: Record<string, any>;
}
// Initial default config state
export const config: GameConfigType = {} as GameConfigType;
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
@@ -95,7 +183,7 @@ const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
}, { message: "Must be a valid integer" })
.transform((val) => BigInt(val));
const configSchema = z.object({
const fileConfigSchema = z.object({
leveling: z.object({
base: z.number(),
exponent: z.number(),
@@ -136,7 +224,6 @@ const configSchema = z.object({
max: z.number(),
currency: z.string(),
})
}),
studentRole: z.string(),
visitorRole: z.string(),
@@ -189,44 +276,170 @@ const configSchema = z.object({
system: z.record(z.string(), z.any()).default({}),
});
export function reloadConfig() {
type FileConfig = z.infer<typeof fileConfigSchema>;
function loadFromFile(): FileConfig | null {
if (!existsSync(configPath)) {
throw new Error(`Config file not found at ${configPath}`);
return null;
}
const raw = readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(raw);
// Update config object in place
// We use Object.assign to keep the reference to the exported 'config' object same
const validatedConfig = configSchema.parse(rawConfig);
Object.assign(config, validatedConfig);
console.log("🔄 Config reloaded from disk.");
try {
const raw = readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(raw);
return fileConfigSchema.parse(rawConfig);
} catch (error) {
console.error("Failed to load config from file:", error);
return null;
}
}
// Initial load
reloadConfig();
function applyFileConfig(fileConfig: FileConfig) {
Object.assign(config, {
leveling: fileConfig.leveling,
economy: fileConfig.economy,
inventory: fileConfig.inventory,
commands: fileConfig.commands,
lootdrop: fileConfig.lootdrop,
trivia: fileConfig.trivia,
moderation: fileConfig.moderation,
system: fileConfig.system,
studentRole: fileConfig.studentRole,
visitorRole: fileConfig.visitorRole,
colorRoles: fileConfig.colorRoles,
welcomeChannelId: fileConfig.welcomeChannelId,
welcomeMessage: fileConfig.welcomeMessage,
feedbackChannelId: fileConfig.feedbackChannelId,
terminal: fileConfig.terminal,
});
}
async function loadFromDatabase(): Promise<boolean> {
try {
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
const dbSettings = await gameSettingsService.getSettings();
if (dbSettings) {
Object.assign(config, {
leveling: {
...dbSettings.leveling,
},
economy: {
daily: {
...dbSettings.economy.daily,
amount: BigInt(dbSettings.economy.daily.amount),
streakBonus: BigInt(dbSettings.economy.daily.streakBonus),
weeklyBonus: BigInt(dbSettings.economy.daily.weeklyBonus),
},
transfers: {
...dbSettings.economy.transfers,
minAmount: BigInt(dbSettings.economy.transfers.minAmount),
},
exam: dbSettings.economy.exam,
},
inventory: {
...dbSettings.inventory,
maxStackSize: BigInt(dbSettings.inventory.maxStackSize),
},
commands: dbSettings.commands,
lootdrop: dbSettings.lootdrop,
trivia: {
...dbSettings.trivia,
entryFee: BigInt(dbSettings.trivia.entryFee),
},
moderation: dbSettings.moderation,
system: dbSettings.system,
});
console.log("🎮 Game config loaded from database.");
return true;
}
} catch (error) {
console.error("Failed to load game config from database:", error);
}
return false;
}
export async function reloadConfig(): Promise<void> {
const dbLoaded = await loadFromDatabase();
if (!dbLoaded) {
const fileConfig = loadFromFile();
if (fileConfig) {
applyFileConfig(fileConfig);
console.log("📄 Game config loaded from file (database not available).");
} else {
console.warn("⚠️ No game config found in database or file. Using defaults.");
const { gameSettingsService } = await import('@shared/modules/game-settings/game-settings.service');
const defaults = gameSettingsService.getDefaults();
Object.assign(config, {
leveling: defaults.leveling,
economy: {
...defaults.economy,
daily: {
...defaults.economy.daily,
amount: BigInt(defaults.economy.daily.amount),
streakBonus: BigInt(defaults.economy.daily.streakBonus),
weeklyBonus: BigInt(defaults.economy.daily.weeklyBonus),
},
transfers: {
...defaults.economy.transfers,
minAmount: BigInt(defaults.economy.transfers.minAmount),
},
},
inventory: {
...defaults.inventory,
maxStackSize: BigInt(defaults.inventory.maxStackSize),
},
commands: defaults.commands,
lootdrop: defaults.lootdrop,
trivia: {
...defaults.trivia,
entryFee: BigInt(defaults.trivia.entryFee),
},
moderation: defaults.moderation,
system: defaults.system,
});
}
}
}
export function loadFileSync(): void {
const fileConfig = loadFromFile();
if (fileConfig) {
applyFileConfig(fileConfig);
console.log("📄 Game config loaded from file (sync).");
}
}
// Backwards compatibility alias
export const GameConfig = config;
export function saveConfig(newConfig: unknown) {
// Validate and transform input
const validatedConfig = configSchema.parse(newConfig);
const validatedConfig = fileConfigSchema.parse(newConfig);
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
writeFileSync(configPath, jsonString, 'utf-8');
reloadConfig();
applyFileConfig(validatedConfig);
console.log("🔄 Config saved to file.");
}
export function toggleCommand(commandName: string, enabled: boolean) {
const fileConfig = loadFromFile();
if (!fileConfig) {
console.error("Cannot toggle command: no file config available");
return;
}
const newConfig = {
...config,
...fileConfig,
commands: {
...config.commands,
...fileConfig.commands,
[commandName]: enabled
}
};
saveConfig(newConfig);
}
export async function initializeConfig(): Promise<void> {
loadFileSync();
await reloadConfig();
}
loadFileSync();

View File

@@ -7,6 +7,8 @@ export interface Command {
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
category?: string;
beta?: boolean;
featureFlag?: string;
}
export interface Event<K extends keyof ClientEvents> {

View File

@@ -116,7 +116,7 @@ class LootdropService {
});
// Trigger Terminal Update
terminalService.update();
terminalService.update(channel.guildId);
} catch (error) {
console.error("Failed to spawn lootdrop:", error);
@@ -153,7 +153,7 @@ class LootdropService {
`Claimed lootdrop in channel ${drop.channelId}`
);
// Trigger Terminal Update
// Trigger Terminal Update (uses primary guild from env)
terminalService.update();
return { success: true, amount: drop.rewardAmount, currency: drop.currency };

View File

@@ -0,0 +1,138 @@
import { eq, or, and, inArray } from "drizzle-orm";
import { featureFlags, featureFlagAccess } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { UserError } from "@shared/lib/errors";
export interface FeatureFlagContext {
guildId: string;
userId: string;
memberRoles: string[];
}
export const featureFlagsService = {
isFlagEnabled: async (flagName: string): Promise<boolean> => {
const flag = await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, flagName),
});
return flag?.enabled ?? false;
},
hasAccess: async (
flagName: string,
context: FeatureFlagContext
): Promise<boolean> => {
const flag = await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, flagName),
});
if (!flag || !flag.enabled) return false;
const access = await DrizzleClient.query.featureFlagAccess.findFirst({
where: and(
eq(featureFlagAccess.flagId, flag.id),
or(
eq(featureFlagAccess.guildId, BigInt(context.guildId)),
eq(featureFlagAccess.userId, BigInt(context.userId))
)
),
});
if (access) return true;
if (context.memberRoles.length > 0) {
const roleAccess = await DrizzleClient.query.featureFlagAccess.findFirst({
where: and(
eq(featureFlagAccess.flagId, flag.id),
inArray(featureFlagAccess.roleId, context.memberRoles.map(r => BigInt(r)))
),
});
return !!roleAccess;
}
return false;
},
createFlag: async (name: string, description?: string) => {
const [flag] = await DrizzleClient.insert(featureFlags).values({
name,
description,
enabled: false,
}).returning();
return flag;
},
setFlagEnabled: async (name: string, enabled: boolean) => {
const [flag] = await DrizzleClient.update(featureFlags)
.set({ enabled, updatedAt: new Date() })
.where(eq(featureFlags.name, name))
.returning();
if (!flag) {
throw new UserError(`Feature flag "${name}" not found`);
}
return flag;
},
grantAccess: async (
flagName: string,
access: { guildId?: string; userId?: string; roleId?: string }
) => {
const flag = await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, flagName),
});
if (!flag) throw new UserError(`Feature flag "${flagName}" not found`);
const [accessRecord] = await DrizzleClient.insert(featureFlagAccess).values({
flagId: flag.id,
guildId: access.guildId ? BigInt(access.guildId) : null,
userId: access.userId ? BigInt(access.userId) : null,
roleId: access.roleId ? BigInt(access.roleId) : null,
}).returning();
return accessRecord;
},
revokeAccess: async (accessId: number) => {
const [access] = await DrizzleClient.delete(featureFlagAccess)
.where(eq(featureFlagAccess.id, accessId))
.returning();
if (!access) {
throw new UserError(`Access record "${accessId}" not found`);
}
return access;
},
getFlag: async (name: string) => {
return await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, name),
});
},
listFlags: async () => {
return await DrizzleClient.query.featureFlags.findMany({
orderBy: (flags, { asc }) => [asc(flags.name)],
});
},
listAccess: async (flagName: string) => {
const flag = await DrizzleClient.query.featureFlags.findFirst({
where: eq(featureFlags.name, flagName),
});
if (!flag) return [];
return await DrizzleClient.query.featureFlagAccess.findMany({
where: eq(featureFlagAccess.flagId, flag.id),
orderBy: (access, { asc }) => [asc(access.id)],
});
},
deleteFlag: async (name: string) => {
const [flag] = await DrizzleClient.delete(featureFlags)
.where(eq(featureFlags.name, name))
.returning();
if (!flag) {
throw new UserError(`Feature flag "${name}" not found`);
}
return flag;
},
};

View File

@@ -0,0 +1,192 @@
import { eq } from "drizzle-orm";
import { gameSettings } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import type {
LevelingConfig,
EconomyConfig,
InventoryConfig,
LootdropConfig,
TriviaConfig,
ModerationConfig,
} from "@db/schema/game-settings";
export type GameSettingsData = {
leveling: LevelingConfig;
economy: EconomyConfig;
inventory: InventoryConfig;
lootdrop: LootdropConfig;
trivia: TriviaConfig;
moderation: ModerationConfig;
commands: Record<string, boolean>;
system: Record<string, unknown>;
};
let cachedSettings: GameSettingsData | null = null;
let cacheTimestamp = 0;
const CACHE_TTL_MS = 30000;
export const gameSettingsService = {
getSettings: async (useCache = true): Promise<GameSettingsData | null> => {
if (useCache && cachedSettings && Date.now() - cacheTimestamp < CACHE_TTL_MS) {
return cachedSettings;
}
const settings = await DrizzleClient.query.gameSettings.findFirst({
where: eq(gameSettings.id, "default"),
});
if (!settings) return null;
cachedSettings = {
leveling: settings.leveling,
economy: settings.economy,
inventory: settings.inventory,
lootdrop: settings.lootdrop,
trivia: settings.trivia,
moderation: settings.moderation,
commands: settings.commands ?? {},
system: settings.system ?? {},
};
cacheTimestamp = Date.now();
return cachedSettings;
},
upsertSettings: async (data: Partial<GameSettingsData>) => {
const existing = await gameSettingsService.getSettings(false);
const values: typeof gameSettings.$inferInsert = {
id: "default",
leveling: data.leveling ?? existing?.leveling ?? gameSettingsService.getDefaultLeveling(),
economy: data.economy ?? existing?.economy ?? gameSettingsService.getDefaultEconomy(),
inventory: data.inventory ?? existing?.inventory ?? gameSettingsService.getDefaultInventory(),
lootdrop: data.lootdrop ?? existing?.lootdrop ?? gameSettingsService.getDefaultLootdrop(),
trivia: data.trivia ?? existing?.trivia ?? gameSettingsService.getDefaultTrivia(),
moderation: data.moderation ?? existing?.moderation ?? gameSettingsService.getDefaultModeration(),
commands: data.commands ?? existing?.commands ?? {},
system: data.system ?? existing?.system ?? {},
updatedAt: new Date(),
};
const [result] = await DrizzleClient.insert(gameSettings)
.values(values)
.onConflictDoUpdate({
target: gameSettings.id,
set: values,
})
.returning();
gameSettingsService.invalidateCache();
return result;
},
updateSection: async <K extends keyof GameSettingsData>(
section: K,
value: GameSettingsData[K]
) => {
const existing = await gameSettingsService.getSettings(false);
if (!existing) {
throw new Error("Game settings not found. Initialize settings first.");
}
const updates: Partial<GameSettingsData> = { [section]: value };
await gameSettingsService.upsertSettings(updates);
gameSettingsService.invalidateCache();
},
toggleCommand: async (commandName: string, enabled: boolean) => {
const settings = await gameSettingsService.getSettings(false);
if (!settings) {
throw new Error("Game settings not found. Initialize settings first.");
}
const commands = {
...settings.commands,
[commandName]: enabled,
};
await gameSettingsService.updateSection("commands", commands);
},
invalidateCache: () => {
cachedSettings = null;
cacheTimestamp = 0;
},
getDefaultLeveling: (): LevelingConfig => ({
base: 100,
exponent: 1.5,
chat: {
cooldownMs: 60000,
minXp: 5,
maxXp: 15,
},
}),
getDefaultEconomy: (): EconomyConfig => ({
daily: {
amount: "100",
streakBonus: "10",
weeklyBonus: "50",
cooldownMs: 86400000,
},
transfers: {
allowSelfTransfer: false,
minAmount: "1",
},
exam: {
multMin: 1.0,
multMax: 2.0,
},
}),
getDefaultInventory: (): InventoryConfig => ({
maxStackSize: "99",
maxSlots: 20,
}),
getDefaultLootdrop: (): LootdropConfig => ({
activityWindowMs: 300000,
minMessages: 5,
spawnChance: 0.1,
cooldownMs: 60000,
reward: {
min: 10,
max: 50,
currency: "AU",
},
}),
getDefaultTrivia: (): TriviaConfig => ({
entryFee: "50",
rewardMultiplier: 1.8,
timeoutSeconds: 30,
cooldownMs: 60000,
categories: [],
difficulty: "random",
}),
getDefaultModeration: (): ModerationConfig => ({
prune: {
maxAmount: 100,
confirmThreshold: 50,
batchSize: 100,
batchDelayMs: 1000,
},
}),
getDefaults: (): GameSettingsData => ({
leveling: gameSettingsService.getDefaultLeveling(),
economy: gameSettingsService.getDefaultEconomy(),
inventory: gameSettingsService.getDefaultInventory(),
lootdrop: gameSettingsService.getDefaultLootdrop(),
trivia: gameSettingsService.getDefaultTrivia(),
moderation: gameSettingsService.getDefaultModeration(),
commands: {},
system: {},
}),
};

View File

@@ -0,0 +1,158 @@
import { eq } from "drizzle-orm";
import { guildSettings } from "@db/schema";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { UserError } from "@shared/lib/errors";
export interface GuildSettingsData {
guildId: string;
studentRoleId?: string;
visitorRoleId?: string;
colorRoleIds?: string[];
welcomeChannelId?: string;
welcomeMessage?: string;
feedbackChannelId?: string;
terminalChannelId?: string;
terminalMessageId?: string;
moderationLogChannelId?: string;
moderationDmOnWarn?: boolean;
moderationAutoTimeoutThreshold?: number;
featureOverrides?: Record<string, boolean>;
}
export const guildSettingsService = {
getSettings: async (guildId: string): Promise<GuildSettingsData | null> => {
const settings = await DrizzleClient.query.guildSettings.findFirst({
where: eq(guildSettings.guildId, BigInt(guildId)),
});
if (!settings) return null;
return {
guildId: settings.guildId.toString(),
studentRoleId: settings.studentRoleId?.toString(),
visitorRoleId: settings.visitorRoleId?.toString(),
colorRoleIds: settings.colorRoleIds ?? [],
welcomeChannelId: settings.welcomeChannelId?.toString(),
welcomeMessage: settings.welcomeMessage ?? undefined,
feedbackChannelId: settings.feedbackChannelId?.toString(),
terminalChannelId: settings.terminalChannelId?.toString(),
terminalMessageId: settings.terminalMessageId?.toString(),
moderationLogChannelId: settings.moderationLogChannelId?.toString(),
moderationDmOnWarn: settings.moderationDmOnWarn ?? true,
moderationAutoTimeoutThreshold: settings.moderationAutoTimeoutThreshold ?? undefined,
featureOverrides: settings.featureOverrides ?? {},
};
},
upsertSettings: async (data: Partial<GuildSettingsData> & { guildId: string }) => {
const values: typeof guildSettings.$inferInsert = {
guildId: BigInt(data.guildId),
studentRoleId: data.studentRoleId ? BigInt(data.studentRoleId) : null,
visitorRoleId: data.visitorRoleId ? BigInt(data.visitorRoleId) : null,
colorRoleIds: data.colorRoleIds ?? [],
welcomeChannelId: data.welcomeChannelId ? BigInt(data.welcomeChannelId) : null,
welcomeMessage: data.welcomeMessage ?? null,
feedbackChannelId: data.feedbackChannelId ? BigInt(data.feedbackChannelId) : null,
terminalChannelId: data.terminalChannelId ? BigInt(data.terminalChannelId) : null,
terminalMessageId: data.terminalMessageId ? BigInt(data.terminalMessageId) : null,
moderationLogChannelId: data.moderationLogChannelId ? BigInt(data.moderationLogChannelId) : null,
moderationDmOnWarn: data.moderationDmOnWarn ?? true,
moderationAutoTimeoutThreshold: data.moderationAutoTimeoutThreshold ?? null,
featureOverrides: data.featureOverrides ?? {},
updatedAt: new Date(),
};
const [result] = await DrizzleClient.insert(guildSettings)
.values(values)
.onConflictDoUpdate({
target: guildSettings.guildId,
set: values,
})
.returning();
return result;
},
updateSetting: async (
guildId: string,
key: string,
value: string | string[] | boolean | number | Record<string, boolean> | null
) => {
const keyMap: Record<string, keyof typeof guildSettings.$inferSelect> = {
studentRole: "studentRoleId",
visitorRole: "visitorRoleId",
colorRoles: "colorRoleIds",
welcomeChannel: "welcomeChannelId",
welcomeMessage: "welcomeMessage",
feedbackChannel: "feedbackChannelId",
terminalChannel: "terminalChannelId",
terminalMessage: "terminalMessageId",
moderationLogChannel: "moderationLogChannelId",
moderationDmOnWarn: "moderationDmOnWarn",
moderationAutoTimeoutThreshold: "moderationAutoTimeoutThreshold",
featureOverrides: "featureOverrides",
};
const column = keyMap[key];
if (!column) {
throw new UserError(`Unknown setting: ${key}`);
}
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (column === "colorRoleIds" && Array.isArray(value)) {
updates[column] = value;
} else if (column === "featureOverrides" && typeof value === "object" && value !== null) {
updates[column] = value;
} else if (column === "moderationDmOnWarn" && typeof value === "boolean") {
updates[column] = value;
} else if (column === "moderationAutoTimeoutThreshold" && typeof value === "number") {
updates[column] = value;
} else if (typeof value === "string") {
updates[column] = BigInt(value);
} else if (value === null) {
updates[column] = null;
} else {
updates[column] = value;
}
const [result] = await DrizzleClient.update(guildSettings)
.set(updates)
.where(eq(guildSettings.guildId, BigInt(guildId)))
.returning();
if (!result) {
throw new UserError(`No settings found for guild ${guildId}. Use /settings set to create settings first.`);
}
return result;
},
deleteSettings: async (guildId: string) => {
const [result] = await DrizzleClient.delete(guildSettings)
.where(eq(guildSettings.guildId, BigInt(guildId)))
.returning();
return result;
},
addColorRole: async (guildId: string, roleId: string) => {
const settings = await guildSettingsService.getSettings(guildId);
const colorRoleIds = settings?.colorRoleIds ?? [];
if (colorRoleIds.includes(roleId)) {
return settings;
}
colorRoleIds.push(roleId);
return await guildSettingsService.upsertSettings({ guildId, colorRoleIds });
},
removeColorRole: async (guildId: string, roleId: string) => {
const settings = await guildSettingsService.getSettings(guildId);
if (!settings) return null;
const colorRoleIds = (settings.colorRoleIds ?? []).filter(id => id !== roleId);
return await guildSettingsService.upsertSettings({ guildId, colorRoleIds });
},
};

View File

@@ -2,10 +2,14 @@ import { moderationCases } from "@db/schema";
import { eq, and, desc } from "drizzle-orm";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/modules/moderation/moderation.types";
import { config } from "@shared/lib/config";
import { getUserWarningEmbed } from "@/modules/moderation/moderation.view";
import { CaseType } from "@shared/lib/constants";
export interface ModerationConfig {
dmOnWarn?: boolean;
autoTimeoutThreshold?: number;
}
export class ModerationService {
/**
* Generate the next sequential case ID
@@ -62,6 +66,7 @@ export class ModerationService {
guildName?: string;
dmTarget?: { send: (options: any) => Promise<any> };
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
config?: ModerationConfig;
}) {
const moderationCase = await this.createCase({
type: CaseType.WARN,
@@ -77,9 +82,10 @@ export class ModerationService {
}
const warningCount = await this.getActiveWarningCount(options.userId);
const config = options.config ?? {};
// Try to DM the user if configured
if (config.moderation.cases.dmOnWarn && options.dmTarget) {
if (config.dmOnWarn !== false && options.dmTarget) {
try {
await options.dmTarget.send({
embeds: [getUserWarningEmbed(
@@ -96,8 +102,8 @@ export class ModerationService {
// Check for auto-timeout threshold
let autoTimeoutIssued = false;
if (config.moderation.cases.autoTimeoutThreshold &&
warningCount >= config.moderation.cases.autoTimeoutThreshold &&
if (config.autoTimeoutThreshold &&
warningCount >= config.autoTimeoutThreshold &&
options.timeoutTarget) {
try {

View File

@@ -12,24 +12,36 @@ import { AuroraClient } from "@/lib/BotClient";
import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, lootdrops, inventory } from "@db/schema";
import { desc, sql } from "drizzle-orm";
import { config, saveConfig } from "@shared/lib/config";
import { getGuildConfig, invalidateGuildConfigCache } from "@shared/lib/config";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { env } from "@shared/lib/env";
// Color palette for containers (hex as decimal)
const COLORS = {
HEADER: 0x9B59B6, // Purple - mystical
LEADERS: 0xF1C40F, // Gold - achievement
ACTIVITY: 0x3498DB, // Blue - activity
ALERT: 0xE74C3C // Red - active events
HEADER: 0x9B59B6,
LEADERS: 0xF1C40F,
ACTIVITY: 0x3498DB,
ALERT: 0xE74C3C
};
function getPrimaryGuildId(): string | null {
return env.DISCORD_GUILD_ID ?? null;
}
export const terminalService = {
init: async (channel: TextChannel) => {
// Limit to one terminal for now
if (config.terminal) {
const guildId = channel.guildId;
if (!guildId) {
console.error("Cannot initialize terminal: no guild ID");
return;
}
// Clean up old terminal if exists
const currentConfig = await getGuildConfig(guildId);
if (currentConfig.terminal?.channelId && currentConfig.terminal?.messageId) {
try {
const oldChannel = await AuroraClient.channels.fetch(config.terminal.channelId) as TextChannel;
const oldChannel = await AuroraClient.channels.fetch(currentConfig.terminal.channelId).catch(() => null) as TextChannel | null;
if (oldChannel) {
const oldMsg = await oldChannel.messages.fetch(config.terminal.messageId);
const oldMsg = await oldChannel.messages.fetch(currentConfig.terminal.messageId).catch(() => null);
if (oldMsg) await oldMsg.delete();
}
} catch (e) {
@@ -39,25 +51,37 @@ export const terminalService = {
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
config.terminal = {
channelId: channel.id,
messageId: msg.id
};
saveConfig(config);
// Save to database
await guildSettingsService.upsertSettings({
guildId,
terminalChannelId: channel.id,
terminalMessageId: msg.id,
});
invalidateGuildConfigCache(guildId);
await terminalService.update();
await terminalService.update(guildId);
},
update: async () => {
if (!config.terminal) return;
update: async (guildId?: string) => {
const effectiveGuildId = guildId ?? getPrimaryGuildId();
if (!effectiveGuildId) {
console.warn("No guild ID available for terminal update");
return;
}
const guildConfig = await getGuildConfig(effectiveGuildId);
if (!guildConfig.terminal?.channelId || !guildConfig.terminal?.messageId) {
return;
}
try {
const channel = await AuroraClient.channels.fetch(config.terminal.channelId).catch(() => null) as TextChannel;
const channel = await AuroraClient.channels.fetch(guildConfig.terminal.channelId).catch(() => null) as TextChannel | null;
if (!channel) {
console.warn("Terminal channel not found");
return;
}
const message = await channel.messages.fetch(config.terminal.messageId).catch(() => null);
const message = await channel.messages.fetch(guildConfig.terminal.messageId).catch(() => null);
if (!message) {
console.warn("Terminal message not found");
return;

View File

@@ -0,0 +1,51 @@
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { config } from "@shared/lib/config";
import { env } from "@shared/lib/env";
async function migrateConfigToDatabase() {
const guildId = env.DISCORD_GUILD_ID;
if (!guildId) {
console.error("DISCORD_GUILD_ID not set. Cannot migrate config.");
console.log("Set DISCORD_GUILD_ID in your environment to migrate config.");
process.exit(1);
}
console.log(`Migrating config for guild ${guildId}...`);
const existing = await guildSettingsService.getSettings(guildId);
if (existing) {
console.log("Guild settings already exist in database:");
console.log(JSON.stringify(existing, null, 2));
console.log("\nSkipping migration. Delete existing settings first if you want to re-migrate.");
return;
}
await guildSettingsService.upsertSettings({
guildId,
studentRoleId: config.studentRole ?? undefined,
visitorRoleId: config.visitorRole ?? undefined,
colorRoleIds: config.colorRoles ?? [],
welcomeChannelId: config.welcomeChannelId ?? undefined,
welcomeMessage: config.welcomeMessage ?? undefined,
feedbackChannelId: config.feedbackChannelId ?? undefined,
terminalChannelId: config.terminal?.channelId ?? undefined,
terminalMessageId: config.terminal?.messageId ?? undefined,
moderationLogChannelId: config.moderation?.cases?.logChannelId ?? undefined,
moderationDmOnWarn: config.moderation?.cases?.dmOnWarn ?? true,
moderationAutoTimeoutThreshold: config.moderation?.cases?.autoTimeoutThreshold ?? undefined,
});
console.log("✅ Migration complete!");
console.log("\nGuild settings are now stored in the database.");
console.log("You can manage them via:");
console.log(" - /settings command in Discord");
console.log(" - API endpoints at /api/guilds/:guildId/settings");
}
migrateConfigToDatabase()
.then(() => process.exit(0))
.catch((error) => {
console.error("Migration failed:", error);
process.exit(1);
});

View File

@@ -0,0 +1,128 @@
import { gameSettingsService } from "@shared/modules/game-settings/game-settings.service";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
interface FileConfig {
leveling: {
base: number;
exponent: number;
chat: {
cooldownMs: number;
minXp: number;
maxXp: number;
};
};
economy: {
daily: {
amount: string | number;
streakBonus: string | number;
weeklyBonus: string | number;
cooldownMs: number;
};
transfers: {
allowSelfTransfer: boolean;
minAmount: string | number;
};
exam: {
multMin: number;
multMax: number;
};
};
inventory: {
maxStackSize: string | number;
maxSlots: number;
};
lootdrop: {
activityWindowMs: number;
minMessages: number;
spawnChance: number;
cooldownMs: number;
reward: {
min: number;
max: number;
currency: string;
};
};
trivia: {
entryFee: string | number;
rewardMultiplier: number;
timeoutSeconds: number;
cooldownMs: number;
categories: number[];
difficulty: 'easy' | 'medium' | 'hard' | 'random';
};
moderation: {
prune: {
maxAmount: number;
confirmThreshold: number;
batchSize: number;
batchDelayMs: number;
};
};
commands: Record<string, boolean>;
system: Record<string, unknown>;
}
async function migrateGameSettingsToDatabase() {
console.log("🎮 Migrating game settings to database...\n");
const existing = await gameSettingsService.getSettings(false);
if (existing) {
console.log("Game settings already exist in database:");
console.log(JSON.stringify(existing, null, 2));
console.log("\nSkipping migration. Delete existing settings first if you want to re-migrate.");
return;
}
if (!existsSync(configPath)) {
console.log("No config.json file found. Creating default settings...");
await gameSettingsService.upsertSettings(gameSettingsService.getDefaults());
console.log("✅ Default game settings created in database.");
return;
}
const raw = readFileSync(configPath, 'utf-8');
const fileConfig: FileConfig = JSON.parse(raw);
await gameSettingsService.upsertSettings({
leveling: fileConfig.leveling,
economy: {
daily: {
amount: String(fileConfig.economy.daily.amount),
streakBonus: String(fileConfig.economy.daily.streakBonus),
weeklyBonus: String(fileConfig.economy.daily.weeklyBonus ?? 50),
cooldownMs: fileConfig.economy.daily.cooldownMs,
},
transfers: {
allowSelfTransfer: fileConfig.economy.transfers.allowSelfTransfer,
minAmount: String(fileConfig.economy.transfers.minAmount),
},
exam: fileConfig.economy.exam,
},
inventory: {
maxStackSize: String(fileConfig.inventory.maxStackSize),
maxSlots: fileConfig.inventory.maxSlots,
},
lootdrop: fileConfig.lootdrop,
trivia: {
...fileConfig.trivia,
entryFee: String(fileConfig.trivia.entryFee),
},
moderation: fileConfig.moderation,
commands: fileConfig.commands ?? {},
system: fileConfig.system ?? {},
});
console.log("✅ Game settings migrated to database!");
console.log("\nGame-wide settings are now stored in the database.");
console.log("The config.json file can be safely deleted after verification.");
}
migrateGameSettingsToDatabase()
.then(() => process.exit(0))
.catch((error) => {
console.error("Migration failed:", error);
process.exit(1);
});

View File

@@ -0,0 +1,64 @@
/**
* @fileoverview Guild settings endpoints for Aurora API.
* Provides endpoints for reading and updating per-guild configuration
* stored in the database.
*/
import type { RouteContext, RouteModule } from "./types";
import { jsonResponse, errorResponse, withErrorHandling } from "./utils";
import { guildSettingsService } from "@shared/modules/guild-settings/guild-settings.service";
import { invalidateGuildConfigCache } from "@shared/lib/config";
const GUILD_SETTINGS_PATTERN = /^\/api\/guilds\/(\d+)\/settings$/;
async function handler(ctx: RouteContext): Promise<Response | null> {
const { pathname, method, req } = ctx;
const match = pathname.match(GUILD_SETTINGS_PATTERN);
if (!match || !match[1]) {
return null;
}
const guildId = match[1];
if (method === "GET") {
return withErrorHandling(async () => {
const settings = await guildSettingsService.getSettings(guildId);
if (!settings) {
return jsonResponse({ guildId, configured: false });
}
return jsonResponse({ ...settings, guildId, configured: true });
}, "fetch guild settings");
}
if (method === "PUT" || method === "PATCH") {
try {
const body = await req.json() as Record<string, unknown>;
const { guildId: _, ...settings } = body;
const result = await guildSettingsService.upsertSettings({
guildId,
...settings,
} as Parameters<typeof guildSettingsService.upsertSettings>[0]);
invalidateGuildConfigCache(guildId);
return jsonResponse(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return errorResponse("Failed to save guild settings", 400, message);
}
}
if (method === "DELETE") {
return withErrorHandling(async () => {
await guildSettingsService.deleteSettings(guildId);
invalidateGuildConfigCache(guildId);
return jsonResponse({ success: true });
}, "delete guild settings");
}
return null;
}
export const guildSettingsRoutes: RouteModule = {
name: "guild-settings",
handler
};

View File

@@ -9,6 +9,7 @@ import { statsRoutes } from "./stats.routes";
import { actionsRoutes } from "./actions.routes";
import { questsRoutes } from "./quests.routes";
import { settingsRoutes } from "./settings.routes";
import { guildSettingsRoutes } from "./guild-settings.routes";
import { itemsRoutes } from "./items.routes";
import { usersRoutes } from "./users.routes";
import { classesRoutes } from "./classes.routes";
@@ -27,6 +28,7 @@ const routeModules: RouteModule[] = [
actionsRoutes,
questsRoutes,
settingsRoutes,
guildSettingsRoutes,
itemsRoutes,
usersRoutes,
classesRoutes,