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 { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, AttachmentBuilder } from "discord.js";
|
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 { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { items } from "@db/schema";
|
import { items } from "@db/schema";
|
||||||
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
import { createSuccessEmbed, createErrorEmbed } from "@lib/embeds";
|
||||||
@@ -49,7 +50,7 @@ export const createColor = createCommand({
|
|||||||
// 2. Create Role
|
// 2. Create Role
|
||||||
const role = await interaction.guild?.roles.create({
|
const role = await interaction.guild?.roles.create({
|
||||||
name: name,
|
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}`
|
reason: `Created via /createcolor by ${interaction.user.tag}`
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,11 +58,9 @@ export const createColor = createCommand({
|
|||||||
throw new Error("Failed to create role.");
|
throw new Error("Failed to create role.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update Config
|
// 3. Add to guild settings
|
||||||
if (!config.colorRoles.includes(role.id)) {
|
await guildSettingsService.addColorRole(interaction.guildId!, role.id);
|
||||||
config.colorRoles.push(role.id);
|
invalidateGuildConfigCache(interaction.guildId!);
|
||||||
saveConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Create Item
|
// 4. Create Item
|
||||||
await DrizzleClient.insert(items).values({
|
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,
|
getModerationErrorEmbed,
|
||||||
getUserWarningEmbed
|
getUserWarningEmbed
|
||||||
} from "@/modules/moderation/moderation.view";
|
} from "@/modules/moderation/moderation.view";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
export const warn = createCommand({
|
export const warn = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -50,6 +50,9 @@ export const warn = createCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch guild config for moderation settings
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Issue the warning via service
|
// Issue the warning via service
|
||||||
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
const { moderationCase, warningCount, autoTimeoutIssued } = await ModerationService.issueWarning({
|
||||||
userId: targetUser.id,
|
userId: targetUser.id,
|
||||||
@@ -59,7 +62,11 @@ export const warn = createCommand({
|
|||||||
reason,
|
reason,
|
||||||
guildName: interaction.guild?.name || undefined,
|
guildName: interaction.guild?.name || undefined,
|
||||||
dmTarget: targetUser,
|
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
|
// Send success message to moderator
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
import { createCommand } from "@shared/lib/utils";
|
||||||
import { SlashCommandBuilder } from "discord.js";
|
import { SlashCommandBuilder } from "discord.js";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
import { createErrorEmbed } from "@/lib/embeds";
|
import { createErrorEmbed } from "@/lib/embeds";
|
||||||
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
import { getFeedbackTypeMenu } from "@/modules/feedback/feedback.view";
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ export const feedback = createCommand({
|
|||||||
.setName("feedback")
|
.setName("feedback")
|
||||||
.setDescription("Submit feedback, feature requests, or bug reports"),
|
.setDescription("Submit feedback, feature requests, or bug reports"),
|
||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
|
||||||
// Check if feedback channel is configured
|
// Check if feedback channel is configured
|
||||||
if (!config.feedbackChannelId) {
|
if (!guildConfig.feedbackChannelId) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
embeds: [createErrorEmbed("Feedback system is not configured. Please contact an administrator.")],
|
||||||
ephemeral: true
|
ephemeral: true
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createErrorEmbed } from "@lib/embeds";
|
|||||||
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
import { getItemUseResultEmbed } from "@/modules/inventory/inventory.view";
|
||||||
import type { ItemUsageData } from "@shared/lib/types";
|
import type { ItemUsageData } from "@shared/lib/types";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { config } from "@shared/lib/config";
|
import { getGuildConfig } from "@shared/lib/config";
|
||||||
|
|
||||||
export const use = createCommand({
|
export const use = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -21,6 +21,9 @@ export const use = createCommand({
|
|||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
|
const guildConfig = await getGuildConfig(interaction.guildId!);
|
||||||
|
const colorRoles = guildConfig.colorRoles ?? [];
|
||||||
|
|
||||||
const itemId = interaction.options.getNumber("item", true);
|
const itemId = interaction.options.getNumber("item", true);
|
||||||
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
const user = await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -42,7 +45,7 @@ export const use = createCommand({
|
|||||||
await member.roles.add(effect.roleId);
|
await member.roles.add(effect.roleId);
|
||||||
} else if (effect.type === 'COLOR_ROLE') {
|
} else if (effect.type === 'COLOR_ROLE') {
|
||||||
// Remove existing color roles
|
// 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);
|
if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove);
|
||||||
await member.roles.add(effect.roleId);
|
await member.roles.add(effect.roleId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Events } from "discord.js";
|
import { Events } from "discord.js";
|
||||||
import type { Event } from "@shared/lib/types";
|
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";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
|
||||||
// Visitor role
|
|
||||||
const event: Event<Events.GuildMemberAdd> = {
|
const event: Event<Events.GuildMemberAdd> = {
|
||||||
name: Events.GuildMemberAdd,
|
name: Events.GuildMemberAdd,
|
||||||
execute: async (member) => {
|
execute: async (member) => {
|
||||||
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
console.log(`👤 New member joined: ${member.user.tag} (${member.id})`);
|
||||||
|
|
||||||
|
const guildConfig = await getGuildConfig(member.guild.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await userService.getUserById(member.id);
|
const user = await userService.getUserById(member.id);
|
||||||
|
|
||||||
if (user && user.class) {
|
if (user && user.class) {
|
||||||
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
console.log(`🔄 Returning student detected: ${member.user.tag}`);
|
||||||
await member.roles.remove(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
await member.roles.add(config.studentRole);
|
await member.roles.remove(guildConfig.visitorRole);
|
||||||
|
}
|
||||||
|
if (guildConfig.studentRole) {
|
||||||
|
await member.roles.add(guildConfig.studentRole);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.class.roleId) {
|
if (user.class.roleId) {
|
||||||
await member.roles.add(user.class.roleId);
|
await member.roles.add(user.class.roleId);
|
||||||
@@ -22,9 +28,11 @@ const event: Event<Events.GuildMemberAdd> = {
|
|||||||
}
|
}
|
||||||
console.log(`Restored student role to ${member.user.tag}`);
|
console.log(`Restored student role to ${member.user.tag}`);
|
||||||
} else {
|
} else {
|
||||||
await member.roles.add(config.visitorRole);
|
if (guildConfig.visitorRole) {
|
||||||
|
await member.roles.add(guildConfig.visitorRole);
|
||||||
console.log(`Assigned visitor role to ${member.user.tag}`);
|
console.log(`Assigned visitor role to ${member.user.tag}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
console.log(`User Roles: ${member.roles.cache.map(role => role.name).join(", ")}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
|
console.error(`Failed to handle role assignment for ${member.user.tag}:`, error);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
|
||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { userService } from "@shared/modules/user/user.service";
|
import { userService } from "@shared/modules/user/user.service";
|
||||||
|
import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service";
|
||||||
import { createErrorEmbed } from "@lib/embeds";
|
import { createErrorEmbed } from "@lib/embeds";
|
||||||
import { logger } from "@shared/lib/logger";
|
import { logger } from "@shared/lib/logger";
|
||||||
|
|
||||||
@@ -25,6 +26,37 @@ export class CommandHandler {
|
|||||||
return;
|
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
|
// Ensure user exists in database
|
||||||
try {
|
try {
|
||||||
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
await userService.getOrCreateUser(interaction.user.id, interaction.user.username);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Interaction } from "discord.js";
|
import type { Interaction } from "discord.js";
|
||||||
import { TextChannel, MessageFlags } 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 { AuroraClient } from "@/lib/BotClient";
|
||||||
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
import { buildFeedbackMessage, getFeedbackModal } from "./feedback.view";
|
||||||
import { FEEDBACK_CUSTOM_IDS, type FeedbackType, type FeedbackData } from "./feedback.types";
|
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.");
|
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.");
|
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
|
// 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) {
|
if (!channel) {
|
||||||
throw new UserError("Feedback channel not found. Please contact an administrator.");
|
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 { temporaryRoleService } from "@shared/modules/system/temp-role.service";
|
||||||
|
import { terminalService } from "@shared/modules/terminal/terminal.service";
|
||||||
|
|
||||||
export const schedulerService = {
|
export const schedulerService = {
|
||||||
start: () => {
|
start: () => {
|
||||||
@@ -10,7 +11,6 @@ export const schedulerService = {
|
|||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// 2. Terminal Update Loop (every 60s)
|
// 2. Terminal Update Loop (every 60s)
|
||||||
const { terminalService } = require("@shared/modules/terminal/terminal.service");
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ButtonInteraction, MessageFlags } from "discord.js";
|
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 { getEnrollmentSuccessMessage } from "./enrollment.view";
|
||||||
import { classService } from "@shared/modules/class/class.service";
|
import { classService } from "@shared/modules/class/class.service";
|
||||||
import { userService } from "@shared/modules/user/user.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.");
|
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) {
|
if (!studentRole || !visitorRole) {
|
||||||
throw new UserError("No student or visitor role configured for enrollment.");
|
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)
|
// 5. Send Welcome Message (if configured)
|
||||||
if (config.welcomeChannelId) {
|
if (welcomeChannelId) {
|
||||||
const welcomeChannel = interaction.guild.channels.cache.get(config.welcomeChannelId);
|
const welcomeChannel = interaction.guild.channels.cache.get(welcomeChannelId);
|
||||||
if (welcomeChannel && welcomeChannel.isTextBased()) {
|
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
|
const processedMessage = rawMessage
|
||||||
.replace(/{user}/g, member.toString())
|
.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",
|
"db:push:local": "drizzle-kit push",
|
||||||
"dev": "bun --watch bot/index.ts",
|
"dev": "bun --watch bot/index.ts",
|
||||||
"db:studio": "drizzle-kit studio --port 4983 --host 0.0.0.0",
|
"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",
|
"remote": "bash shared/scripts/remote.sh",
|
||||||
"logs": "bash shared/scripts/logs.sh",
|
"logs": "bash shared/scripts/logs.sh",
|
||||||
"db:backup": "bash shared/scripts/db-backup.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,
|
"when": 1767716705797,
|
||||||
"tag": "0002_fancy_forge",
|
"tag": "0002_fancy_forge",
|
||||||
"breakpoints": true
|
"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 './economy';
|
||||||
export * from './quests';
|
export * from './quests';
|
||||||
export * from './moderation';
|
export * from './moderation';
|
||||||
|
export * from './feature-flags';
|
||||||
|
export * from './guild-settings';
|
||||||
|
export * from './game-settings';
|
||||||
|
|||||||
@@ -5,50 +5,9 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
|
const configPath = join(import.meta.dir, '..', 'config', 'config.json');
|
||||||
|
|
||||||
export interface GameConfigType {
|
export interface GuildConfig {
|
||||||
leveling: {
|
studentRole?: string;
|
||||||
base: number;
|
visitorRole?: string;
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
studentRole: string;
|
|
||||||
visitorRole: string;
|
|
||||||
colorRoles: string[];
|
colorRoles: string[];
|
||||||
welcomeChannelId?: string;
|
welcomeChannelId?: string;
|
||||||
welcomeMessage?: string;
|
welcomeMessage?: string;
|
||||||
@@ -58,6 +17,125 @@ export interface GameConfigType {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
};
|
};
|
||||||
moderation: {
|
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: {
|
prune: {
|
||||||
maxAmount: number;
|
maxAmount: number;
|
||||||
confirmThreshold: number;
|
confirmThreshold: number;
|
||||||
@@ -69,19 +147,29 @@ export interface GameConfigType {
|
|||||||
logChannelId?: string;
|
logChannelId?: string;
|
||||||
autoTimeoutThreshold?: number;
|
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 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[];
|
||||||
|
welcomeChannelId?: string;
|
||||||
|
welcomeMessage?: string;
|
||||||
|
feedbackChannelId?: string;
|
||||||
|
terminal?: {
|
||||||
|
channelId: string;
|
||||||
|
messageId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const config: GameConfigType = {} as GameConfigType;
|
export const config: GameConfigType = {} as GameConfigType;
|
||||||
|
|
||||||
const bigIntSchema = z.union([z.string(), z.number(), z.bigint()])
|
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" })
|
}, { message: "Must be a valid integer" })
|
||||||
.transform((val) => BigInt(val));
|
.transform((val) => BigInt(val));
|
||||||
|
|
||||||
const configSchema = z.object({
|
const fileConfigSchema = z.object({
|
||||||
leveling: z.object({
|
leveling: z.object({
|
||||||
base: z.number(),
|
base: z.number(),
|
||||||
exponent: z.number(),
|
exponent: z.number(),
|
||||||
@@ -136,7 +224,6 @@ const configSchema = z.object({
|
|||||||
max: z.number(),
|
max: z.number(),
|
||||||
currency: z.string(),
|
currency: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
}),
|
}),
|
||||||
studentRole: z.string(),
|
studentRole: z.string(),
|
||||||
visitorRole: z.string(),
|
visitorRole: z.string(),
|
||||||
@@ -189,44 +276,170 @@ const configSchema = z.object({
|
|||||||
system: z.record(z.string(), z.any()).default({}),
|
system: z.record(z.string(), z.any()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function reloadConfig() {
|
type FileConfig = z.infer<typeof fileConfigSchema>;
|
||||||
|
|
||||||
|
function loadFromFile(): FileConfig | null {
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
throw new Error(`Config file not found at ${configPath}`);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const raw = readFileSync(configPath, 'utf-8');
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
const rawConfig = JSON.parse(raw);
|
const rawConfig = JSON.parse(raw);
|
||||||
|
return fileConfigSchema.parse(rawConfig);
|
||||||
// Update config object in place
|
} catch (error) {
|
||||||
// We use Object.assign to keep the reference to the exported 'config' object same
|
console.error("Failed to load config from file:", error);
|
||||||
const validatedConfig = configSchema.parse(rawConfig);
|
return null;
|
||||||
Object.assign(config, validatedConfig);
|
}
|
||||||
|
|
||||||
console.log("🔄 Config reloaded from disk.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load
|
function applyFileConfig(fileConfig: FileConfig) {
|
||||||
reloadConfig();
|
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 const GameConfig = config;
|
||||||
|
|
||||||
export function saveConfig(newConfig: unknown) {
|
export function saveConfig(newConfig: unknown) {
|
||||||
// Validate and transform input
|
const validatedConfig = fileConfigSchema.parse(newConfig);
|
||||||
const validatedConfig = configSchema.parse(newConfig);
|
|
||||||
|
|
||||||
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
|
const jsonString = JSON.stringify(validatedConfig, jsonReplacer, 4);
|
||||||
writeFileSync(configPath, jsonString, 'utf-8');
|
writeFileSync(configPath, jsonString, 'utf-8');
|
||||||
reloadConfig();
|
applyFileConfig(validatedConfig);
|
||||||
|
console.log("🔄 Config saved to file.");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleCommand(commandName: string, enabled: boolean) {
|
export function toggleCommand(commandName: string, enabled: boolean) {
|
||||||
|
const fileConfig = loadFromFile();
|
||||||
|
if (!fileConfig) {
|
||||||
|
console.error("Cannot toggle command: no file config available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...config,
|
...fileConfig,
|
||||||
commands: {
|
commands: {
|
||||||
...config.commands,
|
...fileConfig.commands,
|
||||||
[commandName]: enabled
|
[commandName]: enabled
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
saveConfig(newConfig);
|
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;
|
execute: (interaction: ChatInputCommandInteraction) => Promise<void> | void;
|
||||||
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
|
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void> | void;
|
||||||
category?: string;
|
category?: string;
|
||||||
|
beta?: boolean;
|
||||||
|
featureFlag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Event<K extends keyof ClientEvents> {
|
export interface Event<K extends keyof ClientEvents> {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class LootdropService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Trigger Terminal Update
|
// Trigger Terminal Update
|
||||||
terminalService.update();
|
terminalService.update(channel.guildId);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to spawn lootdrop:", error);
|
console.error("Failed to spawn lootdrop:", error);
|
||||||
@@ -153,7 +153,7 @@ class LootdropService {
|
|||||||
`Claimed lootdrop in channel ${drop.channelId}`
|
`Claimed lootdrop in channel ${drop.channelId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger Terminal Update
|
// Trigger Terminal Update (uses primary guild from env)
|
||||||
terminalService.update();
|
terminalService.update();
|
||||||
|
|
||||||
return { success: true, amount: drop.rewardAmount, currency: drop.currency };
|
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 { eq, and, desc } from "drizzle-orm";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/modules/moderation/moderation.types";
|
import type { CreateCaseOptions, ClearCaseOptions, SearchCasesFilter } from "@/modules/moderation/moderation.types";
|
||||||
import { config } from "@shared/lib/config";
|
|
||||||
import { getUserWarningEmbed } from "@/modules/moderation/moderation.view";
|
import { getUserWarningEmbed } from "@/modules/moderation/moderation.view";
|
||||||
import { CaseType } from "@shared/lib/constants";
|
import { CaseType } from "@shared/lib/constants";
|
||||||
|
|
||||||
|
export interface ModerationConfig {
|
||||||
|
dmOnWarn?: boolean;
|
||||||
|
autoTimeoutThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class ModerationService {
|
export class ModerationService {
|
||||||
/**
|
/**
|
||||||
* Generate the next sequential case ID
|
* Generate the next sequential case ID
|
||||||
@@ -62,6 +66,7 @@ export class ModerationService {
|
|||||||
guildName?: string;
|
guildName?: string;
|
||||||
dmTarget?: { send: (options: any) => Promise<any> };
|
dmTarget?: { send: (options: any) => Promise<any> };
|
||||||
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
|
timeoutTarget?: { timeout: (duration: number, reason: string) => Promise<any> };
|
||||||
|
config?: ModerationConfig;
|
||||||
}) {
|
}) {
|
||||||
const moderationCase = await this.createCase({
|
const moderationCase = await this.createCase({
|
||||||
type: CaseType.WARN,
|
type: CaseType.WARN,
|
||||||
@@ -77,9 +82,10 @@ export class ModerationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const warningCount = await this.getActiveWarningCount(options.userId);
|
const warningCount = await this.getActiveWarningCount(options.userId);
|
||||||
|
const config = options.config ?? {};
|
||||||
|
|
||||||
// Try to DM the user if configured
|
// Try to DM the user if configured
|
||||||
if (config.moderation.cases.dmOnWarn && options.dmTarget) {
|
if (config.dmOnWarn !== false && options.dmTarget) {
|
||||||
try {
|
try {
|
||||||
await options.dmTarget.send({
|
await options.dmTarget.send({
|
||||||
embeds: [getUserWarningEmbed(
|
embeds: [getUserWarningEmbed(
|
||||||
@@ -96,8 +102,8 @@ export class ModerationService {
|
|||||||
|
|
||||||
// Check for auto-timeout threshold
|
// Check for auto-timeout threshold
|
||||||
let autoTimeoutIssued = false;
|
let autoTimeoutIssued = false;
|
||||||
if (config.moderation.cases.autoTimeoutThreshold &&
|
if (config.autoTimeoutThreshold &&
|
||||||
warningCount >= config.moderation.cases.autoTimeoutThreshold &&
|
warningCount >= config.autoTimeoutThreshold &&
|
||||||
options.timeoutTarget) {
|
options.timeoutTarget) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,24 +12,36 @@ import { AuroraClient } from "@/lib/BotClient";
|
|||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
import { users, transactions, lootdrops, inventory } from "@db/schema";
|
import { users, transactions, lootdrops, inventory } from "@db/schema";
|
||||||
import { desc, sql } from "drizzle-orm";
|
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 = {
|
const COLORS = {
|
||||||
HEADER: 0x9B59B6, // Purple - mystical
|
HEADER: 0x9B59B6,
|
||||||
LEADERS: 0xF1C40F, // Gold - achievement
|
LEADERS: 0xF1C40F,
|
||||||
ACTIVITY: 0x3498DB, // Blue - activity
|
ACTIVITY: 0x3498DB,
|
||||||
ALERT: 0xE74C3C // Red - active events
|
ALERT: 0xE74C3C
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getPrimaryGuildId(): string | null {
|
||||||
|
return env.DISCORD_GUILD_ID ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export const terminalService = {
|
export const terminalService = {
|
||||||
init: async (channel: TextChannel) => {
|
init: async (channel: TextChannel) => {
|
||||||
// Limit to one terminal for now
|
const guildId = channel.guildId;
|
||||||
if (config.terminal) {
|
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 {
|
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) {
|
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();
|
if (oldMsg) await oldMsg.delete();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -39,25 +51,37 @@ export const terminalService = {
|
|||||||
|
|
||||||
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
|
const msg = await channel.send({ content: "🔄 Initializing Aurora Station..." });
|
||||||
|
|
||||||
config.terminal = {
|
// Save to database
|
||||||
channelId: channel.id,
|
await guildSettingsService.upsertSettings({
|
||||||
messageId: msg.id
|
guildId,
|
||||||
};
|
terminalChannelId: channel.id,
|
||||||
saveConfig(config);
|
terminalMessageId: msg.id,
|
||||||
|
});
|
||||||
|
invalidateGuildConfigCache(guildId);
|
||||||
|
|
||||||
await terminalService.update();
|
await terminalService.update(guildId);
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async () => {
|
update: async (guildId?: string) => {
|
||||||
if (!config.terminal) return;
|
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 {
|
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) {
|
if (!channel) {
|
||||||
console.warn("Terminal channel not found");
|
console.warn("Terminal channel not found");
|
||||||
return;
|
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) {
|
if (!message) {
|
||||||
console.warn("Terminal message not found");
|
console.warn("Terminal message not found");
|
||||||
return;
|
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 { actionsRoutes } from "./actions.routes";
|
||||||
import { questsRoutes } from "./quests.routes";
|
import { questsRoutes } from "./quests.routes";
|
||||||
import { settingsRoutes } from "./settings.routes";
|
import { settingsRoutes } from "./settings.routes";
|
||||||
|
import { guildSettingsRoutes } from "./guild-settings.routes";
|
||||||
import { itemsRoutes } from "./items.routes";
|
import { itemsRoutes } from "./items.routes";
|
||||||
import { usersRoutes } from "./users.routes";
|
import { usersRoutes } from "./users.routes";
|
||||||
import { classesRoutes } from "./classes.routes";
|
import { classesRoutes } from "./classes.routes";
|
||||||
@@ -27,6 +28,7 @@ const routeModules: RouteModule[] = [
|
|||||||
actionsRoutes,
|
actionsRoutes,
|
||||||
questsRoutes,
|
questsRoutes,
|
||||||
settingsRoutes,
|
settingsRoutes,
|
||||||
|
guildSettingsRoutes,
|
||||||
itemsRoutes,
|
itemsRoutes,
|
||||||
usersRoutes,
|
usersRoutes,
|
||||||
classesRoutes,
|
classesRoutes,
|
||||||
|
|||||||
Reference in New Issue
Block a user