Compare commits
15 Commits
73ad889018
...
c2b1fb6db1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b1fb6db1 | ||
|
|
d15d53e839 | ||
|
|
58374d1746 | ||
|
|
ae6a068197 | ||
|
|
43d32918ab | ||
|
|
0bc254b728 | ||
|
|
610d97bde3 | ||
|
|
babccfd08a | ||
|
|
ee7d63df3e | ||
|
|
5f107d03a7 | ||
|
|
1ff24b0f7f | ||
|
|
a5e3534260 | ||
|
|
228005322e | ||
|
|
67a3aa4b0f | ||
|
|
64804f7066 |
@@ -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({
|
||||
|
||||
297
bot/commands/admin/featureflags.ts
Normal file
297
bot/commands/admin/featureflags.ts
Normal 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] });
|
||||
}
|
||||
247
bot/commands/admin/settings.ts
Normal file
247
bot/commands/admin/settings.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
168
docs/feature-flags.md
Normal 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
199
docs/guild-settings.md
Normal 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 |
|
||||
@@ -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",
|
||||
|
||||
25
shared/db/migrations/0003_new_senator_kelly.sql
Normal file
25
shared/db/migrations/0003_new_senator_kelly.sql
Normal 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");
|
||||
17
shared/db/migrations/0004_bored_kat_farrell.sql
Normal file
17
shared/db/migrations/0004_bored_kat_farrell.sql
Normal 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
|
||||
);
|
||||
1205
shared/db/migrations/meta/0003_snapshot.json
Normal file
1205
shared/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1313
shared/db/migrations/meta/0004_snapshot.json
Normal file
1313
shared/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
49
shared/db/schema/feature-flags.ts
Normal file
49
shared/db/schema/feature-flags.ts
Normal 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],
|
||||
}),
|
||||
}));
|
||||
88
shared/db/schema/game-settings.ts
Normal file
88
shared/db/schema/game-settings.ts
Normal 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, () => ({}));
|
||||
31
shared/db/schema/guild-settings.ts
Normal file
31
shared/db/schema/guild-settings.ts
Normal 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, () => ({}));
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
138
shared/modules/feature-flags/feature-flags.service.ts
Normal file
138
shared/modules/feature-flags/feature-flags.service.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
192
shared/modules/game-settings/game-settings.service.ts
Normal file
192
shared/modules/game-settings/game-settings.service.ts
Normal 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: {},
|
||||
}),
|
||||
};
|
||||
158
shared/modules/guild-settings/guild-settings.service.ts
Normal file
158
shared/modules/guild-settings/guild-settings.service.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
51
shared/scripts/migrate-config-to-db.ts
Normal file
51
shared/scripts/migrate-config-to-db.ts
Normal 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);
|
||||
});
|
||||
128
shared/scripts/migrate-game-settings-to-db.ts
Normal file
128
shared/scripts/migrate-game-settings-to-db.ts
Normal 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);
|
||||
});
|
||||
64
web/src/routes/guild-settings.routes.ts
Normal file
64
web/src/routes/guild-settings.routes.ts
Normal 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
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user