From 97202d91fe447bf9b04ff12e36cfa95091e0e7a1 Mon Sep 17 00:00:00 2001 From: Chew Date: Wed, 13 May 2026 01:05:30 -0500 Subject: [PATCH 1/4] initial gauntlet command --- package.json | 2 +- src/slash-commands/gauntlet.command.ts | 108 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/slash-commands/gauntlet.command.ts diff --git a/package.json b/package.json index 71dacde..2e1a0d7 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dependencies": { "@libsql/client": "^0.17.0", "@retroachievements/api": "^2.6.0", - "discord.js": "^14.21.0", + "discord.js": "^14.26.4", "drizzle-orm": "0.44.3", "figlet": "^1.8.2", "youtube-search": "^1.1.6" diff --git a/src/slash-commands/gauntlet.command.ts b/src/slash-commands/gauntlet.command.ts new file mode 100644 index 0000000..c0c4f34 --- /dev/null +++ b/src/slash-commands/gauntlet.command.ts @@ -0,0 +1,108 @@ +import {MessageFlags, ModalBuilder, RadioGroupOptionBuilder, SlashCommandBuilder, TextInputStyle} from "discord.js"; + +import type {SlashCommand} from "../models"; + +const gauntletSlashCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName("gauntlet") + .setDescription("Starts up an icon-gauntlet.") + .addStringOption((option) => option + .setName("url") + .setDescription("URL of the game/hub you are updating") + .setRequired(true) + ) + .addStringOption((option) => option + .setName("type") + .setDescription("The type of gauntlet you want to run") + .setRequired(true) + .addChoices( + { name: 'Icon for Hub/Game', value: 'icon' }, + { name: 'Collage for multiple Badges', value: 'collage' } + ) + ), + + cooldown: 60, // 1 minute cooldown. + + async execute(interaction, _client) { + // options + const url = interaction.options.getString("url", true); + const type: 'icon' | 'collage' = interaction.options.getString("type", true) as 'icon' | 'collage'; + + const regex = /^https:\/\/retroachievements\.org\/(?:hub|game)\/\d+\/?$/; + + if (!regex.test(url)) { + await interaction.reply({ content: "Please specify a valid URL for the game/hub you want to change!", flags: MessageFlags.Ephemeral }) + return; + } + + const actualPollUrl = "gauntlet:" + url; + + // Build our modal + const modal = new ModalBuilder() + .setCustomId(actualPollUrl) + .setTitle("Icon Gauntlet Submission") + .addLabelComponents( + (originalLabel) => originalLabel + .setLabel(type == 'icon' ? "Original Icon" : 'Current Icons') + .setDescription(type == 'icon' ? "Leave blank to use current icon" : 'Upload a preview of what the icons currently look like') + .setFileUploadComponent((file) => file + .setCustomId("icon:original") + .setMaxValues(1) + .setRequired(false) + .setRequired(type == 'collage') + ), + + (contendersLabel) => contendersLabel + .setLabel("Contenders") + .setDescription("Upload each contender as its own image below") + .setFileUploadComponent((file) => file + .setCustomId("icon:contenders") + .setMaxValues(10) + .setRequired(true) + ), + + (contactDevLabel) => contactDevLabel + .setLabel("Have you contacted the dev regarding this?") + .setDescription("This is required unless you have one of the exemptions. It will be added to your message for you.") + .setRadioGroupComponent((radioGroup) => radioGroup + .setCustomId("radiogroup-id") + .addOptions( + new RadioGroupOptionBuilder() + .setLabel("Yes, they have been notified") + .setDescription("Good job!") + .setValue("notified"), + new RadioGroupOptionBuilder() + .setLabel("No, they have opted-out") + .setDescription("Check the sheet to be sure!") + .setValue("optout"), + new RadioGroupOptionBuilder() + .setLabel("No, they are inactive") + .setDescription("No need to contact then") + .setValue("inactive"), + new RadioGroupOptionBuilder() + .setLabel("No, this is a hub") + .setDescription("Who would you contact anyway?") + .setValue("hub") + ) + ), + + (additionalInfoLabel) => additionalInfoLabel + .setLabel("Additional Info") + .setDescription("Anything else you want to add? You can also leave a message below this poll for more control.") + .setTextInputComponent((textInput) => textInput + .setCustomId("user:thoughts") + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setPlaceholder("Your thoughts go here") + ) + ); + + try { + await interaction.showModal(modal); + } catch (error) { + console.error(error); + } + }, +}; + +export default gauntletSlashCommand; From 4eb0442424e332c990b14e29f6515e8495cc9431 Mon Sep 17 00:00:00 2001 From: Chew Date: Fri, 15 May 2026 23:14:13 -0500 Subject: [PATCH 2/4] actually send the first poll response (Icons) --- src/index.ts | 16 ++- src/slash-commands/gauntlet.command.ts | 192 +++++++++++++++++++++++-- 2 files changed, 198 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index b89c6b3..d8e943b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Client, Collection, Events, GatewayIntentBits, MessageFlags } from "discord.js"; +import {Client, Collection, Events, GatewayIntentBits, MessageFlags, ModalSubmitInteraction} from "discord.js"; import figlet from "figlet"; import { loadCommands } from "./commands"; @@ -10,6 +10,7 @@ import { AdminChecker } from "./utils/admin-checker"; import { CommandAnalytics } from "./utils/command-analytics"; import { CooldownManager } from "./utils/cooldown-manager"; import { logError, logger } from "./utils/logger"; +import {GauntletCommand} from "./slash-commands/gauntlet.command.ts"; /** * Validates that all required environment variables are set. @@ -180,6 +181,19 @@ client.on(Events.InteractionCreate, async (interaction) => { return; } + // Handle modals + if (interaction.isModalSubmit()) { + const modalInteraction = interaction as ModalSubmitInteraction // isModalSubmit() confirms it's this class + + if (modalInteraction.customId.startsWith("gauntlet")) { + await new GauntletCommand().handleModalSubmit(modalInteraction) + } else { + logger.error(`No modal matching ${modalInteraction.customId} was found.`) + } + } else { + logger.warn('Not a modal?') + } + if (!interaction.isChatInputCommand()) return; const command = client.slashCommands.get(interaction.commandName); diff --git a/src/slash-commands/gauntlet.command.ts b/src/slash-commands/gauntlet.command.ts index c0c4f34..4939758 100644 --- a/src/slash-commands/gauntlet.command.ts +++ b/src/slash-commands/gauntlet.command.ts @@ -1,6 +1,21 @@ -import {MessageFlags, ModalBuilder, RadioGroupOptionBuilder, SlashCommandBuilder, TextInputStyle} from "discord.js"; +import { + ContainerBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + MessageFlags, + ModalBuilder, + ModalSubmitInteraction, + RadioGroupOptionBuilder, + SectionBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + SlashCommandBuilder, + TextDisplayBuilder, + TextInputStyle +} from "discord.js"; import type {SlashCommand} from "../models"; +import {GameInfoService} from "../services/game-info.service.ts"; const gauntletSlashCommand: SlashCommand = { data: new SlashCommandBuilder() @@ -16,8 +31,8 @@ const gauntletSlashCommand: SlashCommand = { .setDescription("The type of gauntlet you want to run") .setRequired(true) .addChoices( - { name: 'Icon for Hub/Game', value: 'icon' }, - { name: 'Collage for multiple Badges', value: 'collage' } + {name: 'Icon for Hub/Game', value: 'icon'}, + {name: 'Collage for multiple Badges', value: 'collage'} ) ), @@ -31,16 +46,40 @@ const gauntletSlashCommand: SlashCommand = { const regex = /^https:\/\/retroachievements\.org\/(?:hub|game)\/\d+\/?$/; if (!regex.test(url)) { - await interaction.reply({ content: "Please specify a valid URL for the game/hub you want to change!", flags: MessageFlags.Ephemeral }) + await interaction.reply({ + content: "Please specify a valid URL for the game/hub you want to change!", + flags: MessageFlags.Ephemeral + }) return; } - const actualPollUrl = "gauntlet:" + url; + // ["https:", "", "retroachievements.org", "hub/game", "id"] + const urlSplit = url.split('/') + const hubOrGame = urlSplit[3] + const itemId = urlSplit[4] + + // Stores all our info to pass to the final component + const actualPollId = "gauntlet:" + hubOrGame + ":" + itemId + ":" + type; // Build our modal const modal = new ModalBuilder() - .setCustomId(actualPollUrl) + .setCustomId(actualPollId) .setTitle("Icon Gauntlet Submission") + + .addTextDisplayComponents(text => text + .setContent("Use this form to add all the information about your icons.\n" + + (type == 'icon' + ? ("Since you are just updating an icon, you don't need to provide the original info, it will be fetched automatically. " + + "Upload your potential files in the second box. You can upload up to 10 contenders at once!") + : ("Since you are using a collage, upload both the original icons in the first box and all of the contenders in the second box. ")) + + "\nThen, select if you contacted the dev, or if you did not, select why not. " + + "Note that you must contact the dev unless one of the listed exemptions apply. " + + "Regardless of what you enter, it will be added to the final message, so you don't need to mention it in your final message. " + + "\nFinally, if you do have anything else to say, use the final Additional Info box. " + + "Put information like your reasoning, what you changed, etc. " + + "You don't need to mention the @icon-gauntlet, it will do that when you submit this form." + )) + .addLabelComponents( (originalLabel) => originalLabel .setLabel(type == 'icon' ? "Original Icon" : 'Current Icons') @@ -48,7 +87,6 @@ const gauntletSlashCommand: SlashCommand = { .setFileUploadComponent((file) => file .setCustomId("icon:original") .setMaxValues(1) - .setRequired(false) .setRequired(type == 'collage') ), @@ -65,7 +103,7 @@ const gauntletSlashCommand: SlashCommand = { .setLabel("Have you contacted the dev regarding this?") .setDescription("This is required unless you have one of the exemptions. It will be added to your message for you.") .setRadioGroupComponent((radioGroup) => radioGroup - .setCustomId("radiogroup-id") + .setCustomId("notify:group") .addOptions( new RadioGroupOptionBuilder() .setLabel("Yes, they have been notified") @@ -93,7 +131,7 @@ const gauntletSlashCommand: SlashCommand = { .setCustomId("user:thoughts") .setStyle(TextInputStyle.Paragraph) .setRequired(false) - .setPlaceholder("Your thoughts go here") + .setPlaceholder("Your thoughts go here.") ) ); @@ -105,4 +143,140 @@ const gauntletSlashCommand: SlashCommand = { }, }; +export class GauntletCommand { + async handleModalSubmit(event: ModalSubmitInteraction) { + const customId = event.customId.split(":"); + const type = customId[3]! as 'icon' | 'collage'; + + if (type == 'icon') { + await this.buildIconGauntletComponent(event); + } else if (type == 'collage') { + await this.buildCollageGauntletComponent(event); + } else { + await event.reply({ + content: "Invalid gauntlet type specified!", + flags: MessageFlags.Ephemeral + }) + } + } + + async buildIconGauntletComponent(event: ModalSubmitInteraction) { + const author = event.user; + const customId = event.customId.split(":"); // this stores the type, hub/game, and its ID + const hubOrGame = customId[1]!; + const itemId = customId[2]!; + const type = customId[3]! as 'icon' | 'collage'; + + // might be null if not provided, will be something for collages. we need to fetch the icon (if null) and name ourselves + const originalIcon = event.fields.getUploadedFiles("icon:original"); + const contenders = event.fields.getUploadedFiles("icon:contenders", true); + + const info = await GameInfoService.fetchGameInfo(Number(itemId)); + + if (info == null) { + await event.reply({ + content: "Unable to find game. Did you put the right link?", + flags: MessageFlags.Ephemeral + }) + return; + } + + const originalIconUrl = originalIcon == null || originalIcon.size == 0 ? ("https://media.retroachievements.org" + info.imageIcon) : originalIcon.at(0)!.url + + const notes = event.fields.getTextInputValue("user:thoughts") + const devNotice = this.parseDevNotice(event.fields.getRadioGroup('notify:group', true)) + + const contenderComponents: SectionBuilder[] = []; + for (let i = 0; i < contenders.size; i++) { + const contender = contenders.at(i)!; + + contenderComponents.push(new SectionBuilder() + .setThumbnailAccessory(contenderThumbnail => contenderThumbnail.setURL(contender.url)) + .addTextDisplayComponents(contenderText => contenderText.setContent("# 1️⃣ Contender " + (i+1))) + ) + } + + const components = [ + new TextDisplayBuilder().setContent(`Attention @icon-gauntlet! A new gauntlet has been started by <@!${author.id}>.`), + new ContainerBuilder() + .addTextDisplayComponents( + componentLink => componentLink.setContent(`Game/Hub: [${info.title}](https://retroachievements.org/game/${info.id})`), + componentType => componentType.setContent("Changing: Mastery/Hub Icon") + ) + .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) + .addTextDisplayComponents( + authorNotes => authorNotes.setContent("Author left additional notes: " + notes), + devNotify => devNotify.setContent(`Author has stated ${devNotice}.`), + ) + .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) + .addSectionComponents( + new SectionBuilder() + .setThumbnailAccessory(thumbnail => thumbnail.setURL(originalIconUrl)) + .addTextDisplayComponents(iconText => iconText.setContent("# 🅾️ Original Icon")), + ) + .addSectionComponents(contenderComponents) + ]; + + await event.reply({components, flags: MessageFlags.IsComponentsV2}) + } + + async buildCollageGauntletComponent(event: ModalSubmitInteraction) { + const components = [ + new TextDisplayBuilder().setContent("Attention @icon-gauntlet! A new gauntlet has been started by @user"), + new ContainerBuilder() + .setAccentColor(9225410) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("Game/Hub: https://retroachievements.org/game/12345\n\nChanging: Achievement Icons"), + ) + .addSeparatorComponents( + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small).setDivider(true), + ) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("Author left additional notes: Hey guys, hope y'all enjoy!\n\nAuthor has stated the developer has been notified."), + ) + .addSeparatorComponents( + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small).setDivider(true), + ) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# 🅾️ Original Icons"), + ) + .addMediaGalleryComponents( + new MediaGalleryBuilder() + .addItems( + new MediaGalleryItemBuilder() + .setURL(""), + ), + ) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# 1️⃣ Contender 1"), + ) + .addMediaGalleryComponents( + new MediaGalleryBuilder() + .addItems( + new MediaGalleryItemBuilder() + .setURL(""), + ), + ), + ]; + + await event.reply({components, flags: MessageFlags.IsComponentsV2}) + } + + private parseDevNotice(response: string): string { + switch (response) { + case 'notified': + return "the developer has been notified." + case 'optout': + return "the developer has opted-out of gauntlet notices." + case 'inactive': + return "the developer is inactive." + case 'hub': + return ''; // hubs don't need to be contacted + + default: + return "unknown developer notice response, report this error." + } + } +} + export default gauntletSlashCommand; From 016bb50f2c5e004b22bb41ab386a459b7e0b1180 Mon Sep 17 00:00:00 2001 From: Chew Date: Sun, 17 May 2026 01:32:57 -0500 Subject: [PATCH 3/4] support collage polls and fetch game on initial command --- src/slash-commands/gauntlet.command.ts | 179 ++++++++++++++++++------- 1 file changed, 129 insertions(+), 50 deletions(-) diff --git a/src/slash-commands/gauntlet.command.ts b/src/slash-commands/gauntlet.command.ts index 4939758..2059c39 100644 --- a/src/slash-commands/gauntlet.command.ts +++ b/src/slash-commands/gauntlet.command.ts @@ -1,13 +1,11 @@ import { + CheckboxGroupOptionBuilder, ContainerBuilder, - MediaGalleryBuilder, - MediaGalleryItemBuilder, MessageFlags, ModalBuilder, ModalSubmitInteraction, RadioGroupOptionBuilder, SectionBuilder, - SeparatorBuilder, SeparatorSpacingSize, SlashCommandBuilder, TextDisplayBuilder, @@ -36,7 +34,7 @@ const gauntletSlashCommand: SlashCommand = { ) ), - cooldown: 60, // 1 minute cooldown. + cooldown: 60, // 1 minute cooldown, not too long for easy re-try, not too short to prevent spam async execute(interaction, _client) { // options @@ -58,6 +56,23 @@ const gauntletSlashCommand: SlashCommand = { const hubOrGame = urlSplit[3] const itemId = urlSplit[4] + const info = await GameInfoService.fetchGameInfo(Number(itemId)); + + if (info == null) { + await interaction.reply({ + content: "Unable to find the provided game. Did you enter the correct link?", + flags: MessageFlags.Ephemeral + }) + return; + } + + // Check if there are multiple developers. This slightly tweaks the input to handle this + const authors = Object.values(info.achievements).map((value) => { + return value.author + }); + const uniqAuthors = [...new Set(authors)]; + const multipleAuthors = uniqAuthors.length > 1; + // Stores all our info to pass to the final component const actualPollId = "gauntlet:" + hubOrGame + ":" + itemId + ":" + type; @@ -67,17 +82,25 @@ const gauntletSlashCommand: SlashCommand = { .setTitle("Icon Gauntlet Submission") .addTextDisplayComponents(text => text - .setContent("Use this form to add all the information about your icons.\n" + + .setContent("Use this form to add all the information about your icons.\n\n" + + (type == 'icon' ? ("Since you are just updating an icon, you don't need to provide the original info, it will be fetched automatically. " + "Upload your potential files in the second box. You can upload up to 10 contenders at once!") : ("Since you are using a collage, upload both the original icons in the first box and all of the contenders in the second box. ")) + - "\nThen, select if you contacted the dev, or if you did not, select why not. " + - "Note that you must contact the dev unless one of the listed exemptions apply. " + + + "\n\nThen, select if you contacted the set dev, or if you did not, select why not. " + + (multipleAuthors + ? "Note that you must contact every active developer of the set. " + : "Note that you must contact the developer unless one of the listed exemptions apply. ") + "Regardless of what you enter, it will be added to the final message, so you don't need to mention it in your final message. " + - "\nFinally, if you do have anything else to say, use the final Additional Info box. " + + + "\n\nFinally, if you do have anything else to say, use the final Additional Info box. " + "Put information like your reasoning, what you changed, etc. " + - "You don't need to mention the @icon-gauntlet, it will do that when you submit this form." + "You don't need to mention the @icon-gauntlet, it will do that when you submit this form." + + + "\nNote that if you are trying to gauntlet a default icon on an unclaimed set or hub, you don't have to! " + + "You can just submit it to #cleanup-requests." )) .addLabelComponents( @@ -99,16 +122,51 @@ const gauntletSlashCommand: SlashCommand = { .setRequired(true) ), - (contactDevLabel) => contactDevLabel - .setLabel("Have you contacted the dev regarding this?") + (contactDevLabel) => (multipleAuthors ? contactDevLabel + .setLabel("Have you contacted the developers regarding this?") + .setDescription("You must contact every active developer, unless an exemption applies. Select all relevant options.") + .setCheckboxGroupComponent((radioGroup) => radioGroup + .setCustomId("notify:group") + .addOptions( + new CheckboxGroupOptionBuilder() + .setLabel("Yes, they have been notified, and replied") + .setDescription("Good job!") + .setValue("notified"), + new CheckboxGroupOptionBuilder() + .setLabel("Yes, they have been notified, but did not respond in 72 hours") + .setDescription("If no response, well, you tried.") + .setValue("notified-no-reply"), + new CheckboxGroupOptionBuilder() + .setLabel("No, they have opted-out") + .setDescription("Check the sheet to be sure!") + .setValue("optout"), + new CheckboxGroupOptionBuilder() + .setLabel("No, they are inactive") + .setDescription("No need to contact then") + .setValue("inactive"), + new CheckboxGroupOptionBuilder() + .setLabel("No, it is unclaimed") + .setDescription("No one to contact if there's no set") + .setValue("unclaimed"), + new CheckboxGroupOptionBuilder() + .setLabel("No, this is a hub") + .setDescription("Who would you contact anyway?") + .setValue("hub") + ) + ) : contactDevLabel + .setLabel("Have you contacted the developer regarding this?") .setDescription("This is required unless you have one of the exemptions. It will be added to your message for you.") .setRadioGroupComponent((radioGroup) => radioGroup .setCustomId("notify:group") .addOptions( new RadioGroupOptionBuilder() - .setLabel("Yes, they have been notified") + .setLabel("Yes, they have been notified, and replied") .setDescription("Good job!") .setValue("notified"), + new RadioGroupOptionBuilder() + .setLabel("Yes, they have been notified, but did not respond in 72 hours") + .setDescription("If no response, well, you tried.") + .setValue("notified-no-reply"), new RadioGroupOptionBuilder() .setLabel("No, they have opted-out") .setDescription("Check the sheet to be sure!") @@ -117,12 +175,16 @@ const gauntletSlashCommand: SlashCommand = { .setLabel("No, they are inactive") .setDescription("No need to contact then") .setValue("inactive"), + new RadioGroupOptionBuilder() + .setLabel("No, it is unclaimed") + .setDescription("No one to contact if there's no set") + .setValue("unclaimed"), new RadioGroupOptionBuilder() .setLabel("No, this is a hub") .setDescription("Who would you contact anyway?") .setValue("hub") ) - ), + )), (additionalInfoLabel) => additionalInfoLabel .setLabel("Additional Info") @@ -165,7 +227,6 @@ export class GauntletCommand { const customId = event.customId.split(":"); // this stores the type, hub/game, and its ID const hubOrGame = customId[1]!; const itemId = customId[2]!; - const type = customId[3]! as 'icon' | 'collage'; // might be null if not provided, will be something for collages. we need to fetch the icon (if null) and name ourselves const originalIcon = event.fields.getUploadedFiles("icon:original"); @@ -192,7 +253,7 @@ export class GauntletCommand { contenderComponents.push(new SectionBuilder() .setThumbnailAccessory(contenderThumbnail => contenderThumbnail.setURL(contender.url)) - .addTextDisplayComponents(contenderText => contenderText.setContent("# 1️⃣ Contender " + (i+1))) + .addTextDisplayComponents(contenderText => contenderText.setContent("# 1️⃣ Contender " + (i + 1))) ) } @@ -221,42 +282,56 @@ export class GauntletCommand { } async buildCollageGauntletComponent(event: ModalSubmitInteraction) { + const author = event.user; + const customId = event.customId.split(":"); // this stores the type, hub/game, and its ID + const itemId = customId[2]!; + + const originalIcon = event.fields.getUploadedFiles("icon:original", true); + const contenders = event.fields.getUploadedFiles("icon:contenders", true); + + const info = await GameInfoService.fetchGameInfo(Number(itemId)); + + if (info == null) { + await event.reply({ + content: "Unable to find game. Did you put the right link?", + flags: MessageFlags.Ephemeral + }) + return; + } + + const notes = event.fields.getTextInputValue("user:thoughts") + const devNotice = this.parseDevNotice(event.fields.getRadioGroup('notify:group', true)) + + let pollContainer = new ContainerBuilder() + .setAccentColor(9225410) + .addTextDisplayComponents( + componentLink => componentLink.setContent(`Game: [${info.title}](https://retroachievements.org/game/${info.id})`), + componentType => componentType.setContent("Changing: Achievement Icons") + ) + .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) + .addTextDisplayComponents( + authorNotes => authorNotes.setContent("Author left additional notes: " + notes), + devNotify => devNotify.setContent(`Author has stated ${devNotice}.`), + ) + .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) + .addTextDisplayComponents(contender => contender.setContent("# 🅾️ Original Icons")) + .addMediaGalleryComponents(mediaGallery => mediaGallery.addItems( + contenderImage => contenderImage.setURL(originalIcon.at(0)!.url), + )) + + for (let i = 0; i < contenders.size; i++) { + const contender = contenders.at(i)!; + + pollContainer = pollContainer + .addTextDisplayComponents(contender => contender.setContent("# 1️⃣ Contender " + (i+1))) + .addMediaGalleryComponents(mediaGallery => mediaGallery.addItems( + contenderImage => contenderImage.setURL(contender.url), + )) + } + const components = [ - new TextDisplayBuilder().setContent("Attention @icon-gauntlet! A new gauntlet has been started by @user"), - new ContainerBuilder() - .setAccentColor(9225410) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent("Game/Hub: https://retroachievements.org/game/12345\n\nChanging: Achievement Icons"), - ) - .addSeparatorComponents( - new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small).setDivider(true), - ) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent("Author left additional notes: Hey guys, hope y'all enjoy!\n\nAuthor has stated the developer has been notified."), - ) - .addSeparatorComponents( - new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small).setDivider(true), - ) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent("# 🅾️ Original Icons"), - ) - .addMediaGalleryComponents( - new MediaGalleryBuilder() - .addItems( - new MediaGalleryItemBuilder() - .setURL(""), - ), - ) - .addTextDisplayComponents( - new TextDisplayBuilder().setContent("# 1️⃣ Contender 1"), - ) - .addMediaGalleryComponents( - new MediaGalleryBuilder() - .addItems( - new MediaGalleryItemBuilder() - .setURL(""), - ), - ), + new TextDisplayBuilder().setContent(`Attention @icon-gauntlet! A new gauntlet has been started by <@!${author.id}>.`), + pollContainer ]; await event.reply({components, flags: MessageFlags.IsComponentsV2}) @@ -266,10 +341,14 @@ export class GauntletCommand { switch (response) { case 'notified': return "the developer has been notified." + case 'notified-no-reply': + return 'the developer was notified, but did not get a response in 72 hours.' case 'optout': return "the developer has opted-out of gauntlet notices." case 'inactive': return "the developer is inactive." + case 'unclaimed': + return "set is undeveloped and unclaimed." case 'hub': return ''; // hubs don't need to be contacted From dfa1c4da0ff1fa0364e1daf4b1d201abb473305d Mon Sep 17 00:00:00 2001 From: Chew Date: Sun, 17 May 2026 01:33:19 -0500 Subject: [PATCH 4/4] oxfmt --- src/index.ts | 19 +- src/slash-commands/gauntlet.command.ts | 700 +++++++++++++------------ 2 files changed, 385 insertions(+), 334 deletions(-) diff --git a/src/index.ts b/src/index.ts index d8e943b..e5340c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,11 @@ -import {Client, Collection, Events, GatewayIntentBits, MessageFlags, ModalSubmitInteraction} from "discord.js"; +import { + Client, + Collection, + Events, + GatewayIntentBits, + MessageFlags, + ModalSubmitInteraction, +} from "discord.js"; import figlet from "figlet"; import { loadCommands } from "./commands"; @@ -10,7 +17,7 @@ import { AdminChecker } from "./utils/admin-checker"; import { CommandAnalytics } from "./utils/command-analytics"; import { CooldownManager } from "./utils/cooldown-manager"; import { logError, logger } from "./utils/logger"; -import {GauntletCommand} from "./slash-commands/gauntlet.command.ts"; +import { GauntletCommand } from "./slash-commands/gauntlet.command.ts"; /** * Validates that all required environment variables are set. @@ -183,15 +190,15 @@ client.on(Events.InteractionCreate, async (interaction) => { // Handle modals if (interaction.isModalSubmit()) { - const modalInteraction = interaction as ModalSubmitInteraction // isModalSubmit() confirms it's this class + const modalInteraction = interaction as ModalSubmitInteraction; // isModalSubmit() confirms it's this class if (modalInteraction.customId.startsWith("gauntlet")) { - await new GauntletCommand().handleModalSubmit(modalInteraction) + await new GauntletCommand().handleModalSubmit(modalInteraction); } else { - logger.error(`No modal matching ${modalInteraction.customId} was found.`) + logger.error(`No modal matching ${modalInteraction.customId} was found.`); } } else { - logger.warn('Not a modal?') + logger.warn("Not a modal?"); } if (!interaction.isChatInputCommand()) return; diff --git a/src/slash-commands/gauntlet.command.ts b/src/slash-commands/gauntlet.command.ts index 2059c39..75faba4 100644 --- a/src/slash-commands/gauntlet.command.ts +++ b/src/slash-commands/gauntlet.command.ts @@ -1,361 +1,405 @@ import { - CheckboxGroupOptionBuilder, - ContainerBuilder, - MessageFlags, - ModalBuilder, - ModalSubmitInteraction, - RadioGroupOptionBuilder, - SectionBuilder, - SeparatorSpacingSize, - SlashCommandBuilder, - TextDisplayBuilder, - TextInputStyle + CheckboxGroupOptionBuilder, + ContainerBuilder, + MessageFlags, + ModalBuilder, + ModalSubmitInteraction, + RadioGroupOptionBuilder, + SectionBuilder, + SeparatorSpacingSize, + SlashCommandBuilder, + TextDisplayBuilder, + TextInputStyle, } from "discord.js"; -import type {SlashCommand} from "../models"; -import {GameInfoService} from "../services/game-info.service.ts"; +import type { SlashCommand } from "../models"; +import { GameInfoService } from "../services/game-info.service.ts"; const gauntletSlashCommand: SlashCommand = { - data: new SlashCommandBuilder() - .setName("gauntlet") - .setDescription("Starts up an icon-gauntlet.") - .addStringOption((option) => option - .setName("url") - .setDescription("URL of the game/hub you are updating") - .setRequired(true) - ) - .addStringOption((option) => option - .setName("type") - .setDescription("The type of gauntlet you want to run") - .setRequired(true) - .addChoices( - {name: 'Icon for Hub/Game', value: 'icon'}, - {name: 'Collage for multiple Badges', value: 'collage'} - ) + data: new SlashCommandBuilder() + .setName("gauntlet") + .setDescription("Starts up an icon-gauntlet.") + .addStringOption((option) => + option + .setName("url") + .setDescription("URL of the game/hub you are updating") + .setRequired(true), + ) + .addStringOption((option) => + option + .setName("type") + .setDescription("The type of gauntlet you want to run") + .setRequired(true) + .addChoices( + { name: "Icon for Hub/Game", value: "icon" }, + { name: "Collage for multiple Badges", value: "collage" }, ), + ), - cooldown: 60, // 1 minute cooldown, not too long for easy re-try, not too short to prevent spam - - async execute(interaction, _client) { - // options - const url = interaction.options.getString("url", true); - const type: 'icon' | 'collage' = interaction.options.getString("type", true) as 'icon' | 'collage'; - - const regex = /^https:\/\/retroachievements\.org\/(?:hub|game)\/\d+\/?$/; - - if (!regex.test(url)) { - await interaction.reply({ - content: "Please specify a valid URL for the game/hub you want to change!", - flags: MessageFlags.Ephemeral - }) - return; - } - - // ["https:", "", "retroachievements.org", "hub/game", "id"] - const urlSplit = url.split('/') - const hubOrGame = urlSplit[3] - const itemId = urlSplit[4] - - const info = await GameInfoService.fetchGameInfo(Number(itemId)); - - if (info == null) { - await interaction.reply({ - content: "Unable to find the provided game. Did you enter the correct link?", - flags: MessageFlags.Ephemeral - }) - return; - } - - // Check if there are multiple developers. This slightly tweaks the input to handle this - const authors = Object.values(info.achievements).map((value) => { - return value.author - }); - const uniqAuthors = [...new Set(authors)]; - const multipleAuthors = uniqAuthors.length > 1; - - // Stores all our info to pass to the final component - const actualPollId = "gauntlet:" + hubOrGame + ":" + itemId + ":" + type; - - // Build our modal - const modal = new ModalBuilder() - .setCustomId(actualPollId) - .setTitle("Icon Gauntlet Submission") - - .addTextDisplayComponents(text => text - .setContent("Use this form to add all the information about your icons.\n\n" + - - (type == 'icon' - ? ("Since you are just updating an icon, you don't need to provide the original info, it will be fetched automatically. " + - "Upload your potential files in the second box. You can upload up to 10 contenders at once!") - : ("Since you are using a collage, upload both the original icons in the first box and all of the contenders in the second box. ")) + - - "\n\nThen, select if you contacted the set dev, or if you did not, select why not. " + - (multipleAuthors - ? "Note that you must contact every active developer of the set. " - : "Note that you must contact the developer unless one of the listed exemptions apply. ") + - "Regardless of what you enter, it will be added to the final message, so you don't need to mention it in your final message. " + - - "\n\nFinally, if you do have anything else to say, use the final Additional Info box. " + - "Put information like your reasoning, what you changed, etc. " + - "You don't need to mention the @icon-gauntlet, it will do that when you submit this form." + - - "\nNote that if you are trying to gauntlet a default icon on an unclaimed set or hub, you don't have to! " + - "You can just submit it to #cleanup-requests." - )) - - .addLabelComponents( - (originalLabel) => originalLabel - .setLabel(type == 'icon' ? "Original Icon" : 'Current Icons') - .setDescription(type == 'icon' ? "Leave blank to use current icon" : 'Upload a preview of what the icons currently look like') - .setFileUploadComponent((file) => file - .setCustomId("icon:original") - .setMaxValues(1) - .setRequired(type == 'collage') - ), + cooldown: 60, // 1 minute cooldown, not too long for easy re-try, not too short to prevent spam - (contendersLabel) => contendersLabel - .setLabel("Contenders") - .setDescription("Upload each contender as its own image below") - .setFileUploadComponent((file) => file - .setCustomId("icon:contenders") - .setMaxValues(10) - .setRequired(true) - ), + async execute(interaction, _client) { + // options + const url = interaction.options.getString("url", true); + const type: "icon" | "collage" = interaction.options.getString("type", true) as + | "icon" + | "collage"; - (contactDevLabel) => (multipleAuthors ? contactDevLabel - .setLabel("Have you contacted the developers regarding this?") - .setDescription("You must contact every active developer, unless an exemption applies. Select all relevant options.") - .setCheckboxGroupComponent((radioGroup) => radioGroup - .setCustomId("notify:group") - .addOptions( - new CheckboxGroupOptionBuilder() - .setLabel("Yes, they have been notified, and replied") - .setDescription("Good job!") - .setValue("notified"), - new CheckboxGroupOptionBuilder() - .setLabel("Yes, they have been notified, but did not respond in 72 hours") - .setDescription("If no response, well, you tried.") - .setValue("notified-no-reply"), - new CheckboxGroupOptionBuilder() - .setLabel("No, they have opted-out") - .setDescription("Check the sheet to be sure!") - .setValue("optout"), - new CheckboxGroupOptionBuilder() - .setLabel("No, they are inactive") - .setDescription("No need to contact then") - .setValue("inactive"), - new CheckboxGroupOptionBuilder() - .setLabel("No, it is unclaimed") - .setDescription("No one to contact if there's no set") - .setValue("unclaimed"), - new CheckboxGroupOptionBuilder() - .setLabel("No, this is a hub") - .setDescription("Who would you contact anyway?") - .setValue("hub") - ) - ) : contactDevLabel - .setLabel("Have you contacted the developer regarding this?") - .setDescription("This is required unless you have one of the exemptions. It will be added to your message for you.") - .setRadioGroupComponent((radioGroup) => radioGroup - .setCustomId("notify:group") - .addOptions( - new RadioGroupOptionBuilder() - .setLabel("Yes, they have been notified, and replied") - .setDescription("Good job!") - .setValue("notified"), - new RadioGroupOptionBuilder() - .setLabel("Yes, they have been notified, but did not respond in 72 hours") - .setDescription("If no response, well, you tried.") - .setValue("notified-no-reply"), - new RadioGroupOptionBuilder() - .setLabel("No, they have opted-out") - .setDescription("Check the sheet to be sure!") - .setValue("optout"), - new RadioGroupOptionBuilder() - .setLabel("No, they are inactive") - .setDescription("No need to contact then") - .setValue("inactive"), - new RadioGroupOptionBuilder() - .setLabel("No, it is unclaimed") - .setDescription("No one to contact if there's no set") - .setValue("unclaimed"), - new RadioGroupOptionBuilder() - .setLabel("No, this is a hub") - .setDescription("Who would you contact anyway?") - .setValue("hub") - ) - )), - - (additionalInfoLabel) => additionalInfoLabel - .setLabel("Additional Info") - .setDescription("Anything else you want to add? You can also leave a message below this poll for more control.") - .setTextInputComponent((textInput) => textInput - .setCustomId("user:thoughts") - .setStyle(TextInputStyle.Paragraph) - .setRequired(false) - .setPlaceholder("Your thoughts go here.") - ) - ); - - try { - await interaction.showModal(modal); - } catch (error) { - console.error(error); - } - }, -}; + const regex = /^https:\/\/retroachievements\.org\/(?:hub|game)\/\d+\/?$/; -export class GauntletCommand { - async handleModalSubmit(event: ModalSubmitInteraction) { - const customId = event.customId.split(":"); - const type = customId[3]! as 'icon' | 'collage'; - - if (type == 'icon') { - await this.buildIconGauntletComponent(event); - } else if (type == 'collage') { - await this.buildCollageGauntletComponent(event); - } else { - await event.reply({ - content: "Invalid gauntlet type specified!", - flags: MessageFlags.Ephemeral - }) - } + if (!regex.test(url)) { + await interaction.reply({ + content: "Please specify a valid URL for the game/hub you want to change!", + flags: MessageFlags.Ephemeral, + }); + return; } - async buildIconGauntletComponent(event: ModalSubmitInteraction) { - const author = event.user; - const customId = event.customId.split(":"); // this stores the type, hub/game, and its ID - const hubOrGame = customId[1]!; - const itemId = customId[2]!; - - // might be null if not provided, will be something for collages. we need to fetch the icon (if null) and name ourselves - const originalIcon = event.fields.getUploadedFiles("icon:original"); - const contenders = event.fields.getUploadedFiles("icon:contenders", true); - - const info = await GameInfoService.fetchGameInfo(Number(itemId)); + // ["https:", "", "retroachievements.org", "hub/game", "id"] + const urlSplit = url.split("/"); + const hubOrGame = urlSplit[3]; + const itemId = urlSplit[4]; - if (info == null) { - await event.reply({ - content: "Unable to find game. Did you put the right link?", - flags: MessageFlags.Ephemeral - }) - return; - } + const info = await GameInfoService.fetchGameInfo(Number(itemId)); - const originalIconUrl = originalIcon == null || originalIcon.size == 0 ? ("https://media.retroachievements.org" + info.imageIcon) : originalIcon.at(0)!.url - - const notes = event.fields.getTextInputValue("user:thoughts") - const devNotice = this.parseDevNotice(event.fields.getRadioGroup('notify:group', true)) - - const contenderComponents: SectionBuilder[] = []; - for (let i = 0; i < contenders.size; i++) { - const contender = contenders.at(i)!; + if (info == null) { + await interaction.reply({ + content: "Unable to find the provided game. Did you enter the correct link?", + flags: MessageFlags.Ephemeral, + }); + return; + } - contenderComponents.push(new SectionBuilder() - .setThumbnailAccessory(contenderThumbnail => contenderThumbnail.setURL(contender.url)) - .addTextDisplayComponents(contenderText => contenderText.setContent("# 1️⃣ Contender " + (i + 1))) + // Check if there are multiple developers. This slightly tweaks the input to handle this + const authors = Object.values(info.achievements).map((value) => { + return value.author; + }); + const uniqAuthors = [...new Set(authors)]; + const multipleAuthors = uniqAuthors.length > 1; + + // Stores all our info to pass to the final component + const actualPollId = "gauntlet:" + hubOrGame + ":" + itemId + ":" + type; + + // Build our modal + const modal = new ModalBuilder() + .setCustomId(actualPollId) + .setTitle("Icon Gauntlet Submission") + + .addTextDisplayComponents((text) => + text.setContent( + "Use this form to add all the information about your icons.\n\n" + + (type == "icon" + ? "Since you are just updating an icon, you don't need to provide the original info, it will be fetched automatically. " + + "Upload your potential files in the second box. You can upload up to 10 contenders at once!" + : "Since you are using a collage, upload both the original icons in the first box and all of the contenders in the second box. ") + + "\n\nThen, select if you contacted the set dev, or if you did not, select why not. " + + (multipleAuthors + ? "Note that you must contact every active developer of the set. " + : "Note that you must contact the developer unless one of the listed exemptions apply. ") + + "Regardless of what you enter, it will be added to the final message, so you don't need to mention it in your final message. " + + "\n\nFinally, if you do have anything else to say, use the final Additional Info box. " + + "Put information like your reasoning, what you changed, etc. " + + "You don't need to mention the @icon-gauntlet, it will do that when you submit this form." + + "\nNote that if you are trying to gauntlet a default icon on an unclaimed set or hub, you don't have to! " + + "You can just submit it to #cleanup-requests.", + ), + ) + + .addLabelComponents( + (originalLabel) => + originalLabel + .setLabel(type == "icon" ? "Original Icon" : "Current Icons") + .setDescription( + type == "icon" + ? "Leave blank to use current icon" + : "Upload a preview of what the icons currently look like", ) - } - - const components = [ - new TextDisplayBuilder().setContent(`Attention @icon-gauntlet! A new gauntlet has been started by <@!${author.id}>.`), - new ContainerBuilder() - .addTextDisplayComponents( - componentLink => componentLink.setContent(`Game/Hub: [${info.title}](https://retroachievements.org/game/${info.id})`), - componentType => componentType.setContent("Changing: Mastery/Hub Icon") + .setFileUploadComponent((file) => + file + .setCustomId("icon:original") + .setMaxValues(1) + .setRequired(type == "collage"), + ), + + (contendersLabel) => + contendersLabel + .setLabel("Contenders") + .setDescription("Upload each contender as its own image below") + .setFileUploadComponent((file) => + file.setCustomId("icon:contenders").setMaxValues(10).setRequired(true), + ), + + (contactDevLabel) => + multipleAuthors + ? contactDevLabel + .setLabel("Have you contacted the developers regarding this?") + .setDescription( + "You must contact every active developer, unless an exemption applies. Select all relevant options.", ) - .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) - .addTextDisplayComponents( - authorNotes => authorNotes.setContent("Author left additional notes: " + notes), - devNotify => devNotify.setContent(`Author has stated ${devNotice}.`), + .setCheckboxGroupComponent((radioGroup) => + radioGroup + .setCustomId("notify:group") + .addOptions( + new CheckboxGroupOptionBuilder() + .setLabel("Yes, they have been notified, and replied") + .setDescription("Good job!") + .setValue("notified"), + new CheckboxGroupOptionBuilder() + .setLabel("Yes, they have been notified, but did not respond in 72 hours") + .setDescription("If no response, well, you tried.") + .setValue("notified-no-reply"), + new CheckboxGroupOptionBuilder() + .setLabel("No, they have opted-out") + .setDescription("Check the sheet to be sure!") + .setValue("optout"), + new CheckboxGroupOptionBuilder() + .setLabel("No, they are inactive") + .setDescription("No need to contact then") + .setValue("inactive"), + new CheckboxGroupOptionBuilder() + .setLabel("No, it is unclaimed") + .setDescription("No one to contact if there's no set") + .setValue("unclaimed"), + new CheckboxGroupOptionBuilder() + .setLabel("No, this is a hub") + .setDescription("Who would you contact anyway?") + .setValue("hub"), + ), ) - .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) - .addSectionComponents( - new SectionBuilder() - .setThumbnailAccessory(thumbnail => thumbnail.setURL(originalIconUrl)) - .addTextDisplayComponents(iconText => iconText.setContent("# 🅾️ Original Icon")), + : contactDevLabel + .setLabel("Have you contacted the developer regarding this?") + .setDescription( + "This is required unless you have one of the exemptions. It will be added to your message for you.", ) - .addSectionComponents(contenderComponents) - ]; + .setRadioGroupComponent((radioGroup) => + radioGroup + .setCustomId("notify:group") + .addOptions( + new RadioGroupOptionBuilder() + .setLabel("Yes, they have been notified, and replied") + .setDescription("Good job!") + .setValue("notified"), + new RadioGroupOptionBuilder() + .setLabel("Yes, they have been notified, but did not respond in 72 hours") + .setDescription("If no response, well, you tried.") + .setValue("notified-no-reply"), + new RadioGroupOptionBuilder() + .setLabel("No, they have opted-out") + .setDescription("Check the sheet to be sure!") + .setValue("optout"), + new RadioGroupOptionBuilder() + .setLabel("No, they are inactive") + .setDescription("No need to contact then") + .setValue("inactive"), + new RadioGroupOptionBuilder() + .setLabel("No, it is unclaimed") + .setDescription("No one to contact if there's no set") + .setValue("unclaimed"), + new RadioGroupOptionBuilder() + .setLabel("No, this is a hub") + .setDescription("Who would you contact anyway?") + .setValue("hub"), + ), + ), - await event.reply({components, flags: MessageFlags.IsComponentsV2}) + (additionalInfoLabel) => + additionalInfoLabel + .setLabel("Additional Info") + .setDescription( + "Anything else you want to add? You can also leave a message below this poll for more control.", + ) + .setTextInputComponent((textInput) => + textInput + .setCustomId("user:thoughts") + .setStyle(TextInputStyle.Paragraph) + .setRequired(false) + .setPlaceholder("Your thoughts go here."), + ), + ); + + try { + await interaction.showModal(modal); + } catch (error) { + console.error(error); } + }, +}; - async buildCollageGauntletComponent(event: ModalSubmitInteraction) { - const author = event.user; - const customId = event.customId.split(":"); // this stores the type, hub/game, and its ID - const itemId = customId[2]!; +export class GauntletCommand { + async handleModalSubmit(event: ModalSubmitInteraction) { + const customId = event.customId.split(":"); + const type = customId[3]! as "icon" | "collage"; + + if (type == "icon") { + await this.buildIconGauntletComponent(event); + } else if (type == "collage") { + await this.buildCollageGauntletComponent(event); + } else { + await event.reply({ + content: "Invalid gauntlet type specified!", + flags: MessageFlags.Ephemeral, + }); + } + } + + async buildIconGauntletComponent(event: ModalSubmitInteraction) { + const author = event.user; + const customId = event.customId.split(":"); // this stores the type, hub/game, and its ID + const hubOrGame = customId[1]!; + const itemId = customId[2]!; + + // might be null if not provided, will be something for collages. we need to fetch the icon (if null) and name ourselves + const originalIcon = event.fields.getUploadedFiles("icon:original"); + const contenders = event.fields.getUploadedFiles("icon:contenders", true); + + const info = await GameInfoService.fetchGameInfo(Number(itemId)); + + if (info == null) { + await event.reply({ + content: "Unable to find game. Did you put the right link?", + flags: MessageFlags.Ephemeral, + }); + return; + } - const originalIcon = event.fields.getUploadedFiles("icon:original", true); - const contenders = event.fields.getUploadedFiles("icon:contenders", true); + const originalIconUrl = + originalIcon == null || originalIcon.size == 0 + ? "https://media.retroachievements.org" + info.imageIcon + : originalIcon.at(0)!.url; + + const notes = event.fields.getTextInputValue("user:thoughts"); + const devNotice = this.parseDevNotice(event.fields.getRadioGroup("notify:group", true)); + + const contenderComponents: SectionBuilder[] = []; + for (let i = 0; i < contenders.size; i++) { + const contender = contenders.at(i)!; + + contenderComponents.push( + new SectionBuilder() + .setThumbnailAccessory((contenderThumbnail) => contenderThumbnail.setURL(contender.url)) + .addTextDisplayComponents((contenderText) => + contenderText.setContent("# 1️⃣ Contender " + (i + 1)), + ), + ); + } - const info = await GameInfoService.fetchGameInfo(Number(itemId)); + const components = [ + new TextDisplayBuilder().setContent( + `Attention @icon-gauntlet! A new gauntlet has been started by <@!${author.id}>.`, + ), + new ContainerBuilder() + .addTextDisplayComponents( + (componentLink) => + componentLink.setContent( + `Game/Hub: [${info.title}](https://retroachievements.org/game/${info.id})`, + ), + (componentType) => componentType.setContent("Changing: Mastery/Hub Icon"), + ) + .addSeparatorComponents((separator) => + separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true), + ) + .addTextDisplayComponents( + (authorNotes) => authorNotes.setContent("Author left additional notes: " + notes), + (devNotify) => devNotify.setContent(`Author has stated ${devNotice}.`), + ) + .addSeparatorComponents((separator) => + separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true), + ) + .addSectionComponents( + new SectionBuilder() + .setThumbnailAccessory((thumbnail) => thumbnail.setURL(originalIconUrl)) + .addTextDisplayComponents((iconText) => iconText.setContent("# 🅾️ Original Icon")), + ) + .addSectionComponents(contenderComponents), + ]; - if (info == null) { - await event.reply({ - content: "Unable to find game. Did you put the right link?", - flags: MessageFlags.Ephemeral - }) - return; - } + await event.reply({ components, flags: MessageFlags.IsComponentsV2 }); + } - const notes = event.fields.getTextInputValue("user:thoughts") - const devNotice = this.parseDevNotice(event.fields.getRadioGroup('notify:group', true)) + async buildCollageGauntletComponent(event: ModalSubmitInteraction) { + const author = event.user; + const customId = event.customId.split(":"); // this stores the type, hub/game, and its ID + const itemId = customId[2]!; - let pollContainer = new ContainerBuilder() - .setAccentColor(9225410) - .addTextDisplayComponents( - componentLink => componentLink.setContent(`Game: [${info.title}](https://retroachievements.org/game/${info.id})`), - componentType => componentType.setContent("Changing: Achievement Icons") - ) - .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) - .addTextDisplayComponents( - authorNotes => authorNotes.setContent("Author left additional notes: " + notes), - devNotify => devNotify.setContent(`Author has stated ${devNotice}.`), - ) - .addSeparatorComponents(separator => separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true)) - .addTextDisplayComponents(contender => contender.setContent("# 🅾️ Original Icons")) - .addMediaGalleryComponents(mediaGallery => mediaGallery.addItems( - contenderImage => contenderImage.setURL(originalIcon.at(0)!.url), - )) - - for (let i = 0; i < contenders.size; i++) { - const contender = contenders.at(i)!; - - pollContainer = pollContainer - .addTextDisplayComponents(contender => contender.setContent("# 1️⃣ Contender " + (i+1))) - .addMediaGalleryComponents(mediaGallery => mediaGallery.addItems( - contenderImage => contenderImage.setURL(contender.url), - )) - } - - const components = [ - new TextDisplayBuilder().setContent(`Attention @icon-gauntlet! A new gauntlet has been started by <@!${author.id}>.`), - pollContainer - ]; - - await event.reply({components, flags: MessageFlags.IsComponentsV2}) + const originalIcon = event.fields.getUploadedFiles("icon:original", true); + const contenders = event.fields.getUploadedFiles("icon:contenders", true); + + const info = await GameInfoService.fetchGameInfo(Number(itemId)); + + if (info == null) { + await event.reply({ + content: "Unable to find game. Did you put the right link?", + flags: MessageFlags.Ephemeral, + }); + return; + } + + const notes = event.fields.getTextInputValue("user:thoughts"); + const devNotice = this.parseDevNotice(event.fields.getRadioGroup("notify:group", true)); + + let pollContainer = new ContainerBuilder() + .setAccentColor(9225410) + .addTextDisplayComponents( + (componentLink) => + componentLink.setContent( + `Game: [${info.title}](https://retroachievements.org/game/${info.id})`, + ), + (componentType) => componentType.setContent("Changing: Achievement Icons"), + ) + .addSeparatorComponents((separator) => + separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true), + ) + .addTextDisplayComponents( + (authorNotes) => authorNotes.setContent("Author left additional notes: " + notes), + (devNotify) => devNotify.setContent(`Author has stated ${devNotice}.`), + ) + .addSeparatorComponents((separator) => + separator.setSpacing(SeparatorSpacingSize.Small).setDivider(true), + ) + .addTextDisplayComponents((contender) => contender.setContent("# 🅾️ Original Icons")) + .addMediaGalleryComponents((mediaGallery) => + mediaGallery.addItems((contenderImage) => contenderImage.setURL(originalIcon.at(0)!.url)), + ); + + for (let i = 0; i < contenders.size; i++) { + const contender = contenders.at(i)!; + + pollContainer = pollContainer + .addTextDisplayComponents((contender) => contender.setContent("# 1️⃣ Contender " + (i + 1))) + .addMediaGalleryComponents((mediaGallery) => + mediaGallery.addItems((contenderImage) => contenderImage.setURL(contender.url)), + ); } - private parseDevNotice(response: string): string { - switch (response) { - case 'notified': - return "the developer has been notified." - case 'notified-no-reply': - return 'the developer was notified, but did not get a response in 72 hours.' - case 'optout': - return "the developer has opted-out of gauntlet notices." - case 'inactive': - return "the developer is inactive." - case 'unclaimed': - return "set is undeveloped and unclaimed." - case 'hub': - return ''; // hubs don't need to be contacted - - default: - return "unknown developer notice response, report this error." - } + const components = [ + new TextDisplayBuilder().setContent( + `Attention @icon-gauntlet! A new gauntlet has been started by <@!${author.id}>.`, + ), + pollContainer, + ]; + + await event.reply({ components, flags: MessageFlags.IsComponentsV2 }); + } + + private parseDevNotice(response: string): string { + switch (response) { + case "notified": + return "the developer has been notified."; + case "notified-no-reply": + return "the developer was notified, but did not get a response in 72 hours."; + case "optout": + return "the developer has opted-out of gauntlet notices."; + case "inactive": + return "the developer is inactive."; + case "unclaimed": + return "set is undeveloped and unclaimed."; + case "hub": + return ""; // hubs don't need to be contacted + + default: + return "unknown developer notice response, report this error."; } + } } export default gauntletSlashCommand;