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

@ -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<TransformerResults> {
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<TRuntimeConfiguration> {
const values = this.provider.getAll();
const configuration: Partial<TRuntimeConfiguration> = {};
values.forEach((configValue) => {
const value = this.transformer.getValue(configValue);
setPath(configuration, configValue.key, value);
_.set(configuration, configValue.key, value);
})
return configuration;

View file

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

View file

@ -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(<string>value.value);
if (isDefault) {
return italic(`Default (${display})`)
}
if (!value) {
return inlineCode("None");
}
return channelMention(<ChannelId>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 ? "" : <string>value.value;
}
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 {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<GroupRepository>(new GroupRepository(db));
container.set<PlaydateRepository>(new PlaydateRepository(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 {

View file

@ -28,7 +28,7 @@ export class DatabaseUpdater {
);
if (!DBSQLColumns) {
Container.get<Logger>("logger").log("Request failed...");
Container.get<Logger>("logger").warn("Request for database columns failed!");
return;
}
@ -41,7 +41,7 @@ export class DatabaseUpdater {
)
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;
}

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 Playdate from "./tables/Playdate";
import GroupConfiguration from "./tables/GroupConfiguration";
import ServerConfiguration from "./tables/ServerConfiguration";
const definitions = new Set<DatabaseDefinition>([
Groups,
Playdate,
GroupConfiguration
GroupConfiguration,
ServerConfiguration
]);
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

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

View file

@ -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<Command> = new Set<Command>([
new HelloWorldCommand(),
new GroupCommand(),
new PlaydatesCommand()
new PlaydatesCommand(),
new ServerCommand()
]);
export default class Commands {

View file

@ -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 ((<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 {
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>(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>(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."
);
}

View file

@ -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<void> {
if (!this.interactionIsAllowedToManage(<ChatInputCommandInteraction>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;
}
}

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 {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)

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

View file

@ -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, <ChannelId>targetChannel);
}, this)
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) {
return;
}

View file

@ -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<MessageActionRowComponentBuilder>[] {
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<AnyMenuItem>(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<ButtonBuilder>()
.setComponents(
...items.map(item => new ButtonBuilder()