feat(configuration): Adds missing implermentations

This commit is contained in:
Michel Fedde 2025-06-22 16:27:34 +02:00
parent b82ab7dbc4
commit ec0aa5654c
7 changed files with 150 additions and 32 deletions

View file

@ -2,7 +2,15 @@ import {
SlashCommandBuilder, SlashCommandBuilder,
CommandInteraction, CommandInteraction,
AutocompleteInteraction, AutocompleteInteraction,
EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time, AttachmentBuilder, ActivityFlagsBitField, Options EmbedBuilder,
MessageFlags,
ChatInputCommandInteraction,
time,
AttachmentBuilder,
ActivityFlagsBitField,
Options,
User,
GuildMember
} from "discord.js"; } from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command"; import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {Container} from "../../Container/Container"; import {Container} from "../../Container/Container";
@ -13,6 +21,10 @@ import {PlaydateRepository} from "../../Repositories/PlaydateRepository";
import {GroupModel} from "../../Models/GroupModel"; import {GroupModel} from "../../Models/GroupModel";
import * as ics from 'ics'; import * as ics from 'ics';
import ical from 'node-ical'; import ical from 'node-ical';
import {GroupConfigurationHandler} from "../../Groups/GroupConfigurationHandler";
import {GroupConfigurationRepository} from "../../Repositories/GroupConfigurationRepository";
import {privateDecrypt} from "node:crypto";
import {GroupRepository} from "../../Repositories/GroupRepository";
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand { export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
definition(): SlashCommandBuilder { definition(): SlashCommandBuilder {
@ -100,6 +112,13 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
} }
async create(interaction: CommandInteraction, group: GroupModel): Promise<void> { async create(interaction: CommandInteraction, group: GroupModel): Promise<void> {
if (!this.interactionIsAllowedToManage(<ChatInputCommandInteraction>interaction, group)) {
throw new UserError(
"You are not allowed to create playdates for this group.",
"Ask your Game Master to add the playdate or ask him to allow everyone to do so."
)
}
const fromDate = Date.parse(<string>interaction.options.get("from")?.value ?? ''); const fromDate = Date.parse(<string>interaction.options.get("from")?.value ?? '');
const toDate = Date.parse(<string>interaction.options.get("to")?.value ?? ''); const toDate = Date.parse(<string>interaction.options.get("to")?.value ?? '');
@ -199,6 +218,13 @@ 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)) {
throw new UserError(
"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."
)
}
const playdateId = interaction.options.getInteger("playdate", true) const playdateId = interaction.options.getInteger("playdate", true)
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name); const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
@ -231,6 +257,13 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
} }
private async import(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> { private async import(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
if (!this.interactionIsAllowedToManage(<ChatInputCommandInteraction>interaction, group)) {
throw new UserError(
"You are not allowed to create playdates for this group.",
"Ask your Game Master to add the playdate or ask him to allow everyone to do so."
)
}
const file = interaction.options.getAttachment('file', true); const file = interaction.options.getAttachment('file', true);
const mimeType = file.contentType?.split(';')[0]; const mimeType = file.contentType?.split(';')[0];
if (mimeType !== "text/calendar") { if (mimeType !== "text/calendar") {
@ -281,8 +314,25 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
} }
private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> { private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const groupConfig = new GroupConfigurationHandler(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group
).getConfiguration();
const playdates = this.getExportTargets(interaction, group); const playdates = this.getExportTargets(interaction, group);
if (playdates.length < 1) {
await interaction.reply({
embeds: [
new EmbedBuilder()
.setTitle("Export failed")
.setDescription(`:x: No playdates found under those filters.`)
],
flags: MessageFlags.Ephemeral
});
return
}
const result = ics.createEvents( const result = ics.createEvents(
playdates.map((playdate) => { playdates.map((playdate) => {
return { return {
@ -292,7 +342,9 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
end: ics.convertTimestampToArray(playdate.to_time.getTime(), ''), end: ics.convertTimestampToArray(playdate.to_time.getTime(), ''),
endInputType: 'utc', endInputType: 'utc',
endOutputType: 'utc', endOutputType: 'utc',
title: `PnP with ${group.name}`, title: groupConfig.calendar.title ?? `PnP with ${group.name}`,
description: groupConfig.calendar.description ?? undefined,
location: groupConfig.calendar.location ?? undefined,
status: "CONFIRMED", status: "CONFIRMED",
busyStatus: "FREE", busyStatus: "FREE",
categories: ['PnP'] categories: ['PnP']
@ -332,4 +384,22 @@ export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInter
return [playdate]; return [playdate];
} }
private interactionIsAllowedToManage(interaction: ChatInputCommandInteraction, group: GroupModel): boolean {
const interactionMemberId = interaction.member?.user.id;
if (group.leader.memberid === interactionMemberId) {
return true;
}
const groupRepo = Container.get<GroupRepository>(GroupRepository.name);
if (!groupRepo.isMemberInGroup(<GuildMember>interaction.member, group)) {
return false;
}
const config = new GroupConfigurationHandler(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group
);
return config.getConfiguration().permissions.allowMemberManagingPlaydates;
}
} }

View file

@ -13,6 +13,7 @@ import {Container} from "../Container/Container";
import {EventHandler} from "../Events/EventHandler"; 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";
enum InteractionRoutingType { enum InteractionRoutingType {
Unrouted, Unrouted,
@ -91,9 +92,9 @@ export class InteractionRouter {
await command.execute?.call(command, interaction); await command.execute?.call(command, interaction);
} catch (e: any) { } catch (e: any) {
this.logger.error(e)
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;
if (e.constructor.name === UserError.name) { if (e.constructor.name === UserError.name) {
userMessage = `:x: \`${e.message}\` - Please validate your request!` userMessage = `:x: \`${e.message}\` - Please validate your request!`
if (e.tryInstead) { if (e.tryInstead) {
@ -102,7 +103,14 @@ export class InteractionRouter {
You can try the following: You can try the following:
${inlineCode(e.tryInstead)}` ${inlineCode(e.tryInstead)}`
} }
logErrorMessage = false;
} }
if (logErrorMessage) {
this.logger.error(e)
}
if (interaction.replied || interaction.deferred) { if (interaction.replied || interaction.deferred) {
await interaction.followUp({content: userMessage, flags: MessageFlags.Ephemeral}); await interaction.followUp({content: userMessage, flags: MessageFlags.Ephemeral});
} else { } else {

View file

@ -1,7 +1,7 @@
import { import {
AnyMenuItem, FieldMenuItem, AnyMenuItem, FieldMenuItem,
FieldMenuItemContext, FieldMenuItemSaveValue, FieldMenuItemContext, FieldMenuItemSaveValue, MenuItem,
MenuItemType, MenuItemType, PromptMenuItem,
RowBuilderFieldMenuItemContext RowBuilderFieldMenuItemContext
} from "../Menu/MenuRenderer.types"; } from "../Menu/MenuRenderer.types";
import {GroupConfigurationTransformers} from "./GroupConfigurationTransformers"; import {GroupConfigurationTransformers} from "./GroupConfigurationTransformers";
@ -18,6 +18,7 @@ import {
} from "discord.js"; } from "discord.js";
import {ChannelId} from "../types/DiscordTypes"; import {ChannelId} from "../types/DiscordTypes";
import {MessageActionRowComponentBuilder} from "@discordjs/builders"; import {MessageActionRowComponentBuilder} from "@discordjs/builders";
import {Prompt} from "../Menu/Modals/Prompt";
export class ConfigurationMenuHandler { export class ConfigurationMenuHandler {
@ -56,15 +57,6 @@ export class ConfigurationMenuHandler {
} }
] ]
}, },
{
traversalKey: "locale",
label: "Locale",
description: "Provides locale to be used for this group.",
type: MenuItemType.Field,
getCurrentValue: this.getLocaleValue.bind(this),
getActionRowBuilder: this.getLocaleMenuBuilder.bind(this),
setValue: this.setValue.bind(this)
},
{ {
traversalKey: "permissions", traversalKey: "permissions",
label: "Permissions", label: "Permissions",
@ -88,20 +80,45 @@ export class ConfigurationMenuHandler {
description: "Provides settings for the metadata contained in the playdate exports.", description: "Provides settings for the metadata contained in the playdate exports.",
type: MenuItemType.Collection, type: MenuItemType.Collection,
children: [ children: [
{ this.createStringMenuItem({
traversalKey: "title", traversalKey: "title",
label: "Title", label: "Title",
description: "Defines how the calendar entry should be called.", description: "Defines how the calendar entry should be called.",
type: MenuItemType.Prompt, }),
getCurrentValue: this.getStringValue.bind(this), this.createTextareaMenuItem({
getActionRowBuilder: this.getStringBuilder.bind(this), traversalKey: "description",
setValue: this.setValue.bind(this) label: "Description",
} description: "Sets the description for the calendar entry.",
}),
this.createStringMenuItem({
traversalKey: "location",
label: "Location",
description: "Sets the location where the calendar should point to."
}),
] ]
}, },
] ]
} }
private createStringMenuItem(metadata: MenuItem): PromptMenuItem {
return {
...metadata,
type: MenuItemType.Prompt,
getCurrentValue: this.getStringValue.bind(this),
getActionRowBuilder: this.getStringBuilder.bind(this),
setValue: this.setValue.bind(this)
};
}
private createTextareaMenuItem(metadata: MenuItem): PromptMenuItem {
return {
...metadata,
type: MenuItemType.Prompt,
getCurrentValue: this.getStringValue.bind(this),
getActionRowBuilder: this.getTextareaBuilder.bind(this),
setValue: this.setValue.bind(this)
};
}
private getChannelValue(context: FieldMenuItemContext): string { private getChannelValue(context: FieldMenuItemContext): string {
const value = this.configuration.getConfigurationByPath(context.path.join('.')); const value = this.configuration.getConfigurationByPath(context.path.join('.'));
if (value === undefined) { if (value === undefined) {
@ -193,6 +210,11 @@ export class ConfigurationMenuHandler {
return new TextInputBuilder() return new TextInputBuilder()
.setStyle(TextInputStyle.Short) .setStyle(TextInputStyle.Short)
} }
private getTextareaBuilder(context: FieldMenuItemContext): TextInputBuilder {
return new TextInputBuilder()
.setStyle(TextInputStyle.Paragraph)
}
private setValue(value: FieldMenuItemSaveValue[]|string, context: FieldMenuItemContext): void { private setValue(value: FieldMenuItemSaveValue[]|string, context: FieldMenuItemContext): void {
const savedValue = typeof value !== 'string' ? const savedValue = typeof value !== 'string' ?

View file

@ -12,12 +12,13 @@ import {isPlainObject} from "is-plain-object";
export class GroupConfigurationHandler { export class GroupConfigurationHandler {
static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = { static DEFAULT_CONFIGURATION: RuntimeGroupConfiguration = {
channels: null, channels: null,
locale: new Intl.Locale('en-GB'),
permissions: { permissions: {
allowMemberManagingPlaydates: false allowMemberManagingPlaydates: false
}, },
calendar: { calendar: {
title: null title: null,
description: null,
location: null
} }
} }

View file

@ -16,7 +16,7 @@ type GroupConfigurationTransformer = {
} }
export type GroupConfigurationResult = export type GroupConfigurationResult =
ChannelId | Intl.Locale | boolean | string ChannelId | Intl.Locale | boolean | string | null
export class GroupConfigurationTransformers { export class GroupConfigurationTransformers {
static TRANSFORMERS: GroupConfigurationTransformer[] = [ static TRANSFORMERS: GroupConfigurationTransformer[] = [
@ -28,10 +28,6 @@ export class GroupConfigurationTransformers {
path: ['channels', 'playdateReminders'], path: ['channels', 'playdateReminders'],
type: TransformerType.Channel, type: TransformerType.Channel,
}, },
{
path: ['locale'],
type: TransformerType.Locale,
},
{ {
path: ['permissions', 'allowMemberManagingPlaydates'], path: ['permissions', 'allowMemberManagingPlaydates'],
type: TransformerType.PermissionBoolean type: TransformerType.PermissionBoolean
@ -39,6 +35,14 @@ export class GroupConfigurationTransformers {
{ {
path: ['calendar', 'title'], path: ['calendar', 'title'],
type: TransformerType.String type: TransformerType.String
},
{
path: ['calendar', 'description'],
type: TransformerType.String,
},
{
path: ['calendar', 'location'],
type: TransformerType.String
} }
]; ];

View file

@ -3,7 +3,6 @@ import {Nullable} from "../types/Nullable";
export type RuntimeGroupConfiguration = { export type RuntimeGroupConfiguration = {
channels: Nullable<ChannelRuntimeGroupConfiguration>, channels: Nullable<ChannelRuntimeGroupConfiguration>,
locale: Intl.Locale,
permissions: PermissionRuntimeGroupConfiguration, permissions: PermissionRuntimeGroupConfiguration,
calendar: CalendarRuntimeGroupConfiguration calendar: CalendarRuntimeGroupConfiguration
}; };
@ -18,5 +17,7 @@ export type PermissionRuntimeGroupConfiguration = {
} }
export type CalendarRuntimeGroupConfiguration = { export type CalendarRuntimeGroupConfiguration = {
title: null|string title: null|string,
description: null|string,
location: null|string
} }

View file

@ -2,7 +2,7 @@ import {Repository} from "./Repository";
import {GroupModel} from "../Models/GroupModel"; import {GroupModel} from "../Models/GroupModel";
import Groups, {DBGroup} from "../Database/tables/Groups"; import Groups, {DBGroup} from "../Database/tables/Groups";
import {DatabaseConnection} from "../Database/DatabaseConnection"; import {DatabaseConnection} from "../Database/DatabaseConnection";
import {GuildMember} from "discord.js"; import {GuildMember, UserFlagsBitField} from "discord.js";
import {Nullable} from "../types/Nullable"; import {Nullable} from "../types/Nullable";
import {PlaydateRepository} from "./PlaydateRepository"; import {PlaydateRepository} from "./PlaydateRepository";
import {Container} from "../Container/Container"; import {Container} from "../Container/Container";
@ -16,7 +16,6 @@ export class GroupRepository extends Repository<GroupModel, DBGroup> {
database, database,
Groups Groups
); );
} }
public findGroupByName(name: string): Nullable<GroupModel> { public findGroupByName(name: string): Nullable<GroupModel> {
@ -61,6 +60,19 @@ export class GroupRepository extends Repository<GroupModel, DBGroup> {
}) })
} }
public isMemberInGroup(member: Nullable<GuildMember>, group: GroupModel): boolean
{
if (!member) {
throw new Error("Can't find member for guild: none given");
}
const groups = this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()])
return groups.some((dbGroup) => {
return group.id === dbGroup.id;
})
}
public deleteGroup(group: GroupModel): void { public deleteGroup(group: GroupModel): void {
this.delete(group.id); this.delete(group.id);