import { AttachmentBuilder, AutocompleteInteraction, ChatInputCommandInteraction, CommandInteraction, EmbedBuilder, GuildMember, 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.") ) .addStringOption((option) => option .setName("to") .setDescription("Defines the end date & time. Your desired format is probably support.") ) ) .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 { 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 { if (!this.interactionIsAllowedToManage(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 [fromDate, toDate] = timezoneHandler.use(() => { return [ parser.fromString(interaction.options.get("from")?.value ?? ''), parser.fromString(interaction.options.get("to")?.value ?? '') ] }) if (!fromDate.isValid()) { throw new UserError("No date or invalid date format for the from parameter."); } if (!fromDate.isValid()) { throw new UserError("No date or invalid date format for the to parameter."); } if (fromDate.getTime() > toDate.getTime()) { throw new UserError("The to-date can't be earlier than the from-date"); } const playdateRepo = Container.get(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 = { 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 { 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.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: playdate.id } }) }) ) } private async list(interaction: ChatInputCommandInteraction, group: GroupModel) { const playdates = Container.get(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 { if (!this.interactionIsAllowedToManage(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.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 { if (!this.interactionIsAllowedToManage(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.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 = { group: group, from_time: event.start, to_time: event.end } const id = playdateRepo.create(playdate); playdates.push({ 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 { const groupConfig = new ConfigurationHandler< GroupConfigurationModel, RuntimeGroupConfiguration >( new GroupConfigurationProvider( Container.get(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.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.name); if (!groupRepo.isMemberInGroup(interaction.member, group)) { return false; } const config = new ConfigurationHandler( new GroupConfigurationProvider( Container.get(GroupConfigurationRepository.name), group ) ); return config.getConfigurationByPath("permissions.allowMemberManagingPlaydates").value === true; } }