add /roleypoly and /pickable-roles slash commands, fix framework issues

This commit is contained in:
41666 2022-02-04 01:37:14 -05:00
parent fd7ed13e9d
commit 5c5258ef5e
6 changed files with 329 additions and 19 deletions

View file

@ -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]],
},
]);
});
});

View file

@ -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;
};

View file

@ -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<InteractionResponse> => {
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;

View file

@ -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;

View file

@ -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'
);
};

View file

@ -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<InteractionData['name'], InteractionHandler> = {
'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);