diff --git a/packages/api/index.ts b/packages/api/index.ts index ef7200a..693acf8 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -66,5 +66,5 @@ router.addFallback('root', () => { }); addEventListener('fetch', (event: FetchEvent) => { - event.respondWith(router.handle(event.request)); + event.respondWith(router.handle(event)); }); diff --git a/packages/api/utils/api-tools.ts b/packages/api/utils/api-tools.ts index f1a6efa..f876eae 100644 --- a/packages/api/utils/api-tools.ts +++ b/packages/api/utils/api-tools.ts @@ -4,7 +4,7 @@ import { permissions as Permissions, } from '@roleypoly/misc-utils/hasPermission'; import { SessionData, UserGuildPermissions } from '@roleypoly/types'; -import { Handler, WrappedKVNamespace } from '@roleypoly/worker-utils'; +import { Handler, HandlerTools, WrappedKVNamespace } from '@roleypoly/worker-utils'; import KSUID from 'ksuid'; import { allowedCallbackHosts, @@ -107,7 +107,7 @@ const NotAuthenticated = (extra?: string) => export const withSession = (wrappedHandler: (session: SessionData) => Handler): Handler => - async (request: Request): Promise => { + async (request: Request, tools: HandlerTools): Promise => { const sessionID = getSessionID(request); if (!sessionID) { return NotAuthenticated('missing authentication'); @@ -118,7 +118,7 @@ export const withSession = return NotAuthenticated('authentication expired or not found'); } - return await wrappedHandler(session)(request); + return await wrappedHandler(session)(request, tools); }; export const setupStateSession = async (data: T): Promise => { @@ -138,9 +138,9 @@ export const getStateSession = async (stateID: string): Promise rootUsers.includes(userID); export const onlyRootUsers = (handler: Handler): Handler => - withSession((session) => (request: Request) => { + withSession((session) => (request: Request, tools: HandlerTools) => { if (isRoot(session.user.id)) { - return handler(request); + return handler(request, tools); } return respond( @@ -166,11 +166,11 @@ export const isAllowedCallbackHost = (host: string): boolean => { export const interactionsEndpoint = (handler: Handler): Handler => - async (request: Request): Promise => { + async (request: Request, tools: HandlerTools): Promise => { const authHeader = request.headers.get('authorization') || ''; if (authHeader !== `Shared ${interactionsSharedKey}`) { return notAuthenticated(); } - return handler(request); + return handler(request, tools); }; diff --git a/packages/backend-emulator/main.js b/packages/backend-emulator/main.js index ebd9613..3444e21 100755 --- a/packages/backend-emulator/main.js +++ b/packages/backend-emulator/main.js @@ -101,6 +101,9 @@ const server = http.createServer((req, res) => { ); isResponseConstructorAllowed = false; }, + waitUntil: async (promise) => { + await promise; + }, request: new fetch.Request( new URL(`http://${req.headers.host || 'localhost'}${req.url}`), { diff --git a/packages/interactions/handlers/interaction.ts b/packages/interactions/handlers/interaction.ts index 70b8ddc..87c976a 100644 --- a/packages/interactions/handlers/interaction.ts +++ b/packages/interactions/handlers/interaction.ts @@ -2,21 +2,17 @@ import { InteractionData, InteractionRequest, InteractionRequestCommand, - InteractionResponse, InteractionType, } from '@roleypoly/types'; -import { respond } from '@roleypoly/worker-utils'; -import { verifyRequest } from '../utils/interactions'; +import { HandlerTools, respond } from '@roleypoly/worker-utils'; +import { CommandHandler, 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'], - (request: InteractionRequestCommand) => Promise -> = { +const commands: Record = { 'hello-world': helloWorld, roleypoly: roleypoly, 'pickable-roles': pickableRoles, @@ -24,7 +20,10 @@ const commands: Record< 'remove-role': pickRole('remove'), }; -export const interactionHandler = async (request: Request): Promise => { +export const interactionHandler = async ( + request: Request, + { waitUntil }: HandlerTools +): Promise => { const interaction = (await request.json()) as InteractionRequest; if (!verifyRequest(request, interaction)) { @@ -49,7 +48,10 @@ export const interactionHandler = async (request: Request): Promise => } try { - const response = await handler(interaction as InteractionRequestCommand); + const response = await handler(interaction as InteractionRequestCommand, { + request, + waitUntil, + }); return respond(response); } catch (e) { console.error(e); diff --git a/packages/interactions/handlers/interactions/pick-role.ts b/packages/interactions/handlers/interactions/pick-role.ts index 2d7aca7..d0ebe8c 100644 --- a/packages/interactions/handlers/interactions/pick-role.ts +++ b/packages/interactions/handlers/interactions/pick-role.ts @@ -1,4 +1,5 @@ import { selectRole } from '@roleypoly/interactions/utils/api'; +import { asyncResponse } from '@roleypoly/interactions/utils/interactions'; import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses'; import { InteractionCallbackType, @@ -7,74 +8,75 @@ import { InteractionResponse, } from '@roleypoly/types'; -export const pickRole = - (mode: 'add' | 'remove') => - async (interaction: InteractionRequestCommand): Promise => { - if (!interaction.guild_id) { - return mustBeInGuild(); - } +export const pickRole = (mode: 'add' | 'remove') => + asyncResponse( + async (interaction: InteractionRequestCommand): Promise => { + if (!interaction.guild_id) { + return mustBeInGuild(); + } - const userID = interaction.member?.user?.id; - if (!userID) { - 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 roleID = interaction.data.options?.find( + (option) => option.name === 'role' + )?.value; + if (!roleID) { + return invalid(); + } - const code = await selectRole(mode, interaction.guild_id, userID, roleID); + 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, + }, + }; + } - if (code === 409) { return { type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data: { - content: `:x: You ${mode === 'add' ? 'already' : "don't"} have that role.`, + content: `:white_check_mark: You ${ + mode === 'add' ? 'got' : 'removed' + } the role: <@&${roleID}>`, 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/pickable-roles.ts b/packages/interactions/handlers/interactions/pickable-roles.ts index fdfa8cf..58d57df 100644 --- a/packages/interactions/handlers/interactions/pickable-roles.ts +++ b/packages/interactions/handlers/interactions/pickable-roles.ts @@ -1,5 +1,6 @@ import { getPickableRoles } from '@roleypoly/interactions/utils/api'; import { uiPublicURI } from '@roleypoly/interactions/utils/config'; +import { asyncResponse } from '@roleypoly/interactions/utils/interactions'; import { mustBeInGuild } from '@roleypoly/interactions/utils/responses'; import { CategoryType, @@ -10,49 +11,49 @@ import { InteractionResponse, } from '@roleypoly/types'; -export const pickableRoles = async ( - interaction: InteractionRequestCommand -): Promise => { - if (!interaction.guild_id) { - return mustBeInGuild(); +export const pickableRoles = asyncResponse( + async (interaction: InteractionRequestCommand): Promise => { + if (!interaction.guild_id) { + return mustBeInGuild(); + } + + const pickableRoles = await getPickableRoles(interaction.guild_id); + const embed: Embed = { + color: 0xab9b9a, + fields: [], + title: 'You can pick any of these roles with /pick-role', + }; + + for (let categoryName in pickableRoles) { + const { roles, type } = pickableRoles[categoryName]; + + embed.fields.push({ + name: `${categoryName}${type === CategoryType.Single ? ' *(pick one)*' : ''}`, + value: roles.map((role) => `<@&${role}>`).join('\n'), + inline: true, + }); + } + + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + embeds: [embed], + flags: InteractionFlags.EPHEMERAL, + components: [ + { + type: 1, + components: [ + // Link to Roleypoly + { + type: 2, + label: 'Pick roles on your browser', + url: `${uiPublicURI}/s/${interaction.guild_id}`, + style: 5, + }, + ], + }, + ], + }, + }; } - - const pickableRoles = await getPickableRoles(interaction.guild_id); - const embed: Embed = { - color: 0xab9b9a, - fields: [], - title: 'You can pick any of these roles with /pick-role', - }; - - for (let categoryName in pickableRoles) { - const { roles, type } = pickableRoles[categoryName]; - - embed.fields.push({ - name: `${categoryName}${type === CategoryType.Single ? ' *(pick one)*' : ''}`, - value: roles.map((role) => `<@&${role}>`).join('\n'), - inline: true, - }); - } - - return { - type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, - data: { - embeds: [embed], - flags: InteractionFlags.EPHEMERAL, - components: [ - { - type: 1, - components: [ - // Link to Roleypoly - { - type: 2, - label: 'Pick roles on your browser', - url: `${uiPublicURI}/s/${interaction.guild_id}`, - style: 5, - }, - ], - }, - ], - }, - }; -}; +); diff --git a/packages/interactions/index.ts b/packages/interactions/index.ts index 2fedfb2..3d77ef7 100644 --- a/packages/interactions/index.ts +++ b/packages/interactions/index.ts @@ -25,5 +25,5 @@ router.addFallback('root', () => { }); addEventListener('fetch', (event: FetchEvent) => { - event.respondWith(router.handle(event.request)); + event.respondWith(router.handle(event)); }); diff --git a/packages/interactions/utils/interactions.ts b/packages/interactions/utils/interactions.ts index ba982d1..c1e5134 100644 --- a/packages/interactions/utils/interactions.ts +++ b/packages/interactions/utils/interactions.ts @@ -1,5 +1,12 @@ import { publicKey } from '@roleypoly/interactions/utils/config'; -import { InteractionRequest } from '@roleypoly/types'; +import { + InteractionCallbackType, + InteractionFlags, + InteractionRequest, + InteractionRequestCommand, + InteractionResponse, +} from '@roleypoly/types'; +import { AuthType, discordFetch, HandlerTools } from '@roleypoly/worker-utils'; import nacl from 'tweetnacl'; export const verifyRequest = ( @@ -25,3 +32,47 @@ export const verifyRequest = ( return true; }; + +export type RequestInfo = HandlerTools & { request: Request }; + +export type CommandHandler = ( + request: InteractionRequestCommand, + requestInfo: RequestInfo +) => Promise; + +export const asyncResponse = + (handler: CommandHandler): CommandHandler => + async ( + command: InteractionRequestCommand, + requestInfo: RequestInfo + ): Promise => { + requestInfo.waitUntil( + (async () => { + const response = await handler(command, requestInfo); + await updateOriginalMessage(command.application_id, command.token, response); + })() + ); + + return { + type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { + flags: InteractionFlags.EPHEMERAL, + }, + }; + }; + +const updateOriginalMessage = async ( + appID: string, + token: string, + response: InteractionResponse +) => { + const url = `/webhooks/${appID}/${token}/messages/@original`; + + return await discordFetch(url, '', AuthType.None, { + method: 'PATCH', + body: JSON.stringify(response.data), + headers: { + 'content-type': 'application/json', + }, + }); +}; diff --git a/packages/worker-utils/discord.ts b/packages/worker-utils/discord.ts index 4fef623..18b1716 100644 --- a/packages/worker-utils/discord.ts +++ b/packages/worker-utils/discord.ts @@ -5,6 +5,7 @@ export const discordAPIBase = 'https://discordapp.com/api/v9'; export enum AuthType { Bearer = 'Bearer', Bot = 'Bot', + None = 'None', } export const discordFetch = async ( @@ -17,7 +18,11 @@ export const discordFetch = async ( ...(init || {}), headers: { ...(init?.headers || {}), - authorization: `${AuthType[authType]} ${auth}`, + ...(authType !== AuthType.None + ? { + authorization: `${AuthType[authType]} ${auth}`, + } + : {}), 'user-agent': userAgent, }, }); diff --git a/packages/worker-utils/router.ts b/packages/worker-utils/router.ts index 8e841ad..2ca16d3 100644 --- a/packages/worker-utils/router.ts +++ b/packages/worker-utils/router.ts @@ -1,4 +1,11 @@ -export type Handler = (request: Request) => Promise | Response; +export type Handler = ( + request: Request, + tools: HandlerTools +) => Promise | Response; + +export type HandlerTools = { + waitUntil: FetchEvent['waitUntil']; +}; type RoutingTree = { [method: string]: { @@ -40,17 +47,17 @@ export class Router { this.routingTree[lowerMethod][rootPath] = handler; } - async handle(request: Request): Promise { - const response = await this.processRequest(request); - this.injectCORSHeaders(request, response.headers); + async handle(event: FetchEvent): Promise { + const response = await this.processRequest(event); + this.injectCORSHeaders(event.request, response.headers); return response; } - private async processRequest(request: Request): Promise { + private async processRequest({ request, waitUntil }: FetchEvent): Promise { const url = new URL(request.url); if (url.pathname === '/' || url.pathname === '') { - return this.fallbacks.root(request); + return this.fallbacks.root(request, { waitUntil }); } const lowerMethod = request.method.toLowerCase(); const rootPath = url.pathname.split('/')[1]; @@ -58,11 +65,11 @@ export class Router { if (handler) { try { - const response = await handler(request); + const response = await handler(request, { waitUntil }); return response; } catch (e) { console.error(e); - return this.fallbacks[500](request); + return this.fallbacks[500](request, { waitUntil }); } } @@ -70,7 +77,7 @@ export class Router { return new Response(null, {}); } - return this.fallbacks[404](request); + return this.fallbacks[404](request, { waitUntil }); } private respondToRoot(): Response { diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index 5332251..fdf81bb 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -2,23 +2,23 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { - version = "2.24.0" + version = "2.25.0" constraints = ">= 2.23.0" hashes = [ - "h1:+fGNZaqk0IPH3M5yOsu978u5t9Q5YP1PrGXSggJUlFQ=", - "zh:10bb13bff60c8c9e234b64ea3d8c37be512459f40fdd97aafc5d60631377b46e", - "zh:1ba01e5636fe79c205908e55a966cb6249f66a657aca62ea040b5b41717a1763", - "zh:1f5870e2602ebaeca40f048c1466e976ac0db66e41297b327ac816001c4090d5", - "zh:203f03b9aa58e9a7516f09f13cff08c00e8e534921ac597cf05634e793f6c9fe", - "zh:2cae731aeee1c511ba26aa64ecb4537931f5ab467e4bc8e07bbbdf82fe11e6c0", - "zh:89f0eb8df82407fb48add3fe4dd38817e4625f5986a69259535bdf5b6ac6d281", - "zh:952c5a213acdace04f86ca4a79a99f476b7da6f69edd0e616e47fb75aa3b77f9", - "zh:958d08bc7a3ca6275106db0d4251a19fa8a5ad0302652439b3c2cc57a80fed74", - "zh:b7797b2fa0377a5c2610d42bf9a1306c1dec4895d5d52e8f7e50340d072d3065", - "zh:c95a1680531f3c1640d7869d69a4abbc184f36a060462920acc756b6ac6c91d9", - "zh:ca7e8438967d31afb8a73473fff237dcddcefc5e5ca3a3159ab941df1e683de1", - "zh:ddfae3c9305aa7299744992e70b61e4919cbe44a4ca561161f77e011a77a0233", - "zh:e85b3814322e1f0a73718fec46bbf3a3d0bda3ea86cc6a875b4b584517558051", + "h1:raEhY+rMHKBNMwXBWYZp4DUua3JNDq5frLu6VOl71Zg=", + "zh:0c2e4665615fa732cd7a025599afa788cf980d1e3e89b20127f8dd5198a3e124", + "zh:1d09a74f6ee533ae9db4064a9a6ec466ca7e7f53ed2033f95ce66ff066becc9e", + "zh:21f9c1b5d30dc691909b4d1f4635171dc53009109cd7b15ac3ee9c23d84dcc10", + "zh:332cbf7da45231fda97d4ca9d840d70558055b45fb63247f57717572c09edde4", + "zh:4056c8fd7b6ca9f2b96a583ee11e3ee5e11ec0706a4f5fa1816f2bacda359a31", + "zh:5a6e134331acc5c9833993f98cbe48e05c995b8d09ac5d4a11fe4c1488fa34ed", + "zh:71c30ce22b906f9130b7d1afe6ca5be281f7dda1b46c6565b9acd22ca3e30fcb", + "zh:83e53ed8489a8c65d8e39e43ba5c8298b19eba0dfa30c0d2f99d69e81e6516af", + "zh:89900baa4735eb9c678815689979765653ff639a528ac0bbe3fceee50028bea8", + "zh:8ba0ea263f0e04589ec95de2381913e6a3b71d7b67c2e2ddbdd78a023ce5949f", + "zh:a98c952cda50a7286e8286697b195d1dc8c016090023b4b8cd6f772734b7fd71", + "zh:c670798e60fd4807524bf7d407ad284c5674b32f42e56005f63cd356233a2310", + "zh:dadd6787f390379f7f231e1176d24ff7841ac9c6b1e742c6bf506309492f89e1", ] }