diff --git a/packages/api/src/guilds/getters.spec.ts b/packages/api/src/guilds/getters.spec.ts index 138e593..8dc7ff2 100644 --- a/packages/api/src/guilds/getters.spec.ts +++ b/packages/api/src/guilds/getters.spec.ts @@ -1,7 +1,7 @@ jest.mock('../utils/discord'); jest.mock('../utils/legacy'); -import { CategoryType, Features, GuildData } from '@roleypoly/types'; +import { CategoryType, Features, Guild, GuildData, RoleSafety } from '@roleypoly/types'; import { APIGuild, discordFetch } from '../utils/discord'; import { fetchLegacyServer, @@ -9,7 +9,7 @@ import { transformLegacyGuild, } from '../utils/legacy'; import { configContext } from '../utils/testHelpers'; -import { getGuild, getGuildData, getGuildMember } from './getters'; +import { getGuild, getGuildData, getGuildMember, getPickableRoles } from './getters'; const mockDiscordFetch = discordFetch as jest.Mock; const mockFetchLegacyServer = fetchLegacyServer as jest.Mock; @@ -241,3 +241,81 @@ describe('getGuildMember', () => { expect(result!.nick).toBe('test2'); }); }); + +describe('getPickableRoles', () => { + it('returns all pickable roles for a given guild', async () => { + const guildData: GuildData = { + id: '123', + message: 'Hello world!', + categories: [ + { + id: '123', + name: 'test', + position: 0, + roles: ['role-1', 'role-2', 'role-unsafe'], + hidden: false, + type: CategoryType.Multi, + }, + { + id: '123', + name: 'test', + position: 0, + roles: ['role-3', 'role-4'], + hidden: true, + type: CategoryType.Multi, + }, + ], + features: Features.None, + auditLogWebhook: null, + accessControl: { + allowList: [], + blockList: [], + blockPending: true, + }, + }; + + const guild: Guild = { + id: '123', + name: 'test', + icon: '', + roles: [ + { + id: 'role-1', + name: 'test', + position: 0, + managed: false, + color: 0, + safety: RoleSafety.Safe, + permissions: '0', + }, + { + id: 'role-3', + name: 'test', + position: 0, + managed: false, + color: 0, + safety: RoleSafety.Safe, + permissions: '0', + }, + { + id: 'role-unsafe', + name: 'test', + position: 0, + managed: false, + color: 0, + safety: RoleSafety.DangerousPermissions, + permissions: '0', + }, + ], + }; + + const result = getPickableRoles(guildData, guild); + + expect(result).toMatchObject([ + { + category: guildData.categories[0], + roles: [guild.roles[0]], + }, + ]); + }); +}); diff --git a/packages/api/src/guilds/getters.ts b/packages/api/src/guilds/getters.ts index 6bc3feb..93d4094 100644 --- a/packages/api/src/guilds/getters.ts +++ b/packages/api/src/guilds/getters.ts @@ -10,6 +10,7 @@ import { import { fetchLegacyServer, transformLegacyGuild } from '@roleypoly/api/src/utils/legacy'; import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission'; import { + Category, Features, Guild, GuildData, @@ -199,3 +200,31 @@ const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: numbe return safety; }; + +export const getPickableRoles = ( + guildData: GuildData, + guild: Guild +): { category: Category; roles: Role[] }[] => { + const pickableRoles: { category: Category; roles: Role[] }[] = []; + + for (const category of guildData.categories) { + if (category.roles.length === 0 || category.hidden) { + continue; + } + + const roles = category.roles + .map((roleID) => guild.roles.find((r) => r.id === roleID)) + .filter((role) => role !== undefined && role.safety === RoleSafety.Safe) as Role[]; + + if (roles.length > 0) { + pickableRoles.push({ + category, + roles, + }); + } + } + + console.log({ pickableRoles }); + + return pickableRoles; +}; diff --git a/packages/api/src/routes/interactions/commands/pickable-roles.ts b/packages/api/src/routes/interactions/commands/pickable-roles.ts new file mode 100644 index 0000000..94b0c43 --- /dev/null +++ b/packages/api/src/routes/interactions/commands/pickable-roles.ts @@ -0,0 +1,135 @@ +import { + getGuild, + getGuildData, + getGuildMember, + getPickableRoles, +} from '@roleypoly/api/src/guilds/getters'; +import { + getName, + InteractionHandler, +} from '@roleypoly/api/src/routes/interactions/helpers'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { + CategoryType, + Embed, + InteractionCallbackType, + InteractionRequest, + InteractionResponse, + Role, +} from '@roleypoly/types'; + +export const pickableRoles: InteractionHandler = async ( + interaction: InteractionRequest, + 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.`, + }, + ], + }, + }; + } + + const [guildData, guild, member] = await Promise.all([ + getGuildData(context.config, interaction.guild_id), + getGuild(context.config, interaction.guild_id), + getGuildMember(context.config, interaction.guild_id, interaction.member?.user?.id!), + ]); + + 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.`, + }, + ], + }, + }; + } + + 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.`, + }, + ], + }, + }; + } + + const makeBoldIfMemberHasRole = (role: Role, base: string): string => { + if (member?.roles.includes(role.id)) { + return `__${base}__`; + } + + return base; + }; + + const embed: Embed = { + color: 0xab9b9a, + fields: roles.map(({ category, roles }) => { + return { + name: `${category.name}${ + category.type === CategoryType.Single ? ' *(pick one)*' : '' + }`, + value: roles + .map((role) => makeBoldIfMemberHasRole(role, `<@&${role.id}>`)) + .join(', '), + } as Embed['fields'][0]; + }), + title: 'You can pick any of these roles with /pick-role', + footer: { + text: `Roles with an __underline__ are already picked by you.`, + }, + }; + + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [embed], + components: [ + { + type: 1, + components: [ + // Link to Roleypoly + { + type: 2, + label: 'Pick roles on your browser', + url: `${context.config.uiPublicURI}/s/${interaction.guild_id}`, + style: 5, + }, + ], + }, + ], + }, + }; +}; + +pickableRoles.ephemeral = true; +pickableRoles.deferred = true; diff --git a/packages/api/src/routes/interactions/commands/roleypoly.ts b/packages/api/src/routes/interactions/commands/roleypoly.ts new file mode 100644 index 0000000..e184465 --- /dev/null +++ b/packages/api/src/routes/interactions/commands/roleypoly.ts @@ -0,0 +1,61 @@ +import { + getName, + InteractionHandler, +} from '@roleypoly/api/src/routes/interactions/helpers'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { + Embed, + InteractionCallbackType, + InteractionRequest, + InteractionResponse, +} from '@roleypoly/types'; + +export const roleypoly: InteractionHandler = ( + interaction: InteractionRequest, + 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 { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [ + { + color: 0x453e3d, + title: `:beginner: Hey there, ${getName(interaction)}!`, + 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(context.config.uiPublicURI).hostname}`, + url: `${context.config.uiPublicURI}/s/${interaction.guild_id}`, + style: 5, + }, + ], + }, + ], + }, + }; +}; + +roleypoly.ephemeral = true; diff --git a/packages/api/src/routes/interactions/helpers.ts b/packages/api/src/routes/interactions/helpers.ts index fff47f0..8e89243 100644 --- a/packages/api/src/routes/interactions/helpers.ts +++ b/packages/api/src/routes/interactions/helpers.ts @@ -1,12 +1,7 @@ import { Config } from '@roleypoly/api/src/utils/config'; import { Context } from '@roleypoly/api/src/utils/context'; import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord'; -import { - InteractionCallbackType, - InteractionFlags, - InteractionRequest, - InteractionResponse, -} from '@roleypoly/types'; +import { InteractionRequest, InteractionResponse } from '@roleypoly/types'; export const verifyRequest = async ( config: Config, @@ -76,17 +71,19 @@ export const runAsync = async ( try { const response = await handler(interaction, context); + if (!response) { + throw new Error('Interaction handler returned no response'); + } + + console.log({ response }); + await discordFetch(url, '', AuthType.None, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, - data: { - flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0, - ...response.data, - }, + ...response.data, }), }); } catch (e) { @@ -105,13 +102,18 @@ export const runAsync = async ( 'Content-Type': 'application/json', }, body: JSON.stringify({ - type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, - data: { - content: "I'm sorry, I'm having trouble processing this request.", - flags: InteractionFlags.EPHEMERAL, - }, - } as InteractionResponse), + content: "I'm sorry, I'm having trouble processing this request.", + } as InteractionResponse['data']), }); } catch (e) {} } }; + +export const getName = (interaction: InteractionRequest): string => { + return ( + interaction.member?.nick || + interaction.member?.user?.username || + interaction.user?.username || + 'friend' + ); +}; diff --git a/packages/api/src/routes/interactions/interactions.ts b/packages/api/src/routes/interactions/interactions.ts index 831fd7d..091cce6 100644 --- a/packages/api/src/routes/interactions/interactions.ts +++ b/packages/api/src/routes/interactions/interactions.ts @@ -1,4 +1,6 @@ import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world'; +import { pickableRoles } from '@roleypoly/api/src/routes/interactions/commands/pickable-roles'; +import { roleypoly } from '@roleypoly/api/src/routes/interactions/commands/roleypoly'; import { InteractionHandler, runAsync, @@ -18,6 +20,8 @@ import { const commands: Record = { 'hello-world': helloWorld, + roleypoly: roleypoly, + 'pickable-roles': pickableRoles, }; export const handleInteraction: RoleypolyHandler = async ( @@ -57,6 +61,7 @@ export const handleInteraction: RoleypolyHandler = async ( return json({ type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, data: { + content: 'Figuring it out...', flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0, }, } as InteractionResponse);