diff --git a/packages/api/src/routes/interactions/commands/pickable-roles.ts b/packages/api/src/routes/interactions/commands/pickable-roles.ts index 94b0c43..8022937 100644 --- a/packages/api/src/routes/interactions/commands/pickable-roles.ts +++ b/packages/api/src/routes/interactions/commands/pickable-roles.ts @@ -5,6 +5,7 @@ import { getPickableRoles, } from '@roleypoly/api/src/guilds/getters'; import { + embedBuilder, getName, InteractionHandler, } from '@roleypoly/api/src/routes/interactions/helpers'; @@ -112,7 +113,7 @@ export const pickableRoles: InteractionHandler = async ( return { type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data: { - embeds: [embed], + embeds: embedBuilder(embed), components: [ { type: 1, diff --git a/packages/api/src/routes/interactions/helpers.spec.ts b/packages/api/src/routes/interactions/helpers.spec.ts index 795332d..e32fa12 100644 --- a/packages/api/src/routes/interactions/helpers.spec.ts +++ b/packages/api/src/routes/interactions/helpers.spec.ts @@ -1,7 +1,7 @@ import { InteractionRequest, InteractionType } from '@roleypoly/types'; import nacl from 'tweetnacl'; import { configContext } from '../../utils/testHelpers'; -import { verifyRequest } from './helpers'; +import { embedBuilder, verifyRequest } from './helpers'; // // Q: Why tweetnacl when WebCrypto is available? @@ -129,3 +129,51 @@ describe('verifyRequest', () => { expect(await verifyRequest(context.config, request, body)).toBe(false); }); }); + +describe('embedBuilder', () => { + it('builds embeds that discord approves of', () => { + const embeds = embedBuilder({ + title: 'Test', + fields: [ + { + name: 'Field 1', + value: 'role-1, role-2, role-3, role-4, role-5, ' + .repeat(1024 / 30 - 15) + .replace(/, $/, ''), + }, + { + name: 'Field 2', + value: 'role-1, role-2, role-3, role-4, role-5, ' + .repeat(1024 / 30 + 4) + .replace(/, $/, ''), + }, + ], + color: 0xff0000, + }); + + expect(embeds).toMatchInlineSnapshot(` + Array [ + Object { + "color": 16711680, + "fields": Array [ + Object { + "name": "Field 1", + "value": "role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5", + }, + Object { + "name": "Field 2", + "value": "role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3", + }, + Object { + "name": "Field 2 (continued)", + "value": "role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5, role-1, role-2, role-3, role-4, role-5", + }, + ], + "title": "Test", + }, + ] + `); + expect(embeds.length).toBe(1); + expect(embeds[0].fields.length).toBe(3); + }); +}); diff --git a/packages/api/src/routes/interactions/helpers.ts b/packages/api/src/routes/interactions/helpers.ts index 8e89243..869cb28 100644 --- a/packages/api/src/routes/interactions/helpers.ts +++ b/packages/api/src/routes/interactions/helpers.ts @@ -1,7 +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 { InteractionRequest, InteractionResponse } from '@roleypoly/types'; +import { Embed, InteractionRequest, InteractionResponse } from '@roleypoly/types'; export const verifyRequest = async ( config: Config, @@ -117,3 +117,87 @@ export const getName = (interaction: InteractionRequest): string => { 'friend' ); }; + +/** + * Take a single big embed and fit it into Discord limits + * per embed, 25 fields, and 1024 characters per field. + * so we'll make new embeds/fields as the content gets too long. + */ +export const embedBuilder = (embed: Embed): Embed[] => { + const embeds: Embed[] = []; + + const titleCorrection = (title: string, withContinued?: boolean) => { + const suffix = withContinued ? '... (continued)' : '...'; + const offsetTitle = title.length + suffix.length; + return title.length > 256 - offsetTitle + ? title.slice(0, 256 - offsetTitle) + suffix + : withContinued + ? `${title} (continued)` + : title; + }; + + let currentEmbed: Embed = { + color: embed.color, + title: embed.title, + fields: [], + }; + + let knownFieldTitles: string[] = []; + + const commitField = (field: Embed['fields'][0]) => { + if (currentEmbed.fields.length === 25) { + embeds.push(currentEmbed); + currentEmbed = { + color: embed.color, + title: `${embed.title} (continued)`, + fields: [], + }; + } + + console.warn({ field }); + const addContinued = knownFieldTitles.includes(field.name); + + if (!addContinued) { + knownFieldTitles.push(field.name); + } + + field.name = titleCorrection(`${field.name}`, addContinued); + console.warn({ field, knownFieldTitles }); + + currentEmbed.fields.push(field); + }; + + for (let field of embed.fields) { + if (field.value.length <= 1024) { + commitField(field); + continue; + } + + const split = field.value.split(', '); // we know we'll be using , as a delimiter + let fieldValue: Embed['fields'][0]['value'] = ''; + for (let part of split) { + if (fieldValue.length + part.length > 1024) { + commitField({ + name: field.name, + value: fieldValue.replace(/, $/, ''), + }); + fieldValue = ''; + } else { + fieldValue += part + ', '; + } + } + + if (fieldValue.length > 0) { + commitField({ + name: field.name, + value: fieldValue.replace(/, $/, ''), + }); + } + } + + if (currentEmbed.fields.length > 0) { + embeds.push(currentEmbed); + } + + return embeds; +}; diff --git a/packages/api/src/routes/interactions/interactions.spec.ts b/packages/api/src/routes/interactions/interactions.spec.ts index 4a4cebd..438a5fd 100644 --- a/packages/api/src/routes/interactions/interactions.spec.ts +++ b/packages/api/src/routes/interactions/interactions.spec.ts @@ -24,11 +24,7 @@ it('responds with a simple hello-world!', async () => { }); expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, { body: JSON.stringify({ - type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, - data: { - flags: InteractionFlags.EPHEMERAL, - content: 'Hey there, test-user-nick', - }, + content: 'Hey there, test-user-nick', }), headers: expect.any(Object), method: 'PATCH', diff --git a/packages/api/src/routes/interactions/interactions.ts b/packages/api/src/routes/interactions/interactions.ts index 091cce6..0dfd35b 100644 --- a/packages/api/src/routes/interactions/interactions.ts +++ b/packages/api/src/routes/interactions/interactions.ts @@ -61,7 +61,6 @@ 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); diff --git a/packages/api/src/routes/interactions/testHelpers.ts b/packages/api/src/routes/interactions/testHelpers.ts index 79c82af..7ff5788 100644 --- a/packages/api/src/routes/interactions/testHelpers.ts +++ b/packages/api/src/routes/interactions/testHelpers.ts @@ -119,11 +119,7 @@ export const mockUpdateCall = ( AuthType.None, { body: JSON.stringify({ - type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, - data: { - flags: InteractionFlags.EPHEMERAL, - ...data, - }, + ...data, }), headers: { 'Content-Type': 'application/json', diff --git a/terraform/interactions.tf b/terraform/interactions.tf index 7488a20..1d0f034 100644 --- a/terraform/interactions.tf +++ b/terraform/interactions.tf @@ -3,3 +3,39 @@ resource "discord-interactions_guild_command" "hello-world" { description = "Says hello!" guild_id = "386659935687147521" } + +resource "discord-interactions_global_command" "roleypoly" { + name = "roleypoly" + description = "Find out how to use Roleypoly" +} + +resource "discord-interactions_global_command" "pickable-roles" { + name = "pickable-roles" + description = "See the roles you can pick from" +} + +resource "discord-interactions_guild_command" "pick-role" { + name = "pick-role" + description = "Pick a new role (see /pickable-roles for a full list)" + guild_id = "386659935687147521" + + option { + name = "role" + description = "The role you want" + type = 8 + required = true + } +} + +resource "discord-interactions_guild_command" "remove-role" { + name = "remove-role" + description = "Remove a role you already have" + guild_id = "386659935687147521" + + option { + name = "role" + description = "The role you want to remove" + type = 8 + required = true + } +}