feat(permissions): Adds server permissions

This commit is contained in:
Michel Fedde 2025-06-24 20:58:46 +02:00
parent d46bbd84c5
commit cf9c88a2d6
24 changed files with 415 additions and 69 deletions

View file

@ -1,6 +1,8 @@
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import path from "path"; import path from "path";
const isDev = process.env.BUILD_TARGET !== 'DOCKER';
const context = await esbuild.context({ const context = await esbuild.context({
entryPoints: [ entryPoints: [
path.join('source', 'main.ts'), path.join('source', 'main.ts'),
@ -10,7 +12,7 @@ const context = await esbuild.context({
outdir: './dist/', outdir: './dist/',
platform: 'node', platform: 'node',
target: 'node10.4', target: 'node10.4',
sourcemap: 'linked', sourcemap: isDev ? 'external' : null,
loader: { loader: {
'.node': 'copy', '.node': 'copy',
} }

8
package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/deepmerge": "^2.1.0", "@types/deepmerge": "^2.1.0",
"@types/lodash": "^4.17.18",
"@types/log4js": "^0.0.33", "@types/log4js": "^0.0.33",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
@ -20,6 +21,7 @@
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"ics": "^3.8.1", "ics": "^3.8.1",
"is-plain-object": "^5.0.0", "is-plain-object": "^5.0.0",
"lodash": "^4.17.21",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"node-cron": "^4.0.7", "node-cron": "^4.0.7",
"node-ical": "^0.20.1", "node-ical": "^0.20.1",
@ -1636,6 +1638,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/log4js": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/log4js/-/log4js-0.0.33.tgz", "resolved": "https://registry.npmjs.org/@types/log4js/-/log4js-0.0.33.tgz",

View file

@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/deepmerge": "^2.1.0", "@types/deepmerge": "^2.1.0",
"@types/lodash": "^4.17.18",
"@types/log4js": "^0.0.33", "@types/log4js": "^0.0.33",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"better-sqlite3": "^11.8.1", "better-sqlite3": "^11.8.1",
@ -26,6 +27,7 @@
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"ics": "^3.8.1", "ics": "^3.8.1",
"is-plain-object": "^5.0.0", "is-plain-object": "^5.0.0",
"lodash": "^4.17.21",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"node-cron": "^4.0.7", "node-cron": "^4.0.7",
"node-ical": "^0.20.1", "node-ical": "^0.20.1",

View file

@ -7,6 +7,17 @@ import deepmerge from "deepmerge";
import {isPlainObject} from "is-plain-object"; import {isPlainObject} from "is-plain-object";
import {Nullable} from "../types/Nullable"; import {Nullable} from "../types/Nullable";
import {ConfigurationTransformer, TransformerResults} from "./ConfigurationTransformer"; import {ConfigurationTransformer, TransformerResults} from "./ConfigurationTransformer";
import _ from "lodash";
export enum PathConfigurationFrom {
Database,
Default
}
export type PathConfiguration = {
value: TransformerResults,
from: PathConfigurationFrom
}
export class ConfigurationHandler< export class ConfigurationHandler<
TProviderModel extends ConfigurationModel = ConfigurationModel, TProviderModel extends ConfigurationModel = ConfigurationModel,
@ -48,22 +59,27 @@ export class ConfigurationHandler<
) )
} }
public getConfigurationByPath(path: string): Nullable<TransformerResults> { public getConfigurationByPath(path: string): PathConfiguration {
const configuration = this.provider.get(path); const configuration = this.provider.get(path);
if (!configuration) { 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<TRuntimeConfiguration> { private getCompleteDatabaseConfig(): Partial<TRuntimeConfiguration> {
const values = this.provider.getAll(); const values = this.provider.getAll();
const configuration: Partial<TRuntimeConfiguration> = {}; const configuration: Partial<TRuntimeConfiguration> = {};
values.forEach((configValue) => { values.forEach((configValue) => {
const value = this.transformer.getValue(configValue); const value = this.transformer.getValue(configValue);
setPath(configuration, configValue.key, value); _.set(configuration, configValue.key, value);
}) })
return configuration; return configuration;

View file

@ -29,9 +29,6 @@ export type CalendarRuntimeGroupConfiguration = {
location: null | string location: null | string
} }
export type GroupConfigurationResult =
ChannelId | Intl.Locale | boolean | string | null
export class GroupConfigurationProvider implements ConfigurationProvider< export class GroupConfigurationProvider implements ConfigurationProvider<
GroupConfigurationModel, GroupConfigurationModel,
RuntimeGroupConfiguration RuntimeGroupConfiguration
@ -66,9 +63,13 @@ export class GroupConfigurationProvider implements ConfigurationProvider<
if (value.id) { if (value.id) {
// @ts-expect-error id is set, due to the check on line above // @ts-expect-error id is set, due to the check on line above
this.repository.update(value); this.repository.update(value);
return
} }
this.repository.create(value); this.repository.create({
...value,
group: this.group
});
} }
getTransformer(): ConfigurationTransformer { getTransformer(): ConfigurationTransformer {
return new ConfigurationTransformer( return new ConfigurationTransformer(

View file

@ -1,8 +1,10 @@
import {ConfigurationHandler} from "./ConfigurationHandler"; import {ConfigurationHandler, PathConfigurationFrom} from "./ConfigurationHandler";
import { import {
AnyMenuItem, AnyMenuItem,
CollectionMenuItem, CollectionMenuItem,
FieldMenuItem, FieldMenuItemContext, FieldMenuItemSaveValue, FieldMenuItem,
FieldMenuItemContext,
FieldMenuItemSaveValue,
MenuItem, MenuItem,
MenuItemType, MenuItemType,
PromptMenuItem PromptMenuItem
@ -12,11 +14,11 @@ import {
channelMention, channelMention,
ChannelSelectMenuBuilder, ChannelSelectMenuBuilder,
ChannelType, ChannelType,
inlineCode,
italic, italic,
StringSelectMenuBuilder, TextInputBuilder, TextInputStyle StringSelectMenuBuilder,
TextInputBuilder,
TextInputStyle
} from "discord.js"; } from "discord.js";
import {ChannelId} from "../types/DiscordTypes";
import {MessageActionRowComponentBuilder} from "@discordjs/builders"; import {MessageActionRowComponentBuilder} from "@discordjs/builders";
import {TransformerType} from "./ConfigurationTransformer"; import {TransformerType} from "./ConfigurationTransformer";
@ -101,14 +103,14 @@ export class MenuHandler {
private getChannelValue(context: FieldMenuItemContext): string { private getChannelValue(context: FieldMenuItemContext): string {
const value = this.config.getConfigurationByPath(context.path.join('.')); const value = this.config.getConfigurationByPath(context.path.join('.'));
if (value === undefined) {
return italic("None");
}
if (!value) { const isDefault = value.from === PathConfigurationFrom.Default;
return inlineCode("None"); const display = !value ? "None" : channelMention(<string>value.value);
if (isDefault) {
return italic(`Default (${display})`)
} }
return channelMention(<ChannelId>value); return display;
} }
private getChannelMenuBuilder(): MessageActionRowComponentBuilder { private getChannelMenuBuilder(): MessageActionRowComponentBuilder {
@ -119,11 +121,14 @@ export class MenuHandler {
private getPermissionBooleanValue(context: FieldMenuItemContext) { private getPermissionBooleanValue(context: FieldMenuItemContext) {
const value = this.config.getConfigurationByPath(context.path.join('.')); 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() { private getPermissionBooleanBuilder() {
@ -144,15 +149,8 @@ export class MenuHandler {
private getStringValue(context: FieldMenuItemContext): string { private getStringValue(context: FieldMenuItemContext): string {
const value = this.config.getConfigurationByPath(context.path.join('.')); const value = this.config.getConfigurationByPath(context.path.join('.'));
if (!value) {
return "";
}
if (typeof value !== 'string') { return value.value === null ? "" : <string>value.value;
throw new TypeError(`Value of type ${typeof value} can't be used for a string value!`)
}
return value;
} }
private getStringBuilder(): TextInputBuilder { private getStringBuilder(): TextInputBuilder {

View file

@ -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<ServerConfigurationModel> {
return this.repository.findConfigurationByPath(this.serverid, path);
}
getAll(): ServerConfigurationModel[] {
return this.repository.findServerConfigurations(this.serverid);
}
save(value: Omit<ConfigurationModel, "id"> & Partial<Model>): 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
}
]
)
}
}

View file

@ -14,6 +14,7 @@ import Commands from "../Discord/Commands/Commands";
import {CommandDeployer} from "../Discord/CommandDeployer"; import {CommandDeployer} from "../Discord/CommandDeployer";
import {REST} from "discord.js"; import {REST} from "discord.js";
import {log} from "node:util"; import {log} from "node:util";
import {ServerConfigurationRepository} from "../Database/Repositories/ServerConfigurationRepository";
export enum ServiceHint { export enum ServiceHint {
App, App,
@ -58,6 +59,7 @@ export class Services {
container.set<GroupRepository>(new GroupRepository(db)); container.set<GroupRepository>(new GroupRepository(db));
container.set<PlaydateRepository>(new PlaydateRepository(db, container.get<GroupRepository>(GroupRepository.name))) container.set<PlaydateRepository>(new PlaydateRepository(db, container.get<GroupRepository>(GroupRepository.name)))
container.set<GroupConfigurationRepository>(new GroupConfigurationRepository(db, container.get<GroupRepository>(GroupRepository.name))) container.set<GroupConfigurationRepository>(new GroupConfigurationRepository(db, container.get<GroupRepository>(GroupRepository.name)))
container.set<ServerConfigurationRepository>(new ServerConfigurationRepository(db));
} }
private static setupLogger(hint: ServiceHint): Logger { private static setupLogger(hint: ServiceHint): Logger {

View file

@ -28,7 +28,7 @@ export class DatabaseUpdater {
); );
if (!DBSQLColumns) { if (!DBSQLColumns) {
Container.get<Logger>("logger").log("Request failed..."); Container.get<Logger>("logger").warn("Request for database columns failed!");
return; return;
} }
@ -41,7 +41,7 @@ export class DatabaseUpdater {
) )
if (missingColumns.length < 1) { if (missingColumns.length < 1) {
Container.get<Logger>("logger").log(`No new columns found for ${definition.name}`) Container.get<Logger>("logger").debug(`No new columns found for ${definition.name}`)
return; return;
} }

View file

@ -0,0 +1,6 @@
import {ConfigurationModel} from "./ConfigurationModel";
import {Snowflake} from "discord.js";
export type ServerConfigurationModel = ConfigurationModel & {
serverid: Snowflake
}

View file

@ -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<ServerConfigurationModel, DBServerConfiguration> {
constructor(
protected readonly database: DatabaseConnection,
) {
super(
database,
ServerConfiguration
);
}
public findServerConfigurations(server: Snowflake): ServerConfigurationModel[] {
return this.database.fetchAll<number, DBServerConfiguration>(
`SELECT * FROM serverConfiguration WHERE serverid = ?`,
server
).map((config) => {
return this.convertToModelType(config);
})
}
public findConfigurationByPath(server: Snowflake, path: string): Nullable<ServerConfigurationModel> {
const result = this.database.fetch<number, DBServerConfiguration>(
`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<ServerConfigurationModel>): object {
return {
serverid: instance.serverid ?? undefined,
key: instance.key ?? undefined,
value: instance.value ?? undefined,
}
}
}

View file

@ -2,11 +2,13 @@ import Groups from "./tables/Groups";
import {DatabaseDefinition} from "./DatabaseDefinition"; import {DatabaseDefinition} from "./DatabaseDefinition";
import Playdate from "./tables/Playdate"; import Playdate from "./tables/Playdate";
import GroupConfiguration from "./tables/GroupConfiguration"; import GroupConfiguration from "./tables/GroupConfiguration";
import ServerConfiguration from "./tables/ServerConfiguration";
const definitions = new Set<DatabaseDefinition>([ const definitions = new Set<DatabaseDefinition>([
Groups, Groups,
Playdate, Playdate,
GroupConfiguration GroupConfiguration,
ServerConfiguration
]); ]);
export default definitions; export default definitions;

View file

@ -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;

View file

@ -39,6 +39,10 @@ export class GroupSelection {
throw new UserError("No group found"); throw new UserError("No group found");
} }
if (group.role.server !== interaction.guildId) {
throw new Error("Invalid access to group detected...");
}
return <GroupModel>group; return <GroupModel>group;
} }
} }

View file

@ -4,11 +4,13 @@ import {GroupCommand} from "./Groups";
import {PlaydatesCommand} from "./Playdates"; import {PlaydatesCommand} from "./Playdates";
import {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js"; import {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js";
import {Nullable} from "../../types/Nullable"; import {Nullable} from "../../types/Nullable";
import {ServerCommand} from "./Server";
const commands: Set<Command> = new Set<Command>([ const commands: Set<Command> = new Set<Command>([
new HelloWorldCommand(), new HelloWorldCommand(),
new GroupCommand(), new GroupCommand(),
new PlaydatesCommand() new PlaydatesCommand(),
new ServerCommand()
]); ]);
export default class Commands { export default class Commands {

View file

@ -5,9 +5,9 @@ import {
GuildMember, GuildMember,
GuildMemberRoleManager, GuildMemberRoleManager,
InteractionReplyOptions, InteractionReplyOptions,
MessageFlags, MessageFlags, PermissionFlagsBits,
roleMention, roleMention,
SlashCommandBuilder, SlashCommandBuilder, Snowflake,
time, time,
userMention userMention
} from "discord.js"; } from "discord.js";
@ -28,6 +28,9 @@ import {MenuTraversal} from "../../Menu/MenuTraversal";
import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler"; import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler";
import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider";
import {MenuHandler} from "../../Configuration/MenuHandler"; 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 { export class GroupCommand implements Command, ChatInteractionCommand, AutocompleteCommand {
private static GOODBYE_MESSAGES: string[] = [ private static GOODBYE_MESSAGES: string[] = [
@ -114,6 +117,10 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
} }
private create(interaction: ChatInputCommandInteraction): void { 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 name = interaction.options.getString("name") ?? '';
const role = interaction.options.getRole("role", true); 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}) interaction.reply({content: `:white_check_mark: Created group \`${name}\``, flags: MessageFlags.Ephemeral})
} }
private allowedCreate(interaction: ChatInputCommandInteraction): boolean {
if ((<GuildMember>interaction.member)?.permissions.has(PermissionFlagsBits.Administrator)) {
return true;
}
const config = new ConfigurationHandler(
new ServerConfigurationProvider(
Container.get<ServerConfigurationRepository>(ServerConfigurationRepository.name),
<Snowflake>interaction.guildId
)
);
const configValue = config.getConfigurationByPath("permissions.groupCreation.allowEveryone").value;
return configValue === true;
}
private validateGroupName(name: string): string | null { private validateGroupName(name: string): string | null {
const lowercaseName = name.toLowerCase(); const lowercaseName = name.toLowerCase();
for (const invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) { for (const invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) {
@ -209,7 +232,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
const repo = Container.get<GroupRepository>(GroupRepository.name); const repo = Container.get<GroupRepository>(GroupRepository.name);
if (group.leader.memberid != interaction.member?.user.id) { 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); repo.deleteGroup(group);
@ -320,8 +343,8 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
const repo = Container.get<GroupRepository>(GroupRepository.name); const repo = Container.get<GroupRepository>(GroupRepository.name);
if (group.leader.memberid != interaction.member?.user.id) { if (group.leader.memberid != interaction.member?.user.id) {
throw new UserError( throw new PermissionError(
"Can't transfer leadership. You are not the leader." "You are not the leader."
); );
} }

View file

@ -22,6 +22,7 @@ import {GroupConfigurationRepository} from "../../Database/Repositories/GroupCon
import {GroupRepository} from "../../Database/Repositories/GroupRepository"; import {GroupRepository} from "../../Database/Repositories/GroupRepository";
import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider"; import {GroupConfigurationProvider} from "../../Configuration/Groups/GroupConfigurationProvider";
import { ConfigurationHandler } from "../../Configuration/ConfigurationHandler"; import { ConfigurationHandler } from "../../Configuration/ConfigurationHandler";
import {PermissionError} from "../PermissionError";
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
definition(): SlashCommandBuilder { definition(): SlashCommandBuilder {
@ -216,7 +217,7 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> { private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
if (!this.interactionIsAllowedToManage(<ChatInputCommandInteraction>interaction, group)) { if (!this.interactionIsAllowedToManage(<ChatInputCommandInteraction>interaction, group)) {
throw new UserError( throw new PermissionError(
"You are not allowed to delete playdates for this group.", "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." "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 group
) )
); );
return config.getConfigurationByPath("permissions.allowMemberManagingPlaydates") === true; return config.getConfigurationByPath("permissions.allowMemberManagingPlaydates").value === true;
} }
} }

View file

@ -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<CacheType>): Promise<void> {
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>(ServerConfigurationRepository.name),
<Snowflake>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);
}
}

View file

@ -14,6 +14,7 @@ import {EventHandler} from "../Events/EventHandler";
import {ModalInteractionEvent} from "../Events/EventClasses/ModalInteractionEvent"; import {ModalInteractionEvent} from "../Events/EventClasses/ModalInteractionEvent";
import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent"; import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent";
import {log} from "node:util"; import {log} from "node:util";
import {PermissionError} from "./PermissionError";
enum InteractionRoutingType { enum InteractionRoutingType {
Unrouted, Unrouted,
@ -95,16 +96,12 @@ export class InteractionRouter {
let userMessage = ":x: There was an error while executing this command!"; let userMessage = ":x: There was an error while executing this command!";
let logErrorMessage = true; 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: if ("getDiscordMessage" in e) {
${inlineCode(e.tryInstead)}` userMessage = e.getDiscordMessage(e);
} }
if ("shouldLog" in e) {
logErrorMessage = false; logErrorMessage = e.shouldLog;
} }
if (logErrorMessage) { if (logErrorMessage) {

View file

@ -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;
}
}

View file

@ -1,4 +1,9 @@
import {inlineCode} from "discord.js";
export class UserError extends Error { export class UserError extends Error {
shouldLog: boolean = false;
constructor( constructor(
message: string, message: string,
public readonly tryInstead: string | null = null 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;
}
} }

View file

@ -73,14 +73,12 @@ export class ReminderEvent implements TimedEvent {
) )
); );
const config = groupConfig.getCompleteConfiguration(); const targetChannel = groupConfig.getConfigurationByPath("channels.playdateReminders").value;
const targetChannel = config.channels?.playdateReminders;
if (!targetChannel) { if (!targetChannel) {
return Promise.resolve(); return Promise.resolve();
} }
return this.sendReminder(playdate, targetChannel); return this.sendReminder(playdate, <ChannelId>targetChannel);
}, this) }, this)
await Promise.all(promises); await Promise.all(promises);

View file

@ -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) { if (!targetChannel) {
return; return;
} }

View file

@ -17,6 +17,7 @@ import {MessageActionRowComponentBuilder} from "@discordjs/builders";
import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent"; import {ComponentInteractionEvent} from "../Events/EventClasses/ComponentInteractionEvent";
import {MenuTraversal} from "./MenuTraversal"; import {MenuTraversal} from "./MenuTraversal";
import {Prompt} from "./Modals/Prompt"; import {Prompt} from "./Modals/Prompt";
import _ from "lodash";
export class MenuRenderer { export class MenuRenderer {
private readonly menuId: string; private readonly menuId: string;
@ -86,20 +87,14 @@ export class MenuRenderer {
private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder<MessageActionRowComponentBuilder>[] { private getComponentForMenuItem(menuItem: AnyMenuItem): ActionRowBuilder<MessageActionRowComponentBuilder>[] {
if (menuItem.type === MenuItemType.Collection) { if (menuItem.type === MenuItemType.Collection) {
const rowCount = Math.ceil(menuItem.children.length / MenuRenderer.MAX_BUTTON_PER_ROW); const rows = _.chunk<AnyMenuItem>(menuItem.children, MenuRenderer.MAX_BUTTON_PER_ROW);
if (rowCount > MenuRenderer.MAX_USER_ROW_COUNT) { if (rows.length > MenuRenderer.MAX_USER_ROW_COUNT) {
throw new TypeError( throw new TypeError(
`A collection can only have a max of ${MenuRenderer.MAX_USER_ROW_COUNT * MenuRenderer.MAX_BUTTON_PER_ROW} entries!` `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()) return rows
.map((index) => {
const childStart = index * MenuRenderer.MAX_BUTTON_PER_ROW;
return menuItem.children.slice(childStart, MenuRenderer.MAX_BUTTON_PER_ROW);
})
return rows.reverse()
.map((items) => new ActionRowBuilder<ButtonBuilder>() .map((items) => new ActionRowBuilder<ButtonBuilder>()
.setComponents( .setComponents(
...items.map(item => new ButtonBuilder() ...items.map(item => new ButtonBuilder()