diff --git a/packages/api/handlers/interactions-pick-role.ts b/packages/api/handlers/interactions-pick-role.ts index 5eda7e5..4590134 100644 --- a/packages/api/handlers/interactions-pick-role.ts +++ b/packages/api/handlers/interactions-pick-role.ts @@ -1,22 +1,103 @@ +import { CategoryType, RoleSafety } from '@roleypoly/types'; +import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils'; +import { difference, keyBy } from 'lodash'; import { interactionsEndpoint } from '../utils/api-tools'; -import { getGuildData } from '../utils/guild'; -import { invalid, notFound, ok } from '../utils/responses'; +import { botToken } from '../utils/config'; +import { + getGuild, + getGuildData, + getGuildMember, + updateGuildMember, +} from '../utils/guild'; +import { conflict, invalid, notAuthenticated, notFound, ok } from '../utils/responses'; export const InteractionsPickRole = interactionsEndpoint( async (request: Request): Promise => { + const mode = request.method === 'PUT' ? 'add' : 'remove'; const reqURL = new URL(request.url); - const [, , serverID, userID, roleID] = reqURL.pathname.split('/'); - if (!serverID || !userID || !roleID) { + const [, , guildID, userID, roleID] = reqURL.pathname.split('/'); + if (!guildID || !userID || !roleID) { return invalid(); } - const guildData = await getGuildData(serverID); - if (!guildData) { + const guildP = getGuild(guildID); + const guildDataP = getGuildData(guildID); + const guildMemberP = getGuildMember( + { serverID: guildID, userID }, + { skipCachePull: true } + ); + + const [guild, guildData, guildMember] = await Promise.all([ + guildP, + guildDataP, + guildMemberP, + ]); + + if (!guild || !guildData || !guildMember) { return notFound(); } - // We get exactly one role, but we have to interact with it the same way as UI does. - // So check for safety, disable any "single" mode roles + let memberRoles = guildMember.roles; + + if ( + (mode === 'add' && memberRoles.includes(roleID)) || + (mode !== 'add' && !memberRoles.includes(roleID)) + ) { + return conflict(); + } + + const roleMap = keyBy(guild.roles, 'id'); + + const category = guildData.categories.find((category) => + category.roles.includes(roleID) + ); + // No category? illegal. + if (!category) { + return notFound(); + } + + // Category is hidden, this is illegal + if (category.hidden) { + return notFound(); + } + + // Role is unsafe, super illegal. + if (roleMap[roleID].safety !== RoleSafety.Safe) { + return notAuthenticated(); + } + + // In add mode, if the category is a single-mode, remove the other roles in the category. + if (mode === 'add' && category.type === CategoryType.Single) { + memberRoles = difference(memberRoles, category.roles); + } + + if (mode === 'add') { + memberRoles = [...memberRoles, roleID]; + } else { + memberRoles = memberRoles.filter((id) => id !== roleID); + } + + const patchMemberRoles = await discordFetch( + `/guilds/${guildID}/members/${userID}`, + botToken, + AuthType.Bot, + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + 'x-audit-log-reason': `Picked their roles via slash command`, + }, + body: JSON.stringify({ + roles: memberRoles, + }), + } + ); + + if (!patchMemberRoles) { + return respond({ error: 'discord rejected the request' }, { status: 500 }); + } + + await updateGuildMember({ serverID: guildID, userID }); return ok(); } diff --git a/packages/api/handlers/update-roles.ts b/packages/api/handlers/update-roles.ts index cf8256d..237b91b 100644 --- a/packages/api/handlers/update-roles.ts +++ b/packages/api/handlers/update-roles.ts @@ -1,5 +1,3 @@ -import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control'; -import { accessControlViolation } from '@roleypoly/api/utils/responses'; import { GuildData, Member, @@ -13,7 +11,7 @@ import { import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils'; import { difference, groupBy, keyBy, union } from 'lodash'; import { withSession } from '../utils/api-tools'; -import { botToken } from '../utils/config'; +import { botToken, uiPublicURI } from '../utils/config'; import { getGuild, getGuildData, @@ -58,10 +56,6 @@ export const UpdateRoles = withSession( const guildData = await getGuildData(guildID); - if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) { - return accessControlViolation(); - } - const newRoles = calculateNewRoles({ currentRoles: guildMember.roles, guildRoles: guild.roles, @@ -77,7 +71,7 @@ export const UpdateRoles = withSession( method: 'PATCH', headers: { 'content-type': 'application/json', - 'x-audit-log-reason': `${username}#${discriminator} changes their roles via ${url.hostname}`, + 'x-audit-log-reason': `Picked their roles via ${uiPublicURI}`, }, body: JSON.stringify({ roles: newRoles, diff --git a/packages/api/index.ts b/packages/api/index.ts index 1c4cf11..ef7200a 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -36,7 +36,8 @@ router.add('POST', 'clear-guild-cache', ClearGuildCache); // Interactions endpoints router.add('GET', 'interactions-pickable-roles', InteractionsPickableRoles); -router.add('POST', 'interactions-pick-role', InteractionsPickRole); +router.add('PUT', 'interactions-pick-role', InteractionsPickRole); +router.add('DELETE', 'interactions-pick-role', InteractionsPickRole); // Tester Routes router.add('GET', 'x-headers', (request) => { diff --git a/packages/interactions/handlers/interaction.ts b/packages/interactions/handlers/interaction.ts index 9daeef5..70b8ddc 100644 --- a/packages/interactions/handlers/interaction.ts +++ b/packages/interactions/handlers/interaction.ts @@ -1,8 +1,3 @@ -import { helloWorld } from '@roleypoly/interactions/handlers/interactions/hello-world'; -import { pickableRoles } from '@roleypoly/interactions/handlers/interactions/pickable-roles'; -import { roleypoly } from '@roleypoly/interactions/handlers/interactions/roleypoly'; -import { verifyRequest } from '@roleypoly/interactions/utils/interactions'; -import { somethingWentWrong } from '@roleypoly/interactions/utils/responses'; import { InteractionData, InteractionRequest, @@ -11,6 +6,12 @@ import { InteractionType, } from '@roleypoly/types'; import { respond } from '@roleypoly/worker-utils'; +import { verifyRequest } from '../utils/interactions'; +import { somethingWentWrong } from '../utils/responses'; +import { helloWorld } from './interactions/hello-world'; +import { pickRole } from './interactions/pick-role'; +import { pickableRoles } from './interactions/pickable-roles'; +import { roleypoly } from './interactions/roleypoly'; const commands: Record< InteractionData['name'], @@ -19,6 +20,8 @@ const commands: Record< 'hello-world': helloWorld, roleypoly: roleypoly, 'pickable-roles': pickableRoles, + 'pick-role': pickRole('add'), + 'remove-role': pickRole('remove'), }; export const interactionHandler = async (request: Request): Promise => { diff --git a/packages/interactions/handlers/interactions/pick-role.ts b/packages/interactions/handlers/interactions/pick-role.ts index 990ffe7..2d7aca7 100644 --- a/packages/interactions/handlers/interactions/pick-role.ts +++ b/packages/interactions/handlers/interactions/pick-role.ts @@ -1,16 +1,80 @@ +import { selectRole } from '@roleypoly/interactions/utils/api'; +import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses'; import { InteractionCallbackType, + InteractionFlags, InteractionRequestCommand, InteractionResponse, } from '@roleypoly/types'; -export const helloWorld = async ( - interaction: InteractionRequestCommand -): Promise => { - return { - type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`, - }, +export const pickRole = + (mode: 'add' | 'remove') => + async (interaction: InteractionRequestCommand): Promise => { + if (!interaction.guild_id) { + return mustBeInGuild(); + } + + const userID = interaction.member?.user?.id; + if (!userID) { + return mustBeInGuild(); + } + + const roleID = interaction.data.options?.find( + (option) => option.name === 'role' + )?.value; + if (!roleID) { + return invalid(); + } + + const code = await selectRole(mode, interaction.guild_id, userID, roleID); + + if (code === 409) { + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `:x: You ${mode === 'add' ? 'already' : "don't"} have that role.`, + flags: InteractionFlags.EPHEMERAL, + }, + }; + } + + if (code === 404) { + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `:x: <@&${roleID}> isn't pickable.`, + flags: InteractionFlags.EPHEMERAL, + }, + }; + } + + if (code === 403) { + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `:x: <@&${roleID}> has unsafe permissions.`, + flags: InteractionFlags.EPHEMERAL, + }, + }; + } + + if (code !== 200) { + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `:x: Something went wrong, please try again later.`, + flags: InteractionFlags.EPHEMERAL, + }, + }; + } + + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `:white_check_mark: You ${ + mode === 'add' ? 'got' : 'removed' + } the role: <@&${roleID}>`, + flags: InteractionFlags.EPHEMERAL, + }, + }; }; -}; diff --git a/packages/interactions/handlers/interactions/roleypoly.ts b/packages/interactions/handlers/interactions/roleypoly.ts index bbf5333..f31ce2d 100644 --- a/packages/interactions/handlers/interactions/roleypoly.ts +++ b/packages/interactions/handlers/interactions/roleypoly.ts @@ -1,5 +1,6 @@ import { uiPublicURI } from '@roleypoly/interactions/utils/config'; import { + Embed, InteractionCallbackType, InteractionFlags, InteractionRequestCommand, @@ -13,7 +14,34 @@ export const roleypoly = async ( return { type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data: { - content: `:beginner: Assign your roles here! ${uiPublicURI}/s/${interaction.guild_id}`, + embeds: [ + { + color: 0x453e3d, + title: `:beginner: Hey there, ${ + interaction.member?.nick || interaction.member?.user?.username || 'friend' + }!`, + description: `Try these slash commands, or pick roles from your browser!`, + fields: [ + { name: 'See all the roles', value: '/pickable-roles' }, + { name: 'Pick a role', value: '/pick-role' }, + { name: 'Remove a role', value: '/remove-role' }, + ], + } as Embed, + ], + components: [ + { + type: 1, + components: [ + // Link to Roleypoly + { + type: 2, + label: `Pick roles on ${new URL(uiPublicURI).hostname}`, + url: `${uiPublicURI}/s/${interaction.guild_id}`, + style: 5, + }, + ], + }, + ], flags: InteractionFlags.EPHEMERAL, }, }; diff --git a/packages/interactions/utils/api.ts b/packages/interactions/utils/api.ts index ea8031b..2ec79ed 100644 --- a/packages/interactions/utils/api.ts +++ b/packages/interactions/utils/api.ts @@ -23,3 +23,19 @@ export const getPickableRoles = async ( return (await response.json()) as Record; }; + +export const selectRole = async ( + mode: 'add' | 'remove', + guildID: string, + userID: string, + roleID: string +): Promise => { + const response = await apiFetch( + `/interactions-pick-role/${guildID}/${userID}/${roleID}`, + { + method: mode === 'add' ? 'PUT' : 'DELETE', + } + ); + + return response.status; +}; diff --git a/packages/interactions/utils/responses.ts b/packages/interactions/utils/responses.ts index 4c6a25b..f79dccc 100644 --- a/packages/interactions/utils/responses.ts +++ b/packages/interactions/utils/responses.ts @@ -12,6 +12,14 @@ export const mustBeInGuild = (): InteractionResponse => ({ }, }); +export const invalid = (): InteractionResponse => ({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: ':x: You filled that command out wrong...', + flags: InteractionFlags.EPHEMERAL, + }, +}); + export const somethingWentWrong = (): InteractionResponse => ({ type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data: { diff --git a/packages/types/Interactions.ts b/packages/types/Interactions.ts index 37cf34c..b547144 100644 --- a/packages/types/Interactions.ts +++ b/packages/types/Interactions.ts @@ -28,7 +28,11 @@ export type InteractionData = { id: string; name: string; resolved?: {}; - options?: {}[]; + options?: { + name: string; + type: number; + value?: string; + }[]; custom_id: string; component_type: string; }; diff --git a/terraform/interactions.tf b/terraform/interactions.tf index c97ba66..692a261 100644 --- a/terraform/interactions.tf +++ b/terraform/interactions.tf @@ -32,6 +32,7 @@ resource "discord-interactions_global_command" "pick-role" { name = "role" description = "The role you want" type = 8 + required = true } } @@ -40,12 +41,29 @@ resource "discord-interactions_guild_command" "pick-role" { guild_id = each.value name = "pick-role" - description = "Pick a role! (See which ones can be picked with /pickable-roles)" + description = "**[TEST]** Pick a role! (See which ones can be picked with /pickable-roles)" + option { name = "role" description = "The role you want" type = 8 + required = true + } +} + +resource "discord-interactions_guild_command" "remove-role" { + for_each = local.internalTestingGuilds + guild_id = each.value + + name = "remove-role" + description = "**[TEST]** Pick a role to remove (See which ones can be removed with /pickable-roles)" + + option { + name = "role" + description = "The role you want to remove" + type = 8 + required = true } }