From cf9c88a2d652fda4fe13f2a6b6c272a128283f43 Mon Sep 17 00:00:00 2001 From: Michel Fedde Date: Tue, 24 Jun 2025 20:58:46 +0200 Subject: [PATCH] feat(permissions): Adds server permissions --- build/context.mjs | 4 +- package-lock.json | 8 ++ package.json | 2 + source/Configuration/ConfigurationHandler.ts | 26 ++++-- .../Groups/GroupConfigurationProvider.ts | 9 ++- source/Configuration/MenuHandler.ts | 46 ++++++----- .../Server/ServerConfigurationProvider.ts | 70 ++++++++++++++++ source/Container/Services.ts | 2 + source/Database/DatabaseUpdater.ts | 4 +- .../Models/ServerConfigurationModel.ts | 6 ++ .../ServerConfigurationRepository.ts | 63 +++++++++++++++ source/Database/definitions.ts | 4 +- source/Database/tables/ServerConfiguration.ts | 37 +++++++++ .../Discord/CommandPartials/GroupSelection.ts | 4 + source/Discord/Commands/Commands.ts | 4 +- source/Discord/Commands/Groups.ts | 33 ++++++-- source/Discord/Commands/Playdates.ts | 5 +- source/Discord/Commands/Server.ts | 79 +++++++++++++++++++ source/Discord/InteractionRouter.ts | 17 ++-- source/Discord/PermissionError.ts | 24 ++++++ source/Discord/UserError.ts | 16 ++++ source/Events/Handlers/ReminderEvent.ts | 6 +- .../Handlers/SendCreatedNotification.ts | 2 +- source/Menu/MenuRenderer.ts | 13 +-- 24 files changed, 415 insertions(+), 69 deletions(-) create mode 100644 source/Configuration/Server/ServerConfigurationProvider.ts create mode 100644 source/Database/Models/ServerConfigurationModel.ts create mode 100644 source/Database/Repositories/ServerConfigurationRepository.ts create mode 100644 source/Database/tables/ServerConfiguration.ts create mode 100644 source/Discord/Commands/Server.ts create mode 100644 source/Discord/PermissionError.ts diff --git a/build/context.mjs b/build/context.mjs index 48d8f02..433a16c 100644 --- a/build/context.mjs +++ b/build/context.mjs @@ -1,6 +1,8 @@ import * as esbuild from "esbuild"; import path from "path"; +const isDev = process.env.BUILD_TARGET !== 'DOCKER'; + const context = await esbuild.context({ entryPoints: [ path.join('source', 'main.ts'), @@ -10,7 +12,7 @@ const context = await esbuild.context({ outdir: './dist/', platform: 'node', target: 'node10.4', - sourcemap: 'linked', + sourcemap: isDev ? 'external' : null, loader: { '.node': 'copy', } diff --git a/package-lock.json b/package-lock.json index 47de5ab..c4da1be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@types/better-sqlite3": "^7.6.12", "@types/deepmerge": "^2.1.0", + "@types/lodash": "^4.17.18", "@types/log4js": "^0.0.33", "@types/node": "^22.13.9", "better-sqlite3": "^11.8.1", @@ -20,6 +21,7 @@ "esbuild": "^0.25.0", "ics": "^3.8.1", "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", "log4js": "^6.9.1", "node-cron": "^4.0.7", "node-ical": "^0.20.1", @@ -1636,6 +1638,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.18", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz", + "integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==", + "license": "MIT" + }, "node_modules/@types/log4js": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/log4js/-/log4js-0.0.33.tgz", diff --git a/package.json b/package.json index 2d514a6..0a15d2b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@types/better-sqlite3": "^7.6.12", "@types/deepmerge": "^2.1.0", + "@types/lodash": "^4.17.18", "@types/log4js": "^0.0.33", "@types/node": "^22.13.9", "better-sqlite3": "^11.8.1", @@ -26,6 +27,7 @@ "esbuild": "^0.25.0", "ics": "^3.8.1", "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", "log4js": "^6.9.1", "node-cron": "^4.0.7", "node-ical": "^0.20.1", diff --git a/source/Configuration/ConfigurationHandler.ts b/source/Configuration/ConfigurationHandler.ts index 606f4bd..64ff884 100644 --- a/source/Configuration/ConfigurationHandler.ts +++ b/source/Configuration/ConfigurationHandler.ts @@ -7,6 +7,17 @@ import deepmerge from "deepmerge"; import {isPlainObject} from "is-plain-object"; import {Nullable} from "../types/Nullable"; import {ConfigurationTransformer, TransformerResults} from "./ConfigurationTransformer"; +import _ from "lodash"; + +export enum PathConfigurationFrom { + Database, + Default +} + +export type PathConfiguration = { + value: TransformerResults, + from: PathConfigurationFrom +} export class ConfigurationHandler< TProviderModel extends ConfigurationModel = ConfigurationModel, @@ -48,22 +59,27 @@ export class ConfigurationHandler< ) } - public getConfigurationByPath(path: string): Nullable { + public getConfigurationByPath(path: string): PathConfiguration { const configuration = this.provider.get(path); if (!configuration) { - return; + return { + value: _.get(this.provider.defaults, path, null), + from: PathConfigurationFrom.Default + }; } - return this.transformer.getValue(configuration); + return { + value: this.transformer.getValue(configuration), + from: PathConfigurationFrom.Database + }; } - private getCompleteDatabaseConfig(): Partial { const values = this.provider.getAll(); const configuration: Partial = {}; values.forEach((configValue) => { const value = this.transformer.getValue(configValue); - setPath(configuration, configValue.key, value); + _.set(configuration, configValue.key, value); }) return configuration; diff --git a/source/Configuration/Groups/GroupConfigurationProvider.ts b/source/Configuration/Groups/GroupConfigurationProvider.ts index ed7cd83..1eb8c5b 100644 --- a/source/Configuration/Groups/GroupConfigurationProvider.ts +++ b/source/Configuration/Groups/GroupConfigurationProvider.ts @@ -29,9 +29,6 @@ export type CalendarRuntimeGroupConfiguration = { location: null | string } -export type GroupConfigurationResult = - ChannelId | Intl.Locale | boolean | string | null - export class GroupConfigurationProvider implements ConfigurationProvider< GroupConfigurationModel, RuntimeGroupConfiguration @@ -66,9 +63,13 @@ export class GroupConfigurationProvider implements ConfigurationProvider< if (value.id) { // @ts-expect-error id is set, due to the check on line above this.repository.update(value); + return } - this.repository.create(value); + this.repository.create({ + ...value, + group: this.group + }); } getTransformer(): ConfigurationTransformer { return new ConfigurationTransformer( diff --git a/source/Configuration/MenuHandler.ts b/source/Configuration/MenuHandler.ts index cdab74d..289fcb5 100644 --- a/source/Configuration/MenuHandler.ts +++ b/source/Configuration/MenuHandler.ts @@ -1,8 +1,10 @@ -import {ConfigurationHandler} from "./ConfigurationHandler"; +import {ConfigurationHandler, PathConfigurationFrom} from "./ConfigurationHandler"; import { AnyMenuItem, CollectionMenuItem, - FieldMenuItem, FieldMenuItemContext, FieldMenuItemSaveValue, + FieldMenuItem, + FieldMenuItemContext, + FieldMenuItemSaveValue, MenuItem, MenuItemType, PromptMenuItem @@ -12,11 +14,11 @@ import { channelMention, ChannelSelectMenuBuilder, ChannelType, - inlineCode, italic, - StringSelectMenuBuilder, TextInputBuilder, TextInputStyle + StringSelectMenuBuilder, + TextInputBuilder, + TextInputStyle } from "discord.js"; -import {ChannelId} from "../types/DiscordTypes"; import {MessageActionRowComponentBuilder} from "@discordjs/builders"; import {TransformerType} from "./ConfigurationTransformer"; @@ -101,14 +103,14 @@ export class MenuHandler { private getChannelValue(context: FieldMenuItemContext): string { const value = this.config.getConfigurationByPath(context.path.join('.')); - if (value === undefined) { - return italic("None"); + + const isDefault = value.from === PathConfigurationFrom.Default; + const display = !value ? "None" : channelMention(value.value); + + if (isDefault) { + return italic(`Default (${display})`) } - - if (!value) { - return inlineCode("None"); - } - return channelMention(value); + return display; } private getChannelMenuBuilder(): MessageActionRowComponentBuilder { @@ -119,11 +121,14 @@ export class MenuHandler { private getPermissionBooleanValue(context: FieldMenuItemContext) { const value = this.config.getConfigurationByPath(context.path.join('.')); - if (value === undefined) { - return italic("None"); - } - return value ? 'Allowed' : "Disallowed"; + const isDefault = value.from === PathConfigurationFrom.Default; + const display = value.value === null ? "None" : value.value == true ? "Allowed" : "Disallowed"; + + if (isDefault) { + return italic(`Default (${display})`) + } + return display; } private getPermissionBooleanBuilder() { @@ -144,15 +149,8 @@ export class MenuHandler { private getStringValue(context: FieldMenuItemContext): string { const value = this.config.getConfigurationByPath(context.path.join('.')); - if (!value) { - return ""; - } - if (typeof value !== 'string') { - throw new TypeError(`Value of type ${typeof value} can't be used for a string value!`) - } - - return value; + return value.value === null ? "" : value.value; } private getStringBuilder(): TextInputBuilder { diff --git a/source/Configuration/Server/ServerConfigurationProvider.ts b/source/Configuration/Server/ServerConfigurationProvider.ts new file mode 100644 index 0000000..df329ab --- /dev/null +++ b/source/Configuration/Server/ServerConfigurationProvider.ts @@ -0,0 +1,70 @@ +import {ConfigurationProvider} from "../ConfigurationProvider"; +import {ServerConfigurationModel} from "../../Database/Models/ServerConfigurationModel"; +import {ServerConfigurationRepository} from "../../Database/Repositories/ServerConfigurationRepository"; +import {Snowflake} from "discord.js"; +import { ConfigurationModel } from "../../Database/Models/ConfigurationModel"; +import { Model } from "../../Database/Models/Model"; +import { Nullable } from "../../types/Nullable"; +import {ConfigurationTransformer, TransformerType} from "../ConfigurationTransformer"; + +export type RuntimeServerConfiguration = { + permissions: PermissionRuntimeServerConfiguration +} + +export type PermissionRuntimeServerConfiguration = { + groupCreation: GroupCreatePermissionRuntimeServerConfiguration +} + +export type GroupCreatePermissionRuntimeServerConfiguration = { + allowEveryone: boolean +} + +export class ServerConfigurationProvider implements ConfigurationProvider< + ServerConfigurationModel, + RuntimeServerConfiguration +> { + constructor( + private readonly repository: ServerConfigurationRepository, + private readonly serverid: Snowflake + ) { + } + + get defaults(): RuntimeServerConfiguration { + return { + permissions: { + groupCreation: { + allowEveryone: false + } + } + } + } + get(path: string): Nullable { + return this.repository.findConfigurationByPath(this.serverid, path); + } + getAll(): ServerConfigurationModel[] { + return this.repository.findServerConfigurations(this.serverid); + } + save(value: Omit & Partial): void { + if (value.id) { + // @ts-expect-error id is set, due to the check on line above + this.repository.update(value); + return + } + + this.repository.create({ + ...value, + serverid: this.serverid + }); + } + getTransformer(): ConfigurationTransformer { + return new ConfigurationTransformer( + [ + { + path: ['permissions', 'groupCreation', 'allowEveryone'], + type: TransformerType.PermissionBoolean + } + ] + ) + } + +} \ No newline at end of file diff --git a/source/Container/Services.ts b/source/Container/Services.ts index 324bdbb..5cf4093 100644 --- a/source/Container/Services.ts +++ b/source/Container/Services.ts @@ -14,6 +14,7 @@ import Commands from "../Discord/Commands/Commands"; import {CommandDeployer} from "../Discord/CommandDeployer"; import {REST} from "discord.js"; import {log} from "node:util"; +import {ServerConfigurationRepository} from "../Database/Repositories/ServerConfigurationRepository"; export enum ServiceHint { App, @@ -58,6 +59,7 @@ export class Services { container.set(new GroupRepository(db)); container.set(new PlaydateRepository(db, container.get(GroupRepository.name))) container.set(new GroupConfigurationRepository(db, container.get(GroupRepository.name))) + container.set(new ServerConfigurationRepository(db)); } private static setupLogger(hint: ServiceHint): Logger { diff --git a/source/Database/DatabaseUpdater.ts b/source/Database/DatabaseUpdater.ts index c83b929..edad8de 100644 --- a/source/Database/DatabaseUpdater.ts +++ b/source/Database/DatabaseUpdater.ts @@ -28,7 +28,7 @@ export class DatabaseUpdater { ); if (!DBSQLColumns) { - Container.get("logger").log("Request failed..."); + Container.get("logger").warn("Request for database columns failed!"); return; } @@ -41,7 +41,7 @@ export class DatabaseUpdater { ) if (missingColumns.length < 1) { - Container.get("logger").log(`No new columns found for ${definition.name}`) + Container.get("logger").debug(`No new columns found for ${definition.name}`) return; } diff --git a/source/Database/Models/ServerConfigurationModel.ts b/source/Database/Models/ServerConfigurationModel.ts new file mode 100644 index 0000000..74f37c3 --- /dev/null +++ b/source/Database/Models/ServerConfigurationModel.ts @@ -0,0 +1,6 @@ +import {ConfigurationModel} from "./ConfigurationModel"; +import {Snowflake} from "discord.js"; + +export type ServerConfigurationModel = ConfigurationModel & { + serverid: Snowflake +} diff --git a/source/Database/Repositories/ServerConfigurationRepository.ts b/source/Database/Repositories/ServerConfigurationRepository.ts new file mode 100644 index 0000000..3ccbe6c --- /dev/null +++ b/source/Database/Repositories/ServerConfigurationRepository.ts @@ -0,0 +1,63 @@ +import {Repository} from "./Repository"; +import {Nullable} from "../../types/Nullable"; +import {DatabaseConnection} from "../DatabaseConnection"; +import {ServerConfigurationModel} from "../Models/ServerConfigurationModel"; +import ServerConfiguration, {DBServerConfiguration} from "../tables/ServerConfiguration"; +import {Snowflake} from "discord.js"; + +export class ServerConfigurationRepository extends Repository { + + constructor( + protected readonly database: DatabaseConnection, + ) { + super( + database, + ServerConfiguration + ); + } + + public findServerConfigurations(server: Snowflake): ServerConfigurationModel[] { + return this.database.fetchAll( + `SELECT * FROM serverConfiguration WHERE serverid = ?`, + server + ).map((config) => { + return this.convertToModelType(config); + }) + } + + public findConfigurationByPath(server: Snowflake, path: string): Nullable { + const result = this.database.fetch( + `SELECT * FROM serverConfiguration WHERE serverid = ? AND key = ?`, + server, + path + ); + + if (!result) { + return null; + } + + return this.convertToModelType(result); + } + + + protected convertToModelType(intermediateModel: DBServerConfiguration | undefined): ServerConfigurationModel { + if (!intermediateModel) { + throw new Error("No intermediate model provided"); + } + + return { + id: intermediateModel.id, + serverid: intermediateModel.serverid, + key: intermediateModel.key, + value: intermediateModel.value, + } + } + + protected convertToCreateObject(instance: Partial): object { + return { + serverid: instance.serverid ?? undefined, + key: instance.key ?? undefined, + value: instance.value ?? undefined, + } + } +} \ No newline at end of file diff --git a/source/Database/definitions.ts b/source/Database/definitions.ts index 96e7ceb..4e732ed 100644 --- a/source/Database/definitions.ts +++ b/source/Database/definitions.ts @@ -2,11 +2,13 @@ import Groups from "./tables/Groups"; import {DatabaseDefinition} from "./DatabaseDefinition"; import Playdate from "./tables/Playdate"; import GroupConfiguration from "./tables/GroupConfiguration"; +import ServerConfiguration from "./tables/ServerConfiguration"; const definitions = new Set([ Groups, Playdate, - GroupConfiguration + GroupConfiguration, + ServerConfiguration ]); export default definitions; \ No newline at end of file diff --git a/source/Database/tables/ServerConfiguration.ts b/source/Database/tables/ServerConfiguration.ts new file mode 100644 index 0000000..1fd7cc7 --- /dev/null +++ b/source/Database/tables/ServerConfiguration.ts @@ -0,0 +1,37 @@ +import {DatabaseDefinition} from "../DatabaseDefinition"; + +export type DBServerConfiguration = { + id: number; + serverid: string; + key: string, + value: string +} + +const dbDefinition: DatabaseDefinition = { + name: "serverConfiguration", + columns: [ + { + name: "id", + type: "INTEGER", + autoIncrement: true, + primaryKey: true, + }, + { + name: "serverid", + type: "VARCHAR", + size: 32 + }, + { + name: "key", + type: "VARCHAR", + size: 32 + }, + { + name: "value", + type: "VARCHAR", + size: 2 ^ 11 + } + ] +} + +export default dbDefinition; diff --git a/source/Discord/CommandPartials/GroupSelection.ts b/source/Discord/CommandPartials/GroupSelection.ts index 73e8e7b..735539f 100644 --- a/source/Discord/CommandPartials/GroupSelection.ts +++ b/source/Discord/CommandPartials/GroupSelection.ts @@ -38,6 +38,10 @@ export class GroupSelection { if (!group) { throw new UserError("No group found"); } + + if (group.role.server !== interaction.guildId) { + throw new Error("Invalid access to group detected..."); + } return group; } diff --git a/source/Discord/Commands/Commands.ts b/source/Discord/Commands/Commands.ts index 6168e4f..129f7b3 100644 --- a/source/Discord/Commands/Commands.ts +++ b/source/Discord/Commands/Commands.ts @@ -4,11 +4,13 @@ import {GroupCommand} from "./Groups"; import {PlaydatesCommand} from "./Playdates"; import {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; import {Nullable} from "../../types/Nullable"; +import {ServerCommand} from "./Server"; const commands: Set = new Set([ new HelloWorldCommand(), new GroupCommand(), - new PlaydatesCommand() + new PlaydatesCommand(), + new ServerCommand() ]); export default class Commands { diff --git a/source/Discord/Commands/Groups.ts b/source/Discord/Commands/Groups.ts index 2e58d48..24a8c62 100644 --- a/source/Discord/Commands/Groups.ts +++ b/source/Discord/Commands/Groups.ts @@ -5,9 +5,9 @@ import { GuildMember, GuildMemberRoleManager, InteractionReplyOptions, - MessageFlags, + MessageFlags, PermissionFlagsBits, roleMention, - SlashCommandBuilder, + SlashCommandBuilder, Snowflake, time, userMention } from "discord.js"; @@ -28,6 +28,9 @@ import {MenuTraversal} from "../../Menu/MenuTraversal"; import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; import {MenuHandler} from "../../Configuration/MenuHandler"; +import {ServerConfigurationProvider} from "../../Configuration/Server/ServerConfigurationProvider"; +import {ServerConfigurationRepository} from "../../Database/Repositories/ServerConfigurationRepository"; +import {PermissionError} from "../PermissionError"; export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand { private static GOODBYE_MESSAGES: string[] = [ @@ -114,6 +117,10 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple } private create(interaction: ChatInputCommandInteraction): void { + if (!this.allowedCreate(interaction)) { + throw new PermissionError("You don't have the permissions for it!") + } + const name = interaction.options.getString("name") ?? ''; const role = interaction.options.getRole("role", true); @@ -151,6 +158,22 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral}) } + private allowedCreate(interaction: ChatInputCommandInteraction): boolean { + if ((interaction.member)?.permissions.has(PermissionFlagsBits.Administrator)) { + return true; + } + + const config = new ConfigurationHandler( + new ServerConfigurationProvider( + Container.get(ServerConfigurationRepository.name), + interaction.guildId + ) + ); + + const configValue = config.getConfigurationByPath("permissions.groupCreation.allowEveryone").value; + return configValue === true; + } + private validateGroupName(name: string): string | null { const lowercaseName = name.toLowerCase(); for (const invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) { @@ -209,7 +232,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple const repo = Container.get(GroupRepository.name); if (group.leader.memberid != interaction.member?.user.id) { - throw new UserError("Can't remove group. You are not the leader."); + throw new PermissionError("You are not the leader."); } repo.deleteGroup(group); @@ -320,8 +343,8 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple const repo = Container.get(GroupRepository.name); if (group.leader.memberid != interaction.member?.user.id) { - throw new UserError( - "Can't transfer leadership. You are not the leader." + throw new PermissionError( + "You are not the leader." ); } diff --git a/source/Discord/Commands/Playdates.ts b/source/Discord/Commands/Playdates.ts index 67d0899..ec581f9 100644 --- a/source/Discord/Commands/Playdates.ts +++ b/source/Discord/Commands/Playdates.ts @@ -22,6 +22,7 @@ import {GroupConfigurationRepository} from "../../Database/Repositories/GroupCon import {GroupRepository} from "../../Database/Repositories/GroupRepository"; import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; import { ConfigurationHandler } from "../../Configuration/ConfigurationHandler"; +import {PermissionError} from "../PermissionError"; export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { definition(): SlashCommandBuilder { @@ -216,7 +217,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise { if (!this.interactionIsAllowedToManage(interaction, group)) { - throw new UserError( + throw new PermissionError( "You are not allowed to delete playdates for this group.", "Ask your Game Master to delete the playdate or ask him to allow everyone to do so." ) @@ -401,6 +402,6 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter group ) ); - return config.getConfigurationByPath("permissions.allowMemberManagingPlaydates") === true; + return config.getConfigurationByPath("permissions.allowMemberManagingPlaydates").value === true; } } \ No newline at end of file diff --git a/source/Discord/Commands/Server.ts b/source/Discord/Commands/Server.ts new file mode 100644 index 0000000..86e22e1 --- /dev/null +++ b/source/Discord/Commands/Server.ts @@ -0,0 +1,79 @@ +import {CacheType, ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder, Snowflake} from "discord.js"; +import {ChatInteractionCommand, Command} from "./Command"; +import {GroupSelection} from "../CommandPartials/GroupSelection"; +import {MenuHandler} from "../../Configuration/MenuHandler"; +import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; +import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; +import {Container} from "../../Container/Container"; +import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository"; +import {MenuRenderer} from "../../Menu/MenuRenderer"; +import {MenuTraversal} from "../../Menu/MenuTraversal"; +import {MenuItemType} from "../../Menu/MenuRenderer.types"; +import {ServerConfigurationProvider} from "../../Configuration/Server/ServerConfigurationProvider"; +import {ServerConfigurationRepository} from "../../Database/Repositories/ServerConfigurationRepository"; + +export class ServerCommand implements Command, ChatInteractionCommand { + definition(): SlashCommandBuilder { + return new SlashCommandBuilder() + .setName("server") + .setDescription("Allows server administrators to adjust things about the PnP Scheduler bot.") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(command => command + .setName("config") + .setDescription("Starts the configurator for the server settings") + ) + } + + async execute(interaction: ChatInputCommandInteraction): Promise { + switch (interaction.options.getSubcommand()) { + case "config": + await this.startConfiguration(interaction); + break + } + } + + private async startConfiguration(interaction: ChatInputCommandInteraction) { + const menuHandler = new MenuHandler( + new ConfigurationHandler( + new ServerConfigurationProvider( + Container.get(ServerConfigurationRepository.name), + interaction.guildId + ) + ) + ) + + const menu = new MenuRenderer( + new MenuTraversal( + menuHandler.fillMenuItems( + [ + { + traversalKey: "permissions", + label: "Permissions", + description: "Allows customization, how the server members are allowed to interact with the PnP Scheduler.", + type: MenuItemType.Collection, + children: [ + { + traversalKey: "groupCreation", + label: "Group Creation", + description: "Sets the permissions, who is allowed to create groups.", + type: MenuItemType.Collection, + children: [ + { + traversalKey: "allowEveryone", + label: "Group Creation", + description: "Defines if all members are allowed to create groups.", + } + ] + }, + ] + }, + ] + ), + 'Server Configuration', + "This UI allows you to change settings for your server." + ) + ) + + menu.display(interaction); + } +} \ No newline at end of file diff --git a/source/Discord/InteractionRouter.ts b/source/Discord/InteractionRouter.ts index 918284e..89d1ef2 100644 --- a/source/Discord/InteractionRouter.ts +++ b/source/Discord/InteractionRouter.ts @@ -14,6 +14,7 @@ import {EventHandler} from "../Events/EventHandler"; import {ModalInteractionEvent} from "../Events/EventClasses/ModalInteractionEvent"; import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent"; import {log} from "node:util"; +import {PermissionError} from "./PermissionError"; enum InteractionRoutingType { Unrouted, @@ -95,17 +96,13 @@ export class InteractionRouter { let userMessage = ":x: There was an error while executing this command!"; let logErrorMessage = true; - if (e.constructor.name === UserError.name) { - userMessage = `:x: \`${e.message}\` - Please validate your request!` - if (e.tryInstead) { - userMessage += ` - -You can try the following: -${inlineCode(e.tryInstead)}` - } - - logErrorMessage = false; + + if ("getDiscordMessage" in e) { + userMessage = e.getDiscordMessage(e); } + if ("shouldLog" in e) { + logErrorMessage = e.shouldLog; + } if (logErrorMessage) { this.logger.error(e) diff --git a/source/Discord/PermissionError.ts b/source/Discord/PermissionError.ts new file mode 100644 index 0000000..64eb97a --- /dev/null +++ b/source/Discord/PermissionError.ts @@ -0,0 +1,24 @@ +import {inlineCode} from "discord.js"; + +export class PermissionError extends Error { + shouldLog: boolean = false; + + constructor( + message: string, + public readonly tryInstead: string | null = null + ) { + super(message); + } + + public getDiscordMessage(e: PermissionError): string { + let userMessage = `:x: You can not perform this action! ${inlineCode(e.message)}` + if (e.tryInstead) { + userMessage += ` + + You can try the following: + ${inlineCode(e.tryInstead)}` + } + + return userMessage; + } +} \ No newline at end of file diff --git a/source/Discord/UserError.ts b/source/Discord/UserError.ts index 8277d96..3699c4b 100644 --- a/source/Discord/UserError.ts +++ b/source/Discord/UserError.ts @@ -1,4 +1,9 @@ +import {inlineCode} from "discord.js"; + export class UserError extends Error { + + shouldLog: boolean = false; + constructor( message: string, public readonly tryInstead: string | null = null @@ -7,4 +12,15 @@ export class UserError extends Error { } + public getDiscordMessage(e: UserError): string { + let userMessage = `:x: \`${e.message}\` - Please validate your request!` + if (e.tryInstead) { + userMessage += ` + +You can try the following: +${inlineCode(e.tryInstead)}` + } + + return userMessage; + } } \ No newline at end of file diff --git a/source/Events/Handlers/ReminderEvent.ts b/source/Events/Handlers/ReminderEvent.ts index b1c5e45..616bea6 100644 --- a/source/Events/Handlers/ReminderEvent.ts +++ b/source/Events/Handlers/ReminderEvent.ts @@ -73,14 +73,12 @@ export class ReminderEvent implements TimedEvent { ) ); - const config = groupConfig.getCompleteConfiguration(); - const targetChannel = config.channels?.playdateReminders; - + const targetChannel = groupConfig.getConfigurationByPath("channels.playdateReminders").value; if (!targetChannel) { return Promise.resolve(); } - return this.sendReminder(playdate, targetChannel); + return this.sendReminder(playdate, targetChannel); }, this) await Promise.all(promises); diff --git a/source/Events/Handlers/SendCreatedNotification.ts b/source/Events/Handlers/SendCreatedNotification.ts index d628b99..9caa28e 100644 --- a/source/Events/Handlers/SendCreatedNotification.ts +++ b/source/Events/Handlers/SendCreatedNotification.ts @@ -37,7 +37,7 @@ export async function sendCreatedNotificationEventHandler(event: ElementCreatedE ) ); - const targetChannel = groupConfig.getConfigurationByPath('channels.newPlaydates'); + const targetChannel = groupConfig.getConfigurationByPath('channels.newPlaydates').value; if (!targetChannel) { return; } diff --git a/source/Menu/MenuRenderer.ts b/source/Menu/MenuRenderer.ts index fa1e77d..5e99b0d 100644 --- a/source/Menu/MenuRenderer.ts +++ b/source/Menu/MenuRenderer.ts @@ -17,6 +17,7 @@ import {MessageActionRowComponentBuilder} from "@discordjs/builders"; import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent"; import {MenuTraversal} from "./MenuTraversal"; import {Prompt} from "./Modals/Prompt"; +import _ from "lodash"; export class MenuRenderer { private readonly menuId: string; @@ -86,20 +87,14 @@ export class MenuRenderer { private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder[] { if (menuItem.type === MenuItemType.Collection) { - const rowCount = Math.ceil(menuItem.children.length / MenuRenderer.MAX_BUTTON_PER_ROW); - if (rowCount > MenuRenderer.MAX_USER_ROW_COUNT) { + const rows = _.chunk(menuItem.children, MenuRenderer.MAX_BUTTON_PER_ROW); + if (rows.length > MenuRenderer.MAX_USER_ROW_COUNT) { throw new TypeError( `A collection can only have a max of ${MenuRenderer.MAX_USER_ROW_COUNT * MenuRenderer.MAX_BUTTON_PER_ROW} entries!` ); } - const rows = Array.from(Array(rowCount).keys()) - .map((index) => { - const childStart = index * MenuRenderer.MAX_BUTTON_PER_ROW; - return menuItem.children.slice(childStart, MenuRenderer.MAX_BUTTON_PER_ROW); - }) - - return rows.reverse() + return rows .map((items) => new ActionRowBuilder() .setComponents( ...items.map(item => new ButtonBuilder()