diff --git a/server/package.json b/server/package.json index 1a79b938..cf75363b 100644 --- a/server/package.json +++ b/server/package.json @@ -28,7 +28,7 @@ "body-parser": "^1.19.0", "chalk": "^4.1.0", "cors": "^2.8.5", - "discord.js": "^14.11.0", + "discord.js": "^14.12.0-dev.1688904265-df40dcd.0", "dotenv": "^8.2.0", "express": "^4.17.1", "libsodium-wrappers": "^0.7.10", diff --git a/server/src/action-utils/action-manager-models.js b/server/src/action-utils/action-manager-models.js index fa1323e0..232c7820 100644 --- a/server/src/action-utils/action-manager-models.js +++ b/server/src/action-utils/action-manager-models.js @@ -58,7 +58,7 @@ class Action { }) { return this.handler(args, auth) .then(data => { - console.log(`[actions] Success in ${this.key}`, data); + console.log(`[actions] Success in ${this.key}`); success(data); }, e => { diff --git a/server/src/action-utils/action-permissions.js b/server/src/action-utils/action-permissions.js index 41d03591..e765d191 100644 --- a/server/src/action-utils/action-permissions.js +++ b/server/src/action-utils/action-permissions.js @@ -75,6 +75,11 @@ async function isEventStaffOrHasRole(user, event, role, websiteRoles) { return false; } +function canUpdateUserDetails(user) { + // TODO: Better / specific permission? + return (user.airtable?.website_settings ?? []).includes("Full broadcast permissions"); +} + module.exports = { - canEditMatch, isEventStaffOrHasRole + canEditMatch, isEventStaffOrHasRole, canUpdateUserDetails }; diff --git a/server/src/actions/match-discord-slmngg-player.js b/server/src/actions/match-discord-slmngg-player.js new file mode 100644 index 00000000..0f5be1b0 --- /dev/null +++ b/server/src/actions/match-discord-slmngg-player.js @@ -0,0 +1,55 @@ +const { canUpdateUserDetails } = require("../action-utils/action-permissions"); +const { log } = require("../discord/slmngg-log"); +const { User } = require("discord.js"); +const Cache = require("../cache"); + +module.exports = { + key: "match-discord-slmngg-player", + requiredParams: ["discordData"], + auth: ["user"], + /*** + * @param {User} discordData + * @param {UserData} user + * @returns {Promise} + */ + async handler({ discordData }, { user }) { + if (!canUpdateUserDetails(user)) { + throw { + errorMessage: "You don't have permission update user details", + errorCode: 403 + }; + } + + let players = await Promise.all((await Cache.get("Players")).ids.map(id => (Cache.get(id.slice(3))))); + + if (await Cache.auth.getPlayer(discordData.id)) { + throw { + errorMessage: "This user is already linked to a SLMN.GG profile", + errorCode: 400 + }; + } + + const isNewUsername = discordData.discriminator === "0"; + + let searchValues = [discordData.username, discordData.globalName, !isNewUsername && `${discordData.username}#${discordData.discriminator}`] + .filter(Boolean) + .map((value) => value.toLocaleLowerCase()); + + let potentials = players.filter((player) => { + if (player.discord_id) return false; + + const discordSimple = player.discord_tag?.split("#")[0].toLocaleLowerCase(); + + return searchValues.includes(discordSimple) || searchValues.includes(player.discord_tag?.toLocaleLowerCase()) || searchValues.includes(player.name?.toLocaleLowerCase()); + }); + + if (potentials.length === 0) { + throw { + errorMessage: "Unable to find a SLMN.GG user that matches that Discord account.", + errorCode: 404 + }; + } + + return potentials; + } +}; diff --git a/server/src/actions/update-player-discord-id.js b/server/src/actions/update-player-discord-id.js new file mode 100644 index 00000000..e1a72393 --- /dev/null +++ b/server/src/actions/update-player-discord-id.js @@ -0,0 +1,45 @@ +const { canUpdateUserDetails } = require("../action-utils/action-permissions"); +const { log } = require("../discord/slmngg-log"); +const { + User, + userMention +} = require("discord.js"); +const Cache = require("../cache"); +const { cleanID } = require("../action-utils/action-utils"); + +module.exports = { + key: "update-player-discord-id", + requiredParams: ["discordData", "slmnggId"], + auth: ["user"], + /*** + * @param {User} discordData + * @param {string} slmnggId + * @param {UserData} user + * @returns {Promise} + */ + async handler({ discordData, slmnggId }, { user }) { + if (!canUpdateUserDetails(user)) { + throw { + errorMessage: "You don't have permission update user details", + errorCode: 403 + }; + } + + const targetUser = await Cache.get(cleanID(slmnggId)); + + if (!targetUser) throw { errorMessage: "No user found to send to Airtable" }; + + let response = await this.helpers.updateRecord("Players", targetUser, { + "Discord ID": discordData.id, + "Discord Tag": discordData.username + }); + + log(`[Profile] ${user.airtable.name} ${userMention(user.discord.id)} ${cleanID(user.airtable.id)} is linking Discord account for ${userMention(discordData.id)} ${discordData.id} to ${targetUser.name} ${cleanID(targetUser.id)} https://slmn.gg/player/${cleanID(targetUser.id)}`); + + if (response?.error) { + console.error("Airtable error", response.error); + throw "Airtable error"; + } + return targetUser.name; + } +}; diff --git a/server/src/actions/update-profile-data.js b/server/src/actions/update-profile-data.js index a6785341..c4e2eaa2 100644 --- a/server/src/actions/update-profile-data.js +++ b/server/src/actions/update-profile-data.js @@ -41,7 +41,7 @@ module.exports = { validatedData["Profile Picture Theme"] = [dirtyID(profileData.profile_picture_theme)]; } - console.log("[profile]", user.airtable.name, user.airtable.id, "is setting", validatedData); + console.log("[Profile]", user.airtable.name, user.airtable.id, "is setting", validatedData); let response = await this.helpers.updateRecord("Players", user.airtable, { ...validatedData }); diff --git a/server/src/discord/commands/admin/set-user-id.js b/server/src/discord/commands/admin/set-user-id.js new file mode 100644 index 00000000..be5a6473 --- /dev/null +++ b/server/src/discord/commands/admin/set-user-id.js @@ -0,0 +1,148 @@ +const { + ContextMenuCommandBuilder, + userMention, + ApplicationCommandType, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ButtonBuilder, + ButtonStyle, +} = require("discord.js"); +const Cache = require("../../../cache.js"); + +const { getInternalManager } = require("../../../action-utils/action-manager"); +const { cleanID } = require("../../../action-utils/action-utils"); + + +function generatePlayerDescription(player) { + let descriptionItems = []; + // descriptionItems.push(`${player["#_of_teams"]} team${player["#_of_teams"] === 1 ? "" : "s"}`); + if (player.pronouns) descriptionItems.push(`${player.pronouns}`); + if (player.discord_tag) descriptionItems.push(`Discord: ${player.discord_tag}`); + if (player.battletag) descriptionItems.push(`Battletag: ${player.battletag}`); + if (!descriptionItems?.length) return "No additional info"; + return descriptionItems.join(", "); +} + +function selectUserUI(targetUser, users, selectedUser) { + let content; + + const rows = []; + + if (users.length === 1) { + selectedUser = users[0]; + content = `Found match for ${userMention(targetUser.id)}: [${selectedUser.name}](https://slmn.gg/player/${cleanID(selectedUser.id)})`; + } else { + const options = users.map((p) => { + const option = new StringSelectMenuOptionBuilder() + .setLabel(p.name + ` (${p["#_of_teams"]} team${p["#_of_teams"] === 1 ? "" : "s"})`) + .setDescription(generatePlayerDescription(p)) + .setValue(p.id); + if (p.id === selectedUser?.id) { + option.setDefault(true); + } + return option; + }); + const dropdown = new StringSelectMenuBuilder() + .setCustomId("slmngg-player") + .setPlaceholder("Select SLMNGG player") + .addOptions(options); + rows.push(new ActionRowBuilder().addComponents(dropdown)); + content = "Please select the SLMN.GG user to link this account to."; + } + + if (selectedUser) { + const confirmButton = new ButtonBuilder() + .setCustomId("confirm") + .setLabel("Confirm") + .setStyle(ButtonStyle.Success); + + const cancelButton = new ButtonBuilder() + .setCustomId("cancel") + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary); + + rows.push(new ActionRowBuilder().addComponents(cancelButton, confirmButton)); + } + + return { + components: rows, + content + }; +} + +module.exports = { + data: new ContextMenuCommandBuilder() + .setName("Associate with SLMN.GG player") + .setDMPermission(false) + .setType(ApplicationCommandType.User), + async execute(interaction) { + const ephemeral = true; + + await interaction.deferReply({ ephemeral }); + + const { token } = await Cache.auth.startRawDiscordAuth(interaction.user); + + if (!token) { + return interaction.followUp({ ephemeral, content: "Could not authenticate you with SLMN.GG." }); + } + + const internalManager = getInternalManager(); + if (!internalManager) { + return interaction.followUp({ ephemeral, content: "No action handlers can process your request." }); + } + + const potentials = await internalManager.runAction("match-discord-slmngg-player", { discordData: interaction.targetUser }, token); + let selectedUser = potentials[0]; + + let response = await interaction.followUp({ ephemeral, ...selectUserUI(interaction.targetUser, potentials, null) }); + + const collector = await response.createMessageComponentCollector({ time: 3_600_000 }); + + collector.on("collect", async event => { + // console.log("collection", event); + + /* String menu selection */ + if (event.componentType === 3) { + let selectedUserID = event.values[0]; + selectedUser = potentials.find(u => u.id === selectedUserID); + console.log("collector setting selected user", selectedUser); + console.table(event.values); + + let menuOptions = { ephemeral, ...selectUserUI(interaction.targetUser, potentials, selectedUser) }; + console.log(menuOptions); + event.update({}); // tell discord we've handled this + await interaction.editReply(menuOptions); + + return; + } + + /* Button */ + if (event.componentType === 2) { + event.update({}); // tell discord we've handled this + + if (event.customId === "cancel") { + await interaction.followUp({ ephemeral, content: "Cancelled action." }); + collector.stop(); + } else if (event.customId === "confirm" && selectedUser !== null) { + await internalManager.runAction("update-player-discord-id", { discordData: interaction.targetUser, slmnggId: selectedUser?.id }, token) + .then(async slmnggUser => { + await interaction.followUp({ ephemeral, content: `Linked discord user ${userMention(interaction.targetUser.id)} to SLMN.GG user ${slmnggUser}` }); + collector.stop(); + }) + .catch(async ({ + errorCode, + errorMessage + }) => { + console.error({ errorCode, errorMessage }); + await interaction.followUp({ + ephemeral, content: `Action failed: ${errorMessage}` + }); + collector.stop(); + }); + } + } + + }); + } +}; diff --git a/server/src/discord/slash-commands.js b/server/src/discord/slash-commands.js index 6f9e26b2..7bf2d02f 100644 --- a/server/src/discord/slash-commands.js +++ b/server/src/discord/slash-commands.js @@ -26,12 +26,10 @@ for (const folder of commandFolders) { } client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isChatInputCommand()) return; - const command = interaction.client.commands.get(interaction.commandName); if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); + if (interaction.commandName) console.error(`No command matching ${interaction.commandName} was found.`); return; } @@ -41,12 +39,12 @@ client.on(Events.InteractionCreate, async (interaction) => { console.error(error); if (interaction.replied || interaction.deferred) { await interaction.followUp({ - content: "There was an error while executing this command!", + content: `There was an error while executing this command: \n> ${error.errorMessage}`, ephemeral: true }); } else { await interaction.reply({ - content: "There was an error while executing this command!", + content: `There was an error while executing this command: \n> ${error.errorMessage}`, ephemeral: true }); } diff --git a/server/yarn.lock b/server/yarn.lock index 96685308..567e9a18 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -210,7 +210,7 @@ fast-deep-equal "^3.1.3" lodash "^4.17.21" -"@sapphire/snowflake@^3.4.2": +"@sapphire/snowflake@^3.4.2", "@sapphire/snowflake@^3.5.1": version "3.5.1" resolved "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz" integrity sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA== @@ -977,10 +977,15 @@ discord-api-types@^0.37.37, discord-api-types@^0.37.41: resolved "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.45.tgz" integrity sha512-r9m/g+YQfo7XWMrl645jvMlYoWF8lvns/ch4NCxsz/FbingrECu97LFSD2zKOvgHaSc90BHP8wgshaMcA2/c6Q== -discord.js@^14.11.0: - version "14.11.0" - resolved "https://registry.npmjs.org/discord.js/-/discord.js-14.11.0.tgz" - integrity sha512-CkueWYFQ28U38YPR8HgsBR/QT35oPpMbEsTNM30Fs8loBIhnA4s70AwQEoy6JvLcpWWJO7GY0y2BUzZmuBMepQ== +discord-api-types@^0.37.45: + version "0.37.47" + resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.37.47.tgz#d622d5f5629a71ca2cd534442ae2800844e1888d" + integrity sha512-rNif8IAv6duS2z47BMXq/V9kkrLfkAoiwpFY3sLxxbyKprk065zqf3HLTg4bEoxRSmi+Lhc7yqGDrG8C3j8GFA== + +discord.js@14.12.0-dev.1688904265-df40dcd.0: + version "14.12.0-dev.1688904265-df40dcd.0" + resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-14.12.0-dev.1688904265-df40dcd.0.tgz#94c7100827ac1672af5d3a041b70331d90497c1e" + integrity sha512-4TomFy6bRIvU7uUAO/cFqhSY3LtiQkSJPYQHKkO+5eZQJZhXQl2fTNu807V5xJpzpElno7oEl+4SEoPItIAVvg== dependencies: "@discordjs/builders" "^1.6.3" "@discordjs/collection" "^1.5.1" @@ -988,13 +993,13 @@ discord.js@^14.11.0: "@discordjs/rest" "^1.7.1" "@discordjs/util" "^0.3.1" "@discordjs/ws" "^0.8.3" - "@sapphire/snowflake" "^3.4.2" + "@sapphire/snowflake" "^3.5.1" "@types/ws" "^8.5.4" - discord-api-types "^0.37.41" + discord-api-types "^0.37.45" fast-deep-equal "^3.1.3" lodash.snakecase "^4.1.1" - tslib "^2.5.0" - undici "^5.22.0" + tslib "^2.5.2" + undici "^5.22.1" ws "^8.13.0" doctrine@^3.0.0: @@ -3094,6 +3099,11 @@ tslib@^2.5.0: resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz" integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w== +tslib@^2.5.2: + version "2.6.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" + integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz" @@ -3150,7 +3160,7 @@ undefsafe@^2.0.3: dependencies: debug "^2.2.0" -undici@^5.22.0: +undici@^5.22.0, undici@^5.22.1: version "5.22.1" resolved "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz" integrity sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==