pnp-scheduler/source/Discord/Commands/Playdates.ts

471 lines
No EOL
16 KiB
TypeScript

import {
AttachmentBuilder,
AutocompleteInteraction,
ChatInputCommandInteraction,
CommandInteraction,
EmbedBuilder,
GuildMember, hyperlink,
MessageFlags,
SlashCommandBuilder,
time
} from "discord.js";
import {AutocompleteCommand, ChatInteractionCommand, Command} from "./Command";
import {Container} from "../../Container/Container";
import {GroupSelection} from "../CommandPartials/GroupSelection";
import {UserError} from "../UserError";
import {PlaydateModel} from "../../Database/Models/PlaydateModel";
import {PlaydateRepository} from "../../Database/Repositories/PlaydateRepository";
import {GroupModel} from "../../Database/Models/GroupModel";
import * as ics from 'ics';
import ical from 'node-ical';
import {GroupConfigurationRepository} from "../../Database/Repositories/GroupConfigurationRepository";
import {GroupRepository} from "../../Database/Repositories/GroupRepository";
import {
GroupConfigurationProvider,
RuntimeGroupConfiguration
} from "../../Configuration/Groups/GroupConfigurationProvider";
import {ConfigurationHandler} from "../../Configuration/ConfigurationHandler";
import {PermissionError} from "../PermissionError";
import {EmbedLibrary, EmbedType} from "../EmbedLibrary";
import {GroupConfigurationModel} from "../../Database/Models/GroupConfigurationModel";
import parser from "any-date-parser";
import {TimezoneHandler} from "../../Configuration/TimezoneHandler";
import _ from "lodash";
export class PlaydatesCommand implements Command, AutocompleteCommand, ChatInteractionCommand {
definition(): SlashCommandBuilder {
// @ts-expect-error Command builder is improperly marked as incomplete.
return new SlashCommandBuilder()
.setName("playdate")
.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. Your desired format is probably support.")
.setRequired(true)
)
.addStringOption((option) => option
.setName("to")
.setDescription("Defines the end date & time. Your desired format is probably support.")
.setRequired(true)
)
)
.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> {
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 timezoneHandler = new TimezoneHandler(
interaction.guildId ?? '',
group
);
const fromDateString = <string>interaction.options.get("from")?.value ?? '';
const toDateString = <string>interaction.options.get("to")?.value ?? '';
if (!this.checkDateString(fromDateString) || !this.checkDateString(toDateString)) {
throw new UserError(
"Please do not use words like 'Uhr', since those may result in values you did not intent.",
`Write the time out completely. Either in military time (15:00) or in the 12-hour format (3pm). For more valid formats check this ${hyperlink("list", "https://github.com/kensnyder/any-date-parser?tab=readme-ov-file#exhaustive-list-of-date-formats")}`
)
}
const [fromDate, toDate] = timezoneHandler.use(() => {
const fromDate = parser.fromString(fromDateString);
const toAttempt = parser.attempt(toDateString);
if (toAttempt.invalid) {
throw new UserError(
"No date or invalid date format for the to parameter.",
`If you want to make sure your format is valid, please check this ${hyperlink("list", "https://github.com/kensnyder/any-date-parser?tab=readme-ov-file#exhaustive-list-of-date-formats")}.`
);
}
const toDate = new Date(fromDate.getTime());
if (toAttempt.year !== undefined) {
toDate.setFullYear(toAttempt.year, toAttempt.month ?? 0, toAttempt.day ?? 0);
}
if (toAttempt.hour !== undefined) {
toDate.setHours(toAttempt.hour, toAttempt.minute ?? 0, toAttempt.second ?? 0);
}
return [
fromDate,
toDate
]
})
if (!fromDate.isValid()) {
throw new UserError(
"No date or invalid date format for the from parameter.",
`If you want to make sure your format is valid, please check this ${hyperlink("list", "https://github.com/kensnyder/any-date-parser?tab=readme-ov-file#exhaustive-list-of-date-formats")}.`
);
}
if (!fromDate.isValid()) {
throw new UserError(
"No date or invalid date format for the to parameter.",
`If you want to make sure your format is valid, please check this ${hyperlink("list", "https://github.com/kensnyder/any-date-parser?tab=readme-ov-file#exhaustive-list-of-date-formats")}.`
);
}
if (fromDate.getTime() > toDate.getTime()) {
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: fromDate,
to_time: toDate,
}
playdateRepo.create(playdate);
const embed = EmbedLibrary.withGroup(
group,
"Created a play-date.",
":white_check_mark: Your playdate has been created! You and your group get notified, when its time.",
EmbedType.Success
).setFields({
name: "Created playdate",
value: `${time(new Date(fromDate), 'F')} - ${time(new Date(toDate), 'F')}`,
})
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 timezone = new TimezoneHandler(
interaction.guildId ?? '',
group
);
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
await interaction.respond(
timezone.use(() => {
return _.slice(playdates, 0, 25).map(playdate => {
return {
name: `${playdate.from_time.toLocaleString()} - ${playdate.to_time.toLocaleString()}`,
value: <number>playdate.id
}
})
})
)
}
private checkDateString(value: string): boolean {
return [
'Uhr'
].some((section) => value.includes(section));
}
private async list(interaction: ChatInputCommandInteraction, group: GroupModel) {
const playdates = Container.get<PlaydateRepository>(PlaydateRepository.name).findFromGroup(group);
const embed = EmbedLibrary.withGroup(
group,
"Created a play-date.",
null,
EmbedType.Info
).setFields(
playdates.map((playdate) => {
return {
name: `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`,
value: `${time(playdate.from_time, 'R')}`
}
})
)
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
private async delete(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
if (!this.interactionIsAllowedToManage(<ChatInputCommandInteraction>interaction, group)) {
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."
)
}
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 = EmbedLibrary.withGroup(
group,
"Playdate deleted",
`:x: Deleted ${time(selected.from_time, 'F')} - ${time(selected.to_time, 'F')}`,
EmbedType.Success
);
await interaction.reply({
embeds: [
embed
],
flags: MessageFlags.Ephemeral,
})
}
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 mimeType = file.contentType?.split(';')[0];
if (mimeType !== "text/calendar") {
throw new UserError(`Invalid ical file. Got: ${mimeType}`, "Providing a valid iCal file");
}
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 = EmbedLibrary.withGroup(
group,
"Imported play-dates",
`:white_check_mark: Your ${playdates.length} playdates has been created! You and your group get notified, when its time.`,
EmbedType.Success
).setFields({
name: "Created playdates",
value: playdates.map((playdate) => `${time(playdate.from_time, 'F')} - ${time(playdate.to_time, 'F')}`).join('\n')
})
interaction.followUp({
embeds: [embed],
flags: MessageFlags.Ephemeral
})
}
private async export(interaction: ChatInputCommandInteraction, group: GroupModel): Promise<void> {
const groupConfig = new ConfigurationHandler<
GroupConfigurationModel,
RuntimeGroupConfiguration
>(
new GroupConfigurationProvider(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group
)
).getCompleteConfiguration();
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(
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: groupConfig.calendar.title ?? `PnP with ${group.name}`,
description: groupConfig.calendar.description ?? undefined,
location: groupConfig.calendar.location ?? undefined,
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];
}
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 ConfigurationHandler(
new GroupConfigurationProvider(
Container.get<GroupConfigurationRepository>(GroupConfigurationRepository.name),
group
)
);
return config.getConfigurationByPath("permissions.allowMemberManagingPlaydates").value === true;
}
}