diff --git a/src/event-distribution/events/events.ts b/src/event-distribution/events/events.ts index b7861c3c5..be72a73f2 100644 --- a/src/event-distribution/events/events.ts +++ b/src/event-distribution/events/events.ts @@ -316,9 +316,10 @@ export const rejectWithError = async ( error ); - if (interactionArg.deferred) { + if (interactionArg.deferred || interactionArg.replied) { return await interactionArg.editReply({ content: detailedMessage, + components: [], }); } diff --git a/src/programs/nitro-colors/index.ts b/src/programs/nitro-colors/index.ts index fd7cabaec..8a01acf6c 100644 --- a/src/programs/nitro-colors/index.ts +++ b/src/programs/nitro-colors/index.ts @@ -22,7 +22,7 @@ import prisma from "../../prisma"; export const logger = createYesBotLogger("programs", "NitroColors"); export let nitroRolesCache: Collection; -export let colorSelectionMessage: Message; +export let colorSelectionMessage: Message | undefined; @Command({ event: DiscordEvent.READY, @@ -78,7 +78,7 @@ class RemoveNitroColorIfNotAllowed if (!nitroColor) return; - colorSelectionMessage.reactions.cache.find( + colorSelectionMessage?.reactions.cache.find( (reactions) => !!reactions.users.remove(member) ); await member.roles.remove(nitroColor); @@ -149,4 +149,4 @@ const memberHasNitroColor = (member: GuildMember) => ); export const isColorSelectionMessage = (messageId: Snowflake) => - colorSelectionMessage.id === messageId; + colorSelectionMessage?.id === messageId; diff --git a/src/programs/nitro-colors/roles-reset.ts b/src/programs/nitro-colors/roles-reset.ts index 4830832a1..1b36dacd9 100644 --- a/src/programs/nitro-colors/roles-reset.ts +++ b/src/programs/nitro-colors/roles-reset.ts @@ -2,9 +2,8 @@ import bot from "../../index"; import cron from "node-cron"; import { nitroRolesCache, colorSelectionMessage, logger } from "."; import Tools from "../../common/tools"; -import { ColorResolvable, Colors, Role, TextChannel } from "discord.js"; +import { ColorResolvable, TextChannel } from "discord.js"; import { - NitroRole, buildAnnouncementsMessage, getCurrentSeason, isNewSeason, @@ -28,7 +27,7 @@ export class RoleResetCron { const cleanupChannelMessages = async (channel: TextChannel) => { const messages = await channel.messages.fetch({ limit: 5 }); for (const message of messages.values()) { - if (message.id !== colorSelectionMessage.id) { + if (message.id !== colorSelectionMessage?.id) { await message.delete(); continue; } diff --git a/src/programs/tickets/travel/approve-travel-button.ts b/src/programs/tickets/travel/approve-travel-button.ts new file mode 100644 index 000000000..aae3d2996 --- /dev/null +++ b/src/programs/tickets/travel/approve-travel-button.ts @@ -0,0 +1,57 @@ +import { + Command, + CommandHandler, + DiscordEvent, +} from "../../../event-distribution"; +import { + ThreadAutoArchiveDuration, + ButtonInteraction, + TextChannel, +} from "discord.js"; +import { ChatNames } from "../../../collections/chat-names"; +import { TravelDataMessageConverter } from "./travel-data-message-converter"; + +@Command({ + event: DiscordEvent.BUTTON_CLICKED, + customId: "travel-approve", +}) +class ApproveTravelButton extends CommandHandler { + async handle(interaction: ButtonInteraction): Promise { + const message = interaction.message; + const guild = interaction.guild!; + + const details = TravelDataMessageConverter.fromMessage(message, guild); + + const member = guild.members.resolve(interaction.user.id); + if (!member) throw new Error("Could not resolve approving member!"); + + const approver = member.displayName; + const newContent = message.content + `\n\nApproved by ${approver}`; + // Remove buttons as early as possible before someone else votes as well + await message.edit({ content: newContent, components: [] }); + + const travelChannel = interaction.guild?.channels.cache.find( + (c): c is TextChannel => c.name === ChatNames.TRAVELING_TOGETHER + ); + if (!travelChannel) throw new Error("Could not find travel channel!"); + + const messageWithMentions = TravelDataMessageConverter.toMessage( + details, + true + ); + + const travelPost = + messageWithMentions + + "\n\nClick on the thread right below this line if you're interested to join the chat and talk about it 🙂"; + const travelMessage = await travelChannel?.send(travelPost); + + const ticketMember = await guild.members.fetch(details.userId); + const threadName = `${ticketMember.displayName} in ${details.places}`; + const trimmedThreadname = threadName.substring(0, 100); + + await travelMessage.startThread({ + name: trimmedThreadname, + autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, + }); + } +} diff --git a/src/programs/tickets/travel/cancel-travel-button.ts b/src/programs/tickets/travel/cancel-travel-button.ts new file mode 100644 index 000000000..76626dd85 --- /dev/null +++ b/src/programs/tickets/travel/cancel-travel-button.ts @@ -0,0 +1,21 @@ +import { + Command, + CommandHandler, + DiscordEvent, + EventLocation, +} from "../../../event-distribution"; +import { ButtonInteraction } from "discord.js"; + +@Command({ + event: DiscordEvent.BUTTON_CLICKED, + customId: "travel-cancel", + location: EventLocation.DIRECT_MESSAGE, +}) +class CancelTravelButton extends CommandHandler { + async handle(interaction: ButtonInteraction): Promise { + await interaction.message.delete(); + await interaction.reply( + "Feel free to open up a new travel ticket anytime using the /travel command!" + ); + } +} diff --git a/src/programs/tickets/travel/decline-travel-button.ts b/src/programs/tickets/travel/decline-travel-button.ts new file mode 100644 index 000000000..7a6c32752 --- /dev/null +++ b/src/programs/tickets/travel/decline-travel-button.ts @@ -0,0 +1,126 @@ +import { + Command, + CommandHandler, + DiscordEvent, +} from "../../../event-distribution"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { + TravelDataMessageConverter, + TripDetails, +} from "./travel-data-message-converter"; + +@Command({ + event: DiscordEvent.BUTTON_CLICKED, + customId: "travel-decline", +}) +class DeclineTravelButton extends CommandHandler { + async handle(interaction: ButtonInteraction): Promise { + const message = interaction.message; + const originalMessage = message.content; + const guild = interaction.guild!; + const details = TravelDataMessageConverter.fromMessage(message, guild); + + const member = guild.members.resolve(interaction.user.id); + if (!member) throw new Error("Could not resolve approving member!"); + + const decliner = member.displayName; + const newContent = + message.content + `\n\n${decliner} is currently declining...`; + await message.edit({ content: newContent }); + + const { userId } = details; + + const reasonInput = new ActionRowBuilder({ + components: [ + new TextInputBuilder({ + customId: "reason", + style: TextInputStyle.Paragraph, + required: true, + label: "Reason", + }), + ], + }); + + const modalId = "travel-decline-reason-" + userId; + await interaction.showModal({ + title: "Decline reason", + customId: modalId, + components: [reasonInput], + }); + try { + const submission = await interaction.awaitModalSubmit({ + time: 5 * 60 * 1000, + filter: (i) => i.customId === modalId, + }); + + await this.declineWithReason(submission, originalMessage, details); + } catch { + await interaction.update({ content: originalMessage }); + } + } + + async declineWithReason( + submission: ModalSubmitInteraction, + originalMessage: string, + details: TripDetails + ) { + const reason = submission.fields.getTextInputValue("reason").trim(); + if (!submission.isFromMessage()) return; + const guild = submission.guild!; + const member = guild.members.resolve(submission.user.id); + if (!member) throw new Error("Could not resolve approving member!"); + + const decliner = member.displayName; + + if (!reason) { + await submission.update({ content: originalMessage }); + return; + } + + await submission.update({ + content: + originalMessage + `\n\nDeclined by ${decliner}\nReason: ${reason}`, + components: [], + }); + + const editButtonId = "travel-edit"; + const editButton = new ButtonBuilder({ + label: "Edit", + style: ButtonStyle.Primary, + customId: editButtonId, + emoji: "✏", + }); + + const cancelButtonId = "travel-cancel"; + const cancelButton = new ButtonBuilder({ + label: "Cancel", + style: ButtonStyle.Danger, + customId: cancelButtonId, + }); + + const dm = await submission.client.users.createDM(details.userId); + await dm.send({ + content: `Hello there! A moderator has declined your travel ticket with the following reason: ${reason} + +This was your submission: +--- +${originalMessage} +--- +You can edit or cancel your ticket: + `, + components: [ + new ActionRowBuilder({ + components: [editButton, cancelButton], + }), + ], + }); + } +} diff --git a/src/programs/tickets/travel/edit-travel-button.ts b/src/programs/tickets/travel/edit-travel-button.ts new file mode 100644 index 000000000..cd465692f --- /dev/null +++ b/src/programs/tickets/travel/edit-travel-button.ts @@ -0,0 +1,36 @@ +import { + Command, + CommandHandler, + DiscordEvent, +} from "../../../event-distribution"; +import { ButtonInteraction } from "discord.js"; +import { TravelEditing } from "./travel-editing"; +import { TravelDataMessageConverter } from "./travel-data-message-converter"; + +@Command({ + event: DiscordEvent.BUTTON_CLICKED, + customId: "travel-edit", +}) +class EditTravelButton extends CommandHandler { + async handle(interaction: ButtonInteraction): Promise { + const editing = new TravelEditing(); + const guild = interaction.client.guilds.resolve(process.env.GUILD_ID); + + if (!guild) throw new Error("Yeet"); + + const details = TravelDataMessageConverter.fromMessage( + interaction.message, + guild + ); + + const { details: finalDetails, interaction: editInteraction } = + await editing.doEditing(details, interaction, false); + await editing.sendApprovalMessage(finalDetails, guild); + + await editInteraction.update({ + content: + "I've sent everything to the mods! Have some patience while they take a look at the updates :)", + components: [], + }); + } +} diff --git a/src/programs/tickets/travel/approve-ticket.ts b/src/programs/tickets/travel/legacy/approve-ticket.ts similarity index 93% rename from src/programs/tickets/travel/approve-ticket.ts rename to src/programs/tickets/travel/legacy/approve-ticket.ts index 152c53c99..5374ae74b 100644 --- a/src/programs/tickets/travel/approve-ticket.ts +++ b/src/programs/tickets/travel/legacy/approve-ticket.ts @@ -2,7 +2,7 @@ import { Command, CommandHandler, DiscordEvent, -} from "../../../event-distribution"; +} from "../../../../event-distribution"; import { Collection, Guild, @@ -15,9 +15,9 @@ import { ThreadChannel, User, } from "discord.js"; -import { ChatNames } from "../../../collections/chat-names"; -import { closeTicket, getChannelName, TicketType } from "../common"; -import { createYesBotLogger } from "../../../log"; +import { ChatNames } from "../../../../collections/chat-names"; +import { closeTicket, getChannelName, TicketType } from "../../common"; +import { createYesBotLogger } from "../../../../log"; import { parseOriginMember } from "./common"; @Command({ diff --git a/src/programs/tickets/travel/common.ts b/src/programs/tickets/travel/legacy/common.ts similarity index 98% rename from src/programs/tickets/travel/common.ts rename to src/programs/tickets/travel/legacy/common.ts index f8b8e7ff9..73dbba7e8 100644 --- a/src/programs/tickets/travel/common.ts +++ b/src/programs/tickets/travel/legacy/common.ts @@ -7,9 +7,9 @@ import { TextChannel, User, } from "discord.js"; -import { CountryRoleFinder } from "../../../common/country-role-finder"; -import { ChatNames } from "../../../collections/chat-names"; -import { createYesBotLogger } from "../../../log"; +import { CountryRoleFinder } from "../../../../common/country-role-finder"; +import { ChatNames } from "../../../../collections/chat-names"; +import { createYesBotLogger } from "../../../../log"; const fiveMinutes = 5 * 60 * 1000; type CancellationToken = { cancelled: boolean }; diff --git a/src/programs/tickets/travel/decline-ticket.ts b/src/programs/tickets/travel/legacy/decline-ticket.ts similarity index 89% rename from src/programs/tickets/travel/decline-ticket.ts rename to src/programs/tickets/travel/legacy/decline-ticket.ts index 35514d61e..1001c5118 100644 --- a/src/programs/tickets/travel/decline-ticket.ts +++ b/src/programs/tickets/travel/legacy/decline-ticket.ts @@ -2,10 +2,10 @@ import { Command, CommandHandler, DiscordEvent, -} from "../../../event-distribution"; -import { ChatNames } from "../../../collections/chat-names"; +} from "../../../../event-distribution"; +import { ChatNames } from "../../../../collections/chat-names"; import { MessageReaction, TextChannel, User } from "discord.js"; -import { getChannelName, TicketType } from "../common"; +import { getChannelName, TicketType } from "../../common"; import { parseOriginMember } from "./common"; @Command({ diff --git a/src/programs/tickets/travel/legacy/open-travel-ticket.ts b/src/programs/tickets/travel/legacy/open-travel-ticket.ts new file mode 100644 index 000000000..0c39f65ea --- /dev/null +++ b/src/programs/tickets/travel/legacy/open-travel-ticket.ts @@ -0,0 +1,34 @@ +import { + Command, + CommandHandler, + DiscordEvent, + HandlerRejectedReason, +} from "../../../../event-distribution"; +import { Message } from "discord.js"; +import { maybeCreateTicket, TicketType } from "../../common"; +import { promptAndSendForApproval } from "./common"; + +@Command({ + event: DiscordEvent.MESSAGE, + trigger: "!travel", + allowedRoles: ["Seek Discomfort"], + description: "This handler is to create a travel ticket.", + stateful: false, + errors: { + [HandlerRejectedReason.MISSING_ROLE]: `Before meeting up with people, it's probably best to let others know who you are! This command requires the 'Seek Discomfort' role which you can get by introducing yourself in #introductions!\n\nIf you already posted your introduction, make sure it's longer than just two or three sentences and give the support team some time to check it :)`, + }, +}) +class OpenTravelTicket implements CommandHandler { + async handle(message: Message): Promise { + const channel = await maybeCreateTicket( + message, + TicketType.TRAVEL, + `Hi ${message.member?.toString()}, let's collect all important information for your trip!`, + false + ); + + if (!channel) return; + + await promptAndSendForApproval(channel, message.author.id); + } +} diff --git a/src/programs/tickets/travel/retry.ts b/src/programs/tickets/travel/legacy/retry.ts similarity index 88% rename from src/programs/tickets/travel/retry.ts rename to src/programs/tickets/travel/legacy/retry.ts index 184492aa4..8da86fc38 100644 --- a/src/programs/tickets/travel/retry.ts +++ b/src/programs/tickets/travel/legacy/retry.ts @@ -3,10 +3,10 @@ import { CommandHandler, DiscordEvent, EventLocation, -} from "../../../event-distribution"; +} from "../../../../event-distribution"; import { Message, TextChannel } from "discord.js"; -import { getChannelName, TicketType } from "../common"; -import Tools from "../../../common/tools"; +import { getChannelName, TicketType } from "../../common"; +import Tools from "../../../../common/tools"; import { promptAndSendForApproval } from "./common"; @Command({ diff --git a/src/programs/tickets/travel/open-travel-ticket.ts b/src/programs/tickets/travel/open-travel-ticket.ts index 3113e4edd..066048772 100644 --- a/src/programs/tickets/travel/open-travel-ticket.ts +++ b/src/programs/tickets/travel/open-travel-ticket.ts @@ -4,31 +4,127 @@ import { DiscordEvent, HandlerRejectedReason, } from "../../../event-distribution"; -import { Message } from "discord.js"; -import { maybeCreateTicket, TicketType } from "../common"; -import { promptAndSendForApproval } from "./common"; +import { + ButtonStyle, + ChatInputCommandInteraction, + ComponentType, + RepliableInteraction, + StringSelectMenuInteraction, +} from "discord.js"; +import { TripDetails } from "./travel-data-message-converter"; +import { TripDetailsAggregator } from "./trip-details-aggregator"; +import { TravelEditing } from "./travel-editing"; + +const enum Errors { + NOT_IN_TWO_MONTHS = "NOT_IN_TWO_MONTHS", + IS_A_MOVE = "IS_A_MOVE", +} @Command({ - event: DiscordEvent.MESSAGE, - trigger: "!travel", - allowedRoles: ["Seek Discomfort"], - description: "This handler is to create a travel ticket.", + event: DiscordEvent.SLASH_COMMAND, + root: "travel", + description: + "Create a posts for a trip you are making to involve the community!", stateful: false, errors: { [HandlerRejectedReason.MISSING_ROLE]: `Before meeting up with people, it's probably best to let others know who you are! This command requires the 'Seek Discomfort' role which you can get by introducing yourself in #introductions!\n\nIf you already posted your introduction, make sure it's longer than just two or three sentences and give the support team some time to check it :)`, + [Errors.NOT_IN_TWO_MONTHS]: + "Unfortunately we only accept trips that occur within the next two months. Feel free to submit your trip once it's a bit closer in time!", + [Errors.IS_A_MOVE]: + "For moving to a new place, please contact a Support member for a role change. The travel command is reserved for short(-ish) trips.", }, }) -class OpenTravelTicket implements CommandHandler { - async handle(message: Message): Promise { - const channel = await maybeCreateTicket( - message, - TicketType.TRAVEL, - `Hi ${message.member?.toString()}, let's collect all important information for your trip!`, - false +class OpenTravelTicket implements CommandHandler { + private detailsAggregator = new TripDetailsAggregator(); + + async handle(interaction: ChatInputCommandInteraction): Promise { + const guild = interaction.guild; + if (!guild) return; + + const roles = interaction.member?.roles ?? []; + const roleNames = Array.isArray(roles) + ? roles + : roles.cache.map((r) => r.name); + if (!roleNames.includes("Seek Discomfort")) { + throw new Error(HandlerRejectedReason.MISSING_ROLE); + } + + await interaction.deferReply({ ephemeral: true }); + + const confirmTwoMonthsInteraction = + await this.confirmTwoMonthRequirement(interaction); + const confirmNotAMoveInteraction = await this.confirmNotAMoveRequirement( + confirmTwoMonthsInteraction ); - if (!channel) return; + const { countries, interaction: countrySelectInteraction } = + await this.detailsAggregator.selectTraveledCountries( + confirmNotAMoveInteraction + ); + const { + places, + dates, + activities, + interaction: tripDetailsInteraction, + } = await this.detailsAggregator.getTripInformation( + countrySelectInteraction + ); + + const { needsHost, interaction: needsHostInteraction } = + await this.detailsAggregator.getNeedsHost(tripDetailsInteraction); + + const userId = interaction.user.id; + const details: TripDetails = { + userId, + countryRoles: countries, + places, + dates, + activities, + needsHost, + }; + + const editing = new TravelEditing(); + const { details: finalDetails, interaction: finalInteraction } = + await editing.offerEditing(details, needsHostInteraction); + + await editing.sendApprovalMessage(finalDetails, interaction.guild); + + await finalInteraction.update({ + content: + "I've sent everything to mods for review. Please have some patience while they are looking at it :)", + components: [], + }); + } + + async confirmTwoMonthRequirement( + interaction: RepliableInteraction + ): Promise { + const userId = interaction.user.id; + const { result, interaction: confirmInteraction } = + await this.detailsAggregator.getBoolean( + interaction, + "First things first: Is your trip starting within the next two months?", + "travel-" + userId + "-confirm-two-months" + ); + + if (!result) throw new Error(Errors.NOT_IN_TWO_MONTHS); + + return confirmInteraction; + } + + async confirmNotAMoveRequirement( + interaction: RepliableInteraction + ): Promise { + const userId = interaction.user.id; + const { result, interaction: confirmInteraction } = + await this.detailsAggregator.getBoolean( + interaction, + "Is this a short(-ish) duration trip and *not* you moving (Erasmus counts as moving)?", + "travel-" + userId + "-confirm-not-a-move" + ); + + if (!result) throw new Error(Errors.IS_A_MOVE); - await promptAndSendForApproval(channel, message.author.id); + return confirmInteraction; } } diff --git a/src/programs/tickets/travel/travel-data-message-converter.ts b/src/programs/tickets/travel/travel-data-message-converter.ts new file mode 100644 index 000000000..05aa51cf0 --- /dev/null +++ b/src/programs/tickets/travel/travel-data-message-converter.ts @@ -0,0 +1,61 @@ +import { APIRole, Guild, Message, Role, Snowflake } from "discord.js"; + +export type TripDetails = { + userId: Snowflake; + + countryRoles: (Role | APIRole)[]; + places: string; + dates: string; + activities: string; + needsHost: boolean; +}; + +export class TravelDataMessageConverter { + static toMessage(details: TripDetails, roleMentions = false): string { + return `Hey ${details.countryRoles + .map((r) => (roleMentions ? r.toString() : r.name)) + .join(", ")}! + +**Who's traveling**: <@${details.userId}> +**Where**: ${details.places} +**When**: ${details.dates} +**Looking for a host**: ${details.needsHost ? "Yes" : "No"} +**Activities**: ${details.activities}`; + } + + static fromMessage(message: Message, guild: Guild): TripDetails { + const content = message.content; + + const countryNameMatch = /Hey (.*)!$/gm.exec(content); + const countryNames = countryNameMatch?.at(1)?.split(/\s*,\s*/) ?? []; + const countryRoles = countryNames + .map((name) => guild.roles.cache.find((r) => r.name === name)) + .filter((x): x is Role => !!x); + + const userId = message.mentions.users.first()?.id ?? message.author.id; + + const whereMatch = /\*\*Where\*\*: ((?:.|\n)*?)\*\*When\*\*/gm.exec( + content + ); + const where = whereMatch?.at(1)?.trim() ?? ""; + + const whenMatch = + /\*\*When\*\*: ((?:.|\n)*?)\*\*Looking for a host\*\*/gm.exec(content); + const when = whenMatch?.at(1)?.trim() ?? ""; + + const hostMatch = /\*\*Looking for a host\*\*: (Yes|No)/gm.exec(content); + const needsHost = hostMatch?.at(1) === "Yes"; + + const activitiesMatch = /\*\*Activities\*\*:((?:.|\n).*)/gm.exec(content); + const activities = activitiesMatch?.at(1) ?? ""; + + return { + userId, + countryRoles, + places: where, + dates: when, + activities, + needsHost, + }; + } +} diff --git a/src/programs/tickets/travel/travel-editing.ts b/src/programs/tickets/travel/travel-editing.ts new file mode 100644 index 000000000..445585896 --- /dev/null +++ b/src/programs/tickets/travel/travel-editing.ts @@ -0,0 +1,217 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + Guild, + MessageComponentInteraction, + ModalMessageModalSubmitInteraction, + SelectMenuComponentOptionData, + StringSelectMenuBuilder, + StringSelectMenuInteraction, + TextChannel, +} from "discord.js"; +import { + TravelDataMessageConverter, + TripDetails, +} from "./travel-data-message-converter"; +import { + ModalRepliableInteraction, + TripDetailsAggregator, +} from "./trip-details-aggregator"; +import { ChatNames } from "../../../collections/chat-names"; + +type EditResult = { + details: TripDetails; + interaction: MessageComponentInteraction | ModalMessageModalSubmitInteraction; +}; + +const enum EditablePropertyGroupNames { + COUNTRIES = "COUNTRIES", + DETAILS = "DETAILS", + NEEDS_HOST = "NEEDS_HOST", +} + +export class TravelEditing { + private detailsAggregator = new TripDetailsAggregator(); + + async offerEditing( + details: TripDetails, + interaction: + | MessageComponentInteraction + | ModalMessageModalSubmitInteraction + ): Promise { + const userId = interaction.user.id; + + const detailsMessage = TravelDataMessageConverter.toMessage(details); + + const editId = "travel-" + userId + "-result-edit"; + const doneId = "travel-" + userId + "-result-done"; + const choiceButtons = new ActionRowBuilder({ + components: [ + new ButtonBuilder({ + customId: doneId, + style: ButtonStyle.Success, + label: "Yes!", + }), + new ButtonBuilder({ + customId: editId, + style: ButtonStyle.Danger, + label: "No, let me edit", + }), + ], + }); + + const message = await interaction.update({ + content: `This is what I would send to mods to review:\n${detailsMessage}`, + components: [choiceButtons], + }); + + const buttonInteraction = await message.awaitMessageComponent({ + componentType: ComponentType.Button, + }); + + switch (buttonInteraction.customId) { + case editId: + return await this.doEditing(details, buttonInteraction); + case doneId: + return { details, interaction: buttonInteraction }; + default: + throw new Error("Invalid selection!"); + } + } + + async doEditing( + details: TripDetails, + interaction: MessageComponentInteraction, + countriesEnabled = true + ) { + const options: SelectMenuComponentOptionData[] = [ + { + label: "Details", + description: + "Edit anything in the details (i.e. specific places, dates and activities).", + value: EditablePropertyGroupNames.DETAILS, + }, + { + label: "Need host", + description: "Change whether you need a host or not", + value: EditablePropertyGroupNames.NEEDS_HOST, + }, + ]; + + if (countriesEnabled) { + options.unshift({ + label: "Countries", + description: "Re-select the countries included in your post.", + value: EditablePropertyGroupNames.COUNTRIES, + }); + } + const editedPropertySelection = + new ActionRowBuilder({ + components: [ + new StringSelectMenuBuilder({ + customId: "editable-property-selection", + options, + }), + ], + }); + + const reply = await interaction.update({ + components: [editedPropertySelection], + }); + + const selectionInteraction = await reply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + }); + + const { details: newDetails, interaction: newDetailsInteraction } = + await this.getNewDetails(details, selectionInteraction); + + return await this.offerEditing(newDetails, newDetailsInteraction); + } + + private async getNewDetails( + details: TripDetails, + interaction: StringSelectMenuInteraction + ): Promise { + const selection = interaction.values[0]; + + switch (selection) { + case EditablePropertyGroupNames.COUNTRIES: + return await this.editCountries(details, interaction); + case EditablePropertyGroupNames.DETAILS: + return await this.editInformation(details, interaction); + case EditablePropertyGroupNames.NEEDS_HOST: + return await this.editNeedsHost(details, interaction); + default: + throw new Error("Invalid selection!"); + } + } + + async editCountries( + details: TripDetails, + interaction: MessageComponentInteraction + ): Promise { + const { countries, interaction: countryInteraction } = + await this.detailsAggregator.selectTraveledCountries(interaction); + + return { + details: { ...details, countryRoles: countries }, + interaction: countryInteraction, + }; + } + + async editInformation( + details: TripDetails, + interaction: ModalRepliableInteraction + ): Promise { + const { interaction: informationInteraction, ...information } = + await this.detailsAggregator.getTripInformation(interaction, details); + + return { + details: { ...details, ...information }, + interaction: informationInteraction, + }; + } + + async editNeedsHost( + details: TripDetails, + interaction: MessageComponentInteraction + ): Promise { + const { needsHost, interaction: needsHostInteration } = + await this.detailsAggregator.getNeedsHost(interaction); + + return { + details: { ...details, needsHost }, + interaction: needsHostInteration, + }; + } + + async sendApprovalMessage(details: TripDetails, guild: Guild) { + const approvalChannel = guild.channels.cache.find( + (c): c is TextChannel => c.name === ChatNames.TRAVEL_APPROVALS + ); + + const approvalButtons = new ActionRowBuilder({ + components: [ + new ButtonBuilder({ + customId: "travel-approve", + style: ButtonStyle.Success, + label: "Approve", + }), + new ButtonBuilder({ + customId: "travel-decline", + style: ButtonStyle.Danger, + label: "Decline", + }), + ], + }); + + const message = TravelDataMessageConverter.toMessage(details); + await approvalChannel?.send({ + content: message, + components: [approvalButtons], + }); + } +} diff --git a/src/programs/tickets/travel/trip-details-aggregator.ts b/src/programs/tickets/travel/trip-details-aggregator.ts new file mode 100644 index 000000000..f4e09beb7 --- /dev/null +++ b/src/programs/tickets/travel/trip-details-aggregator.ts @@ -0,0 +1,216 @@ +import { + ActionRowBuilder, + APIRole, + ComponentType, + MessageComponentInteraction, + ModalActionRowComponentBuilder, + ModalMessageModalSubmitInteraction, + RepliableInteraction, + Role, + RoleSelectMenuBuilder, + RoleSelectMenuInteraction, + StringSelectMenuBuilder, + StringSelectMenuInteraction, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { CountryRoleFinder } from "../../../common/country-role-finder"; +import { TripDetails } from "./travel-data-message-converter"; + +export type ModalRepliableInteraction = Extract< + RepliableInteraction, + { showModal: any } +>; + +type TripInformation = { + places: string; + dates: string; + activities: string; +}; + +export class TripDetailsAggregator { + async getBoolean( + interaction: MessageComponentInteraction | RepliableInteraction, + prompt: string, + customId: string + ): Promise<{ result: boolean; interaction: StringSelectMenuInteraction }> { + const updateFunction = ( + "update" in interaction ? interaction.update : interaction.editReply + ).bind(interaction); + + const message = await updateFunction({ + content: prompt, + components: [ + new ActionRowBuilder({ + components: [ + new StringSelectMenuBuilder({ + customId, + placeholder: "Yes / No", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ], + }), + ], + }), + ], + }); + + const selection = await message.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + }); + + return { result: selection.values[0] === "true", interaction: selection }; + } + + async selectTraveledCountries( + interaction: MessageComponentInteraction, + retryCount = 0 + ): Promise<{ + countries: (Role | APIRole)[]; + interaction: RoleSelectMenuInteraction; + }> { + const errorMessages = [ + "Select between one and three country roles you want to ping for your trip!", + "Not quite yet! Pick one to three *country* roles, all the others won't get you there.", + "Nope. Still not. Pick *countries* like Germany, Austria or Guam!", + ]; + const errorIndex = (retryCount - 1) % errorMessages.length; + const error = errorMessages[errorIndex]; + + const messageContent = `${ + retryCount ? `**${error}**\n\n` : "" + }Select the countries you want to ping (everything but country roles will be ignored). If you are headed to the US, please select one or more specific regions (see the map below for guidance). For a larger trip, split up countries in batches of 3 and make a ticket for them separately :)\n\nhttps://cdn.discordapp.com/attachments/603399775173476403/613072439500341291/unknown.png`; + + const userId = interaction.user.id; + const message = await interaction.update({ + content: messageContent, + components: [ + new ActionRowBuilder({ + components: [ + new RoleSelectMenuBuilder({ + customId: "travel-" + userId + "-country-select", + maxValues: 3, + placeholder: "Countries", + }), + ], + }), + ], + }); + + const selection = await message.awaitMessageComponent({ + componentType: ComponentType.RoleSelect, + }); + const selectedRoles = [...selection.roles.values()]; + const countryRoles = selectedRoles.filter( + (r) => + CountryRoleFinder.isCountryRole(r.name, true) && + // We disallow the general USA role; we only want the regional ones + !r.name.startsWith("United States of America") + ); + + if (countryRoles.length) { + return { countries: countryRoles, interaction: selection }; + } + + return await this.selectTraveledCountries(selection, retryCount + 1); + } + + async getTripInformation( + interaction: ModalRepliableInteraction, + defaultValues?: TripDetails + ): Promise< + TripInformation & { interaction: ModalMessageModalSubmitInteraction } + > { + const userId = interaction.user.id; + const modalId = "travel-" + userId + "-trip-details"; + + await interaction.showModal({ + title: "Trip Details", + customId: modalId, + components: [ + new ActionRowBuilder({ + components: [ + new TextInputBuilder({ + customId: "places", + label: "What places are you traveling to", + required: true, + style: TextInputStyle.Short, + value: defaultValues?.places, + }), + ], + }), + + new ActionRowBuilder({ + components: [ + new TextInputBuilder({ + customId: "dates", + label: "When are you visiting?", + required: true, + placeholder: + "Be sure to include a timespan for each place traveled to! Ideally as DD.MM.YYYY.", + style: TextInputStyle.Paragraph, + value: defaultValues?.dates, + maxLength: 250, + }), + ], + }), + + new ActionRowBuilder({ + components: [ + new TextInputBuilder({ + customId: "activities", + label: "What are you planning on doing there?", + placeholder: + "Be sure to include how the community comes into this!", + required: true, + style: TextInputStyle.Paragraph, + value: defaultValues?.activities, + maxLength: 1300, + }), + ], + }), + ], + }); + + const tenMinutes = 10 * 60 * 1000; + const submission = await interaction.awaitModalSubmit({ + filter: (i) => i.customId === modalId, + time: tenMinutes, + }); + + const fieldNames: (keyof TripInformation)[] = [ + "places", + "dates", + "activities", + ]; + + const tripDetails = Object.fromEntries( + fieldNames.map((field) => [ + field, + submission.fields.getTextInputValue(field), + ]) + ) as TripInformation; + + if (!submission.isFromMessage()) + throw new Error("I don't understand this!"); + + return { ...tripDetails, interaction: submission }; + } + + async getNeedsHost( + interaction: + | ModalMessageModalSubmitInteraction + | MessageComponentInteraction + ): Promise<{ needsHost: boolean; interaction: StringSelectMenuInteraction }> { + const userId = interaction.user.id; + + const { result, interaction: confirmInteraction } = await this.getBoolean( + interaction, + "Do you need a host?", + "travel-" + userId + "-needs-host" + ); + + return { needsHost: result, interaction: confirmInteraction }; + } +}