diff --git a/packages/api/src/routes/interactions/commands/pick-role.ts b/packages/api/src/routes/interactions/commands/pick-role.ts new file mode 100644 index 0000000..e927ec0 --- /dev/null +++ b/packages/api/src/routes/interactions/commands/pick-role.ts @@ -0,0 +1,18 @@ +import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers'; +import { rolePickerCommon } from '@roleypoly/api/src/routes/interactions/role-picker-common'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { + InteractionRequest, + InteractionResponse, + TransactionType, +} from '@roleypoly/types'; + +export const pickRole: InteractionHandler = async ( + interaction: InteractionRequest, + context: Context +): Promise => { + return rolePickerCommon(interaction, context, TransactionType.Add); +}; + +pickRole.ephemeral = true; +pickRole.deferred = true; diff --git a/packages/api/src/routes/interactions/commands/pickable-roles.ts b/packages/api/src/routes/interactions/commands/pickable-roles.ts index 8022937..c7d0652 100644 --- a/packages/api/src/routes/interactions/commands/pickable-roles.ts +++ b/packages/api/src/routes/interactions/commands/pickable-roles.ts @@ -9,6 +9,10 @@ import { getName, InteractionHandler, } from '@roleypoly/api/src/routes/interactions/helpers'; +import { + embedPalette, + embedResponse, +} from '@roleypoly/api/src/routes/interactions/responses'; import { Context } from '@roleypoly/api/src/utils/context'; import { CategoryType, @@ -24,20 +28,13 @@ export const pickableRoles: InteractionHandler = async ( context: Context ): Promise => { if (!interaction.guild_id) { - return { - type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - color: 0xff0000, - title: ':x: Error', - description: `:x: Hey ${getName( - interaction - )}. You need to use this command in a server, not in a DM.`, - }, - ], - }, - }; + return embedResponse( + ':x: Error', + `Hey ${getName( + interaction + )}. You need to use this command in a server, not in a DM.`, + embedPalette.error + ); } const [guildData, guild, member] = await Promise.all([ @@ -47,41 +44,26 @@ export const pickableRoles: InteractionHandler = async ( ]); if (!guildData || !guild) { - return { - type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - color: 0xff0000, - title: ':x: Error', - description: `:x: Hey ${getName( - interaction - )}. Something's wrong with the server you're in. Try picking your roles at ${ - context.config.uiPublicURI - }/s/${interaction.guild_id} instead.`, - }, - ], - }, - }; + return embedResponse( + ':x: Error', + `Hey ${getName( + interaction + )}. Something's wrong with the server you're in. Try picking your roles at ${ + context.config.uiPublicURI + }/s/${interaction.guild_id} instead.`, + embedPalette.error + ); } const roles = getPickableRoles(guildData, guild); if (roles.length === 0) { - console.warn('/pickable-roles turned up empty?', { roles, guild, guildData }); - return { - type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [ - { - color: 0xff0000, - title: ':fire: Error', - description: `Hey ${getName( - interaction - )}. This server might not be set up to use Roleypoly yet, as there are no roles to pick from.`, - }, - ], - }, - }; + return embedResponse( + ':fire: Error', + `Hey ${getName( + interaction + )}. This server might not be set up to use Roleypoly yet, as there are no roles to pick from.`, + embedPalette.error + ); } const makeBoldIfMemberHasRole = (role: Role, base: string): string => { @@ -93,7 +75,7 @@ export const pickableRoles: InteractionHandler = async ( }; const embed: Embed = { - color: 0xab9b9a, + color: embedPalette.neutral, fields: roles.map(({ category, roles }) => { return { name: `${category.name}${ diff --git a/packages/api/src/routes/interactions/commands/remove-role.ts b/packages/api/src/routes/interactions/commands/remove-role.ts new file mode 100644 index 0000000..771e12a --- /dev/null +++ b/packages/api/src/routes/interactions/commands/remove-role.ts @@ -0,0 +1,18 @@ +import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers'; +import { rolePickerCommon } from '@roleypoly/api/src/routes/interactions/role-picker-common'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { + InteractionRequest, + InteractionResponse, + TransactionType, +} from '@roleypoly/types'; + +export const removeRole: InteractionHandler = async ( + interaction: InteractionRequest, + context: Context +): Promise => { + return rolePickerCommon(interaction, context, TransactionType.Remove); +}; + +removeRole.ephemeral = true; +removeRole.deferred = true; diff --git a/packages/api/src/routes/interactions/commands/roleypoly.ts b/packages/api/src/routes/interactions/commands/roleypoly.ts index e184465..6dd18cd 100644 --- a/packages/api/src/routes/interactions/commands/roleypoly.ts +++ b/packages/api/src/routes/interactions/commands/roleypoly.ts @@ -2,6 +2,7 @@ import { getName, InteractionHandler, } from '@roleypoly/api/src/routes/interactions/helpers'; +import { embedResponse } from '@roleypoly/api/src/routes/interactions/responses'; import { Context } from '@roleypoly/api/src/utils/context'; import { Embed, @@ -15,14 +16,12 @@ export const roleypoly: InteractionHandler = ( context: Context ): InteractionResponse => { if (!interaction.guild_id) { - return { - type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: `:x: Hey ${getName( - interaction - )}. You need to use this command in a server, not in a DM.`, - }, - }; + return embedResponse( + ':x: Error', + `Hey ${getName( + interaction + )}. You need to use this command in a server, not in a DM.` + ); } return { diff --git a/packages/api/src/routes/interactions/interactions.ts b/packages/api/src/routes/interactions/interactions.ts index 0dfd35b..ad67cf6 100644 --- a/packages/api/src/routes/interactions/interactions.ts +++ b/packages/api/src/routes/interactions/interactions.ts @@ -1,5 +1,7 @@ import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world'; +import { pickRole } from '@roleypoly/api/src/routes/interactions/commands/pick-role'; import { pickableRoles } from '@roleypoly/api/src/routes/interactions/commands/pickable-roles'; +import { removeRole } from '@roleypoly/api/src/routes/interactions/commands/remove-role'; import { roleypoly } from '@roleypoly/api/src/routes/interactions/commands/roleypoly'; import { InteractionHandler, @@ -22,6 +24,8 @@ const commands: Record = { 'hello-world': helloWorld, roleypoly: roleypoly, 'pickable-roles': pickableRoles, + 'pick-role': pickRole, + 'remove-role': removeRole, }; export const handleInteraction: RoleypolyHandler = async ( diff --git a/packages/api/src/routes/interactions/responses.ts b/packages/api/src/routes/interactions/responses.ts index deb24da..4d38036 100644 --- a/packages/api/src/routes/interactions/responses.ts +++ b/packages/api/src/routes/interactions/responses.ts @@ -36,3 +36,26 @@ export const notImplemented: InteractionHandler = (): InteractionResponse => ({ flags: InteractionFlags.EPHEMERAL, }, }); + +export const embedResponse = ( + title: string, + description: string, + color?: number +): InteractionResponse => ({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + color: color || 0x00ff00, + title, + description, + }, + ], + }, +}); + +export const embedPalette = { + success: 0x1d8227, + error: 0xf14343, + neutral: 0x2c2f33, +}; diff --git a/packages/api/src/routes/interactions/role-picker-common.ts b/packages/api/src/routes/interactions/role-picker-common.ts new file mode 100644 index 0000000..83ef66a --- /dev/null +++ b/packages/api/src/routes/interactions/role-picker-common.ts @@ -0,0 +1,155 @@ +import { getGuild, getGuildData } from '@roleypoly/api/src/guilds/getters'; +import { calculateNewRoles } from '@roleypoly/api/src/routes/guilds/guild-roles-put'; +import { getName } from '@roleypoly/api/src/routes/interactions/helpers'; +import { + embedPalette, + embedResponse, +} from '@roleypoly/api/src/routes/interactions/responses'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord'; +import { isIdenticalArray } from '@roleypoly/misc-utils/collection-tools'; +import { + CategoryType, + InteractionRequest, + InteractionResponse, + RoleTransaction, + TransactionType, +} from '@roleypoly/types'; + +export const rolePickerCommon = async ( + interaction: InteractionRequest, + context: Context, + action: TransactionType +): Promise => { + if (!interaction.guild_id) { + return embedResponse( + ':x: Error', + `Hey ${getName( + interaction + )}. You need to use this command in a server, not in a DM.` + ); + } + + const currentRoles = interaction.member?.roles || []; + + const [guildData, guild] = await Promise.all([ + getGuildData(context.config, interaction.guild_id), + getGuild(context.config, interaction.guild_id), + ]); + + if (!guildData || !guild) { + return embedResponse( + ':x: Error', + `Hey ${getName( + interaction + )}. Something's wrong with the server you're in. Try picking your roles at ${ + context.config.uiPublicURI + }/s/${interaction.guild_id} instead.`, + embedPalette.error + ); + } + + const roleToPick = interaction.data?.options?.[0]?.value; + if (!roleToPick) { + return embedResponse( + ':fire: Discord sent me the wrong data', + `Hey ${getName(interaction)}. Please try again later.`, + embedPalette.error + ); + } + + const hasRole = interaction.member?.roles.includes(roleToPick); + if (action === TransactionType.Add && hasRole) { + return embedResponse( + `:white_check_mark: You already have that role.`, + `Hey ${getName(interaction)}. You already have <@&${roleToPick}>!`, + embedPalette.neutral + ); + } + + if (action === TransactionType.Remove && !hasRole) { + return embedResponse( + `:white_check_mark: You don't have that role.`, + `Hey ${getName(interaction)}. You already don't have <@&${roleToPick}>!`, + embedPalette.neutral + ); + } + + const extraTransactions: RoleTransaction[] = []; + let isSingle = false; + if (action === TransactionType.Add) { + // For single-type categories, let's also generate the remove rules for the other roles in the category + const category = guildData.categories.find((category) => + category.roles.includes(roleToPick) + ); + if (category?.type === CategoryType.Single) { + const otherRoles = category.roles.filter((role) => role !== roleToPick); + extraTransactions.push( + ...otherRoles.map((role) => ({ action: TransactionType.Remove, id: role })) + ); + isSingle = true; + } + } + + const newRoles = calculateNewRoles({ + currentRoles, + guildRoles: guild.roles, + guildData, + updateRequest: { + knownState: currentRoles, + transactions: [{ action, id: roleToPick }, ...extraTransactions], + }, + }); + + if (isIdenticalArray(currentRoles, newRoles)) { + return embedResponse( + ':x: You cannot pick this role.', + `Hey ${getName( + interaction + )}. <@&${roleToPick}> isn't pickable. Check /pickable-roles to see which roles you can use.`, + embedPalette.error + ); + } + + const patchMemberRoles = await discordFetch( + `/guilds/${interaction.guild_id}/members/${interaction.member?.user?.id}`, + context.config.botToken, + AuthType.Bot, + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + 'x-audit-log-reason': `Picked their roles via /${ + action === TransactionType.Add ? 'pick' : 'remove' + }-role`, + }, + body: JSON.stringify({ + roles: newRoles, + }), + } + ); + + if (!patchMemberRoles) { + return embedResponse( + ':x: Discord stopped me from updating your roles.', + `Hey ${getName( + interaction + )}. Discord didn't let me give you <@&${roleToPick}>. Could you try again later?`, + embedPalette.error + ); + } + + return action === TransactionType.Add + ? embedResponse( + ':white_check_mark: You got it!', + `Hey ${getName(interaction)}, I gave you <@&${roleToPick}>!${ + isSingle ? `\nThe other roles in this category have been removed.` : '' + }`, + embedPalette.success + ) + : embedResponse( + ":white_check_mark: You (don't) got it!", + `Hey ${getName(interaction)}, I took away <@&${roleToPick}>!`, + embedPalette.success + ); +};