This commit is contained in:
Michel Fedde 2025-06-18 22:53:54 +02:00
parent 441715675c
commit a79898b2e9
48 changed files with 2062 additions and 1503 deletions

View file

@ -1,16 +1,16 @@
import {ChatInputCommandInteraction, Interaction, SlashCommandBuilder} from "discord.js";
export interface Command {
definition(): SlashCommandBuilder;
definition(): SlashCommandBuilder;
}
export interface ChatInteractionCommand {
execute(interaction: ChatInputCommandInteraction): Promise<void>;
execute(interaction: ChatInputCommandInteraction): Promise<void>;
}
export interface AutocompleteCommand {
handleAutocomplete(interaction: Interaction): Promise<void>;
handleAutocomplete(interaction: Interaction): Promise<void>;
}
export type CommandUnion =
Command | Partial<ChatInteractionCommand> | Partial<AutocompleteCommand>;
export type CommandUnion =
Command | Partial<ChatInteractionCommand> | Partial<AutocompleteCommand>;

View file

@ -2,40 +2,54 @@ import {HelloWorldCommand} from "./HelloWorldCommand";
import {Command, CommandUnion} from "./Command";
import {GroupCommand} from "./Groups";
import {PlaydatesCommand} from "./Playdates";
import {RESTPostAPIChatInputApplicationCommandsJSONBody} from "discord.js";
import {Nullable} from "../../types/Nullable";
const commands: Set<Command> = new Set<Command>([
new HelloWorldCommand(),
new GroupCommand(),
new PlaydatesCommand()
new HelloWorldCommand(),
new GroupCommand(),
new PlaydatesCommand()
]);
export default class Commands {
private mappedCommands: Map<string, Command> = new Map<string, Command>();
constructor() {
this.mappedCommands = this.getMap();
private mappedCommands: Map<string, Command> = new Map<string, Command>();
private definitions: Nullable<RESTPostAPIChatInputApplicationCommandsJSONBody[]>;
constructor() {
this.mappedCommands = this.getMap();
}
public getCommand(commandName: string): CommandUnion | undefined {
if (!this.mappedCommands.has(commandName)) {
throw new Error(`Unknown command: ${commandName}`);
}
public getCommand(commandName: string): CommandUnion|undefined {
if (!this.mappedCommands.has(commandName)) {
throw new Error(`Unknown command: ${commandName}`);
}
return this.mappedCommands.get(commandName);
return this.mappedCommands.get(commandName);
}
public get allCommands(): Set<Command> {
return commands;
}
public getJsonDefinitions(): RESTPostAPIChatInputApplicationCommandsJSONBody[] {
if (this.definitions) {
return this.definitions;
}
public get allCommands(): Set<Command> {
return commands;
}
private getMap(): Map<string, Command>
{
const map = new Map<string, Command>();
for (const command of commands) {
const definition = command.definition()
map.set(definition.name, command);
}
return map;
this.definitions = [];
commands.forEach((command) => {
this.definitions?.push(command.definition().toJSON())
})
return this.definitions;
}
private getMap(): Map<string, Command> {
const map = new Map<string, Command>();
for (const command of commands) {
const definition = command.definition()
map.set(definition.name, command);
}
return map;
}
}

View file

@ -1,12 +1,12 @@
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
MessageFlags,
InteractionReplyOptions,
GuildMember,
EmbedBuilder,
AutocompleteInteraction,
roleMention, time, userMention, GuildMemberRoleManager
MessageFlags,
InteractionReplyOptions,
GuildMember,
EmbedBuilder,
AutocompleteInteraction,
roleMention, time, userMention, GuildMemberRoleManager
} from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {GroupModel} from "../../Models/GroupModel";
@ -142,10 +142,10 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
Container.get<GroupRepository>(GroupRepository.name).create(group);
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 validateGroupName(name: string): true|string{
private validateGroupName(name: string): true | string {
const lowercaseName = name.toLowerCase();
for (const invalidcharactersequence of GroupCommand.INVALID_CHARACTER_SEQUENCES) {
if (!lowercaseName.includes(invalidcharactersequence)) {
@ -162,7 +162,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
const groups = repo.findGroupsByMember(<GuildMember>interaction.member);
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const embed = new EmbedBuilder()
.setTitle("Your groups on this server:")
.setFields(
@ -191,7 +191,7 @@ export class GroupCommand implements Command, ChatInteractionCommand, Autocomple
embeds: [
embed
],
allowedMentions: { roles: [] },
allowedMentions: {roles: []},
flags: MessageFlags.Ephemeral
}

View file

@ -2,24 +2,24 @@ import {SlashCommandBuilder, CommandInteraction} from "discord.js";
import {Command} from "./Command";
export class HelloWorldCommand implements Command {
private static RESPONSES: string[] = [
'Hello :)',
'zzzZ... ZzzzZ... huh? I am awake. I am awake!',
'Roll for initiative!',
'I was an adventurer like you...',
'Hello :) How are you?',
]
definition(): SlashCommandBuilder
{
return new SlashCommandBuilder()
.setName("hello")
.setDescription("Displays a random response. (commonly used to test if the bot is online)")
}
async execute(interaction: CommandInteraction): Promise<void> {
const random = Math.floor(Math.random() * HelloWorldCommand.RESPONSES.length);
await interaction.reply(HelloWorldCommand.RESPONSES[random]);
return Promise.resolve();
}
private static RESPONSES: string[] = [
'Hello :)',
'zzzZ... ZzzzZ... huh? I am awake. I am awake!',
'Roll for initiative!',
'I was an adventurer like you...',
'Hello :) How are you?',
]
definition(): SlashCommandBuilder {
return new SlashCommandBuilder()
.setName("hello")
.setDescription("Displays a random response. (commonly used to test if the bot is online)")
}
async execute(interaction: CommandInteraction): Promise<void> {
const random = Math.floor(Math.random() * HelloWorldCommand.RESPONSES.length);
await interaction.reply(HelloWorldCommand.RESPONSES[random]);
return Promise.resolve();
}
}

View file

@ -1,8 +1,8 @@
import {
SlashCommandBuilder,
CommandInteraction,
AutocompleteInteraction,
EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time
SlashCommandBuilder,
CommandInteraction,
AutocompleteInteraction,
EmbedBuilder, MessageFlags, ChatInputCommandInteraction, time, AttachmentBuilder, ActivityFlagsBitField, Options
} from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {Container} from "../../Container/Container";
@ -11,195 +11,325 @@ import {UserError} from "../UserError";
import {PlaydateModel} from "../../Models/PlaydateModel";
import {PlaydateRepository} from "../../Repositories/PlaydateRepository";
import {GroupModel} from "../../Models/GroupModel";
import * as ics from 'ics';
import ical from 'node-ical';
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
static REGEX = [
]
definition(): SlashCommandBuilder {
// @ts-expect-error Command builder is improperly marked as incomplete.
return new SlashCommandBuilder()
.setName("playdates")
.setDescription("Manage your playdates")
.addSubcommand((subcommand) => subcommand
.setName("create")
.setDescription("Creates a new playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addStringOption((option) => option
.setName("from")
.setDescription("Defines the start date & time. Format: YYYY-MM-DD HH:mm")
)
.addStringOption((option) => option
.setName("to")
.setDescription("Defines the end date & time. Format: YYYY-MM-DD HH:mm")
)
)
.addSubcommand((subcommand) => subcommand
.setName("list")
.setDescription("Lists all playdates")
.addIntegerOption(GroupSelection.createOptionSetup())
)
.addSubcommand((subcommand) => subcommand
.setName("remove")
.setDescription("Removes a playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addIntegerOption((option) => option
.setName("playdate")
.setDescription("Selects a playdate")
.setRequired(true)
.setAutocomplete(true)
)
);
}
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const group = GroupSelection.getGroup(interaction);
switch (interaction.options.getSubcommand()) {
case "create":
await this.create(interaction, group);
break;
case "remove":
await this.delete(interaction, group);
break;
case "list":
await this.list(interaction, group);
break;
default:
throw new UserError("This subcommand is not yet implemented.");
}
}
async create(interaction: CommandInteraction, group: GroupModel): Promise<void> {
const fromDate = Date.parse(<string>interaction.options.get("from")?.value ?? '');
const toDate = Date.parse(<string>interaction.options.get("to")?.value ?? '');
if (isNaN(fromDate)) {
throw new UserError("No date or invalid date format for the from parameter.");
}
if (isNaN(toDate)) {
throw new UserError("No date or invalid date format for the to parameter.");
}
if (fromDate > toDate) {
throw new UserError("The to-date can't be earlier than the from-date");
}
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const collidingTimes = playdateRepo.findPlaydatesInRange(fromDate, toDate, group);
if (collidingTimes.length > 0) {
throw new UserError("The playdate collides with another playdate. Please either remove the old one or choose a different time.")
}
const playdate: Partial<PlaydateModel> = {
group: group,
from_time: new Date(fromDate),
to_time: new Date(toDate),
}
playdateRepo.create(playdate);
const embed = new EmbedBuilder()
.setTitle("Created a play-date.")
.setDescription(":white_check_mark: Your playdate has been created! You and your group get notified, when its time.")
.setFields({
name: "Created playdate",
value: `${time(new Date(fromDate),'F')} - ${time(new Date(toDate), 'F')}`,
})
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
async handleAutocomplete(interaction: AutocompleteInteraction): Promise<void> {
const option = interaction.options.getFocused(true);
if (option.name == "group") {
await GroupSelection.handleAutocomplete(interaction);
return;
}
if (option.name != 'playdate') {
return;
}
const group = GroupSelection.getGroup(interaction);
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
await interaction.respond(
playdates.map(playdate => {
return {
name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`,
value: playdate.id
}
})
definition(): SlashCommandBuilder {
// @ts-expect-error Command builder is improperly marked as incomplete.
return new SlashCommandBuilder()
.setName("playdates")
.setDescription("Manage your playdates")
.addSubcommand((subcommand) => subcommand
.setName("create")
.setDescription("Creates a new playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addStringOption((option) => option
.setName("from")
.setDescription("Defines the start date & time. Format: YYYY-MM-DD HH:mm")
)
.addStringOption((option) => option
.setName("to")
.setDescription("Defines the end date & time. Format: YYYY-MM-DD HH:mm")
)
)
.addSubcommand((subcommand) => subcommand
.setName("list")
.setDescription("Lists all playdates")
.addIntegerOption(GroupSelection.createOptionSetup())
)
.addSubcommand((subcommand) => subcommand
.setName("remove")
.setDescription("Removes a playdate")
.addIntegerOption(GroupSelection.createOptionSetup())
.addIntegerOption((option) => option
.setName("playdate")
.setDescription("Selects a playdate")
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand => subcommand
.setName('import')
.setDescription("Imports playdates from iCal files")
.addIntegerOption(GroupSelection.createOptionSetup())
.addAttachmentOption(attachment => attachment
.setName("file")
.setDescription("The iCal File to import")
.setRequired(true)
)
)
.addSubcommand((subcommand) => subcommand
.setName("export")
.setDescription("Exports a playdate as iCal file.")
.addIntegerOption(GroupSelection.createOptionSetup())
.addIntegerOption((option) => option
.setName("playdate")
.setDescription("Selects a playdate to export")
.setAutocomplete(true)
)
.addBooleanOption(option => option
.setName("future-dates")
.setDescription("Exports the next playdates as a ical file")
)
);
}
async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const group = GroupSelection.getGroup(interaction);
switch (interaction.options.getSubcommand()) {
case "create":
await this.create(interaction, group);
break;
case "remove":
await this.delete(interaction, group);
break;
case "list":
await this.list(interaction, group);
break;
case "import":
await this.import(interaction, group);
break;
case "export":
await this.export(interaction, group);
break;
default:
throw new UserError("This subcommand is not yet implemented.");
}
}
async create(interaction: CommandInteraction, group: GroupModel): Promise<void> {
const fromDate = Date.parse(<string>interaction.options.get("from")?.value ?? '');
const toDate = Date.parse(<string>interaction.options.get("to")?.value ?? '');
if (isNaN(fromDate)) {
throw new UserError("No date or invalid date format for the from parameter.");
}
private async list(interaction: ChatInputCommandInteraction, group: GroupModel) {
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
if (isNaN(toDate)) {
throw new UserError("No date or invalid date format for the to parameter.");
}
const embed = new EmbedBuilder()
.setTitle("The next playdates:")
.setFields(
playdates.map((playdate) =>
{
return {
name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`,
value: `${time(playdate.from_time, 'R')}`
}
})
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
if (fromDate > toDate) {
throw new UserError("The to-date can't be earlier than the from-date");
}
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const collidingTimes = playdateRepo.findPlaydatesInRange(fromDate, toDate, group);
if (collidingTimes.length > 0) {
throw new UserError("The playdate collides with another playdate. Please either remove the old one or choose a different time.")
}
const playdate: Partial<PlaydateModel> = {
group: group,
from_time: new Date(fromDate),
to_time: new Date(toDate),
}
playdateRepo.create(playdate);
const embed = new EmbedBuilder()
.setTitle("Created a play-date.")
.setDescription(":white_check_mark: Your playdate has been created! You and your group get notified, when its time.")
.setFields({
name: "Created playdate",
value: `${time(new Date(fromDate), 'F')} - ${time(new Date(toDate), 'F')}`,
})
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
async handleAutocomplete(interaction: AutocompleteInteraction): Promise<void> {
const option = interaction.options.getFocused(true);
if (option.name == "group") {
await GroupSelection.handleAutocomplete(interaction);
return;
}
if (option.name != 'playdate') {
return;
}
const group = GroupSelection.getGroup(interaction);
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
await interaction.respond(
playdates.map(playdate => {
return {
name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`,
value: <number>playdate.id
}
})
)
}
private async list(interaction: ChatInputCommandInteraction, group: GroupModel) {
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
const embed = new EmbedBuilder()
.setTitle("The next playdates:")
.setFields(
playdates.map((playdate) => {
return {
name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`,
value: `${time(playdate.from_time, 'R')}`
}
})
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const playdateId = interaction.options.getInteger("playdate", true)
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const selected = repo.getById(playdateId);
if (!selected) {
throw new UserError("No playdate found");
}
if (selected.group?.id != group.id) {
throw new UserError("No playdate found");
}
repo.delete(playdateId);
const embed = new EmbedBuilder()
.setTitle("Playdate deleted")
.setDescription(
`:x: Deleted \`${selected.from_time.toLocaleString()} - ${selected.to_time.toLocaleString()}\``
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
private async import(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const file = interaction.options.getAttachment('file', true);
const mimeType = file.contentType?.split(';')[0];
if (mimeType !== "text/calendar") {
throw new UserError(`Invalid ical file. Got: ${mimeType}`, "Providing a valid iCal file");
}
private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const playdateId = interaction.options.getInteger("playdate", true)
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const selected = repo.getById(playdateId);
if (!selected) {
throw new UserError("No playdate found");
}
if (selected.group?.id != group.id) {
throw new UserError("No playdate found");
}
repo.delete(playdateId);
const embed = new EmbedBuilder()
.setTitle("Playdate deleted")
.setDescription(
`:x: Deleted \`${selected.from_time.toLocaleString()} - ${selected.to_time.toLocaleString()}\``
)
.setFooter({
text: `Group: ${group.name}`
})
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
await interaction.deferReply({
flags: MessageFlags.Ephemeral
});
const playdateRepo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const icalFile = await ical.async.fromURL(file.url);
const playdates: PlaydateModel[] = [];
for (const event of Object.values(icalFile)) {
if (event.type !== 'VEVENT') {
continue;
}
const playdate: Partial<PlaydateModel> = {
group: group,
from_time: event.start,
to_time: event.end
}
const id = playdateRepo.create(playdate);
playdates.push(<PlaydateModel>{
id,
...playdate
});
}
const embed = new EmbedBuilder()
.setTitle("Imported play-dates.")
.setDescription(`:white_check_mark: Your ${playdates.length} playdates has been created! You and your group get notified, when its time.`)
.setFields({
name: "Created playdates",
value: playdates.map((playdate) => `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`).join('\n')
})
.setFooter({
text: `Group: ${group.name}`
})
interaction.followUp({
embeds: [embed],
flags: MessageFlags.Ephemeral
})
}
private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const playdates = this.getExportTargets(interaction, group);
const result = ics.createEvents(
playdates.map((playdate) => {
return {
start: ics.convertTimestampToArray(playdate.from_time.getTime(), ''),
startInputType: 'utc',
startOutputType: 'utc',
end: ics.convertTimestampToArray(playdate.to_time.getTime(), ''),
endInputType: 'utc',
endOutputType: 'utc',
title: `PnP with ${group.name}`,
status: "CONFIRMED",
busyStatus: "FREE",
categories: ['PnP']
}
})
);
if (!result.value) {
throw new Error("Failed creating ics file...")
}
const attachment = new AttachmentBuilder(Buffer.from(result.value), {
name: "ICSExport.ics",
description: "Your export :)"
});
interaction.reply({
files: [attachment],
flags: MessageFlags.Ephemeral
})
}
private getExportTargets(interaction: ChatInputCommandInteraction, group: GroupModel): PlaydateModel[] {
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
const nextPlaydates = interaction.options.getBoolean("future-dates") ?? false;
if (nextPlaydates) {
return repo.findPlaydatesInRange(new Date());
}
const playdateId = interaction.options.getInteger('playdate');
if (!playdateId) {
throw new UserError("Nothing to export", "Please specify what you want to export.");
}
const playdate = repo.getById(playdateId);
if (!playdate) {
throw new UserError("Specified playdate id is invalid");
}
return [playdate];
}
}