fix(interactions): add async responses

This commit is contained in:
41666 2021-08-07 18:00:20 -04:00
parent 3601d435b2
commit 26bc74bcbc
11 changed files with 220 additions and 149 deletions

View file

@ -66,5 +66,5 @@ router.addFallback('root', () => {
}); });
addEventListener('fetch', (event: FetchEvent) => { addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(router.handle(event.request)); event.respondWith(router.handle(event));
}); });

View file

@ -4,7 +4,7 @@ import {
permissions as Permissions, permissions as Permissions,
} from '@roleypoly/misc-utils/hasPermission'; } from '@roleypoly/misc-utils/hasPermission';
import { SessionData, UserGuildPermissions } from '@roleypoly/types'; 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 KSUID from 'ksuid';
import { import {
allowedCallbackHosts, allowedCallbackHosts,
@ -107,7 +107,7 @@ const NotAuthenticated = (extra?: string) =>
export const withSession = export const withSession =
(wrappedHandler: (session: SessionData) => Handler): Handler => (wrappedHandler: (session: SessionData) => Handler): Handler =>
async (request: Request): Promise<Response> => { async (request: Request, tools: HandlerTools): Promise<Response> => {
const sessionID = getSessionID(request); const sessionID = getSessionID(request);
if (!sessionID) { if (!sessionID) {
return NotAuthenticated('missing authentication'); return NotAuthenticated('missing authentication');
@ -118,7 +118,7 @@ export const withSession =
return NotAuthenticated('authentication expired or not found'); return NotAuthenticated('authentication expired or not found');
} }
return await wrappedHandler(session)(request); return await wrappedHandler(session)(request, tools);
}; };
export const setupStateSession = async <T>(data: T): Promise<string> => { export const setupStateSession = async <T>(data: T): Promise<string> => {
@ -138,9 +138,9 @@ export const getStateSession = async <T>(stateID: string): Promise<T | undefined
export const isRoot = (userID: string): boolean => rootUsers.includes(userID); export const isRoot = (userID: string): boolean => rootUsers.includes(userID);
export const onlyRootUsers = (handler: Handler): Handler => export const onlyRootUsers = (handler: Handler): Handler =>
withSession((session) => (request: Request) => { withSession((session) => (request: Request, tools: HandlerTools) => {
if (isRoot(session.user.id)) { if (isRoot(session.user.id)) {
return handler(request); return handler(request, tools);
} }
return respond( return respond(
@ -166,11 +166,11 @@ export const isAllowedCallbackHost = (host: string): boolean => {
export const interactionsEndpoint = export const interactionsEndpoint =
(handler: Handler): Handler => (handler: Handler): Handler =>
async (request: Request): Promise<Response> => { async (request: Request, tools: HandlerTools): Promise<Response> => {
const authHeader = request.headers.get('authorization') || ''; const authHeader = request.headers.get('authorization') || '';
if (authHeader !== `Shared ${interactionsSharedKey}`) { if (authHeader !== `Shared ${interactionsSharedKey}`) {
return notAuthenticated(); return notAuthenticated();
} }
return handler(request); return handler(request, tools);
}; };

View file

@ -101,6 +101,9 @@ const server = http.createServer((req, res) => {
); );
isResponseConstructorAllowed = false; isResponseConstructorAllowed = false;
}, },
waitUntil: async (promise) => {
await promise;
},
request: new fetch.Request( request: new fetch.Request(
new URL(`http://${req.headers.host || 'localhost'}${req.url}`), new URL(`http://${req.headers.host || 'localhost'}${req.url}`),
{ {

View file

@ -2,21 +2,17 @@ import {
InteractionData, InteractionData,
InteractionRequest, InteractionRequest,
InteractionRequestCommand, InteractionRequestCommand,
InteractionResponse,
InteractionType, InteractionType,
} from '@roleypoly/types'; } from '@roleypoly/types';
import { respond } from '@roleypoly/worker-utils'; import { HandlerTools, respond } from '@roleypoly/worker-utils';
import { verifyRequest } from '../utils/interactions'; import { CommandHandler, verifyRequest } from '../utils/interactions';
import { somethingWentWrong } from '../utils/responses'; import { somethingWentWrong } from '../utils/responses';
import { helloWorld } from './interactions/hello-world'; import { helloWorld } from './interactions/hello-world';
import { pickRole } from './interactions/pick-role'; import { pickRole } from './interactions/pick-role';
import { pickableRoles } from './interactions/pickable-roles'; import { pickableRoles } from './interactions/pickable-roles';
import { roleypoly } from './interactions/roleypoly'; import { roleypoly } from './interactions/roleypoly';
const commands: Record< const commands: Record<InteractionData['name'], CommandHandler> = {
InteractionData['name'],
(request: InteractionRequestCommand) => Promise<InteractionResponse>
> = {
'hello-world': helloWorld, 'hello-world': helloWorld,
roleypoly: roleypoly, roleypoly: roleypoly,
'pickable-roles': pickableRoles, 'pickable-roles': pickableRoles,
@ -24,7 +20,10 @@ const commands: Record<
'remove-role': pickRole('remove'), 'remove-role': pickRole('remove'),
}; };
export const interactionHandler = async (request: Request): Promise<Response> => { export const interactionHandler = async (
request: Request,
{ waitUntil }: HandlerTools
): Promise<Response> => {
const interaction = (await request.json()) as InteractionRequest; const interaction = (await request.json()) as InteractionRequest;
if (!verifyRequest(request, interaction)) { if (!verifyRequest(request, interaction)) {
@ -49,7 +48,10 @@ export const interactionHandler = async (request: Request): Promise<Response> =>
} }
try { try {
const response = await handler(interaction as InteractionRequestCommand); const response = await handler(interaction as InteractionRequestCommand, {
request,
waitUntil,
});
return respond(response); return respond(response);
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View file

@ -1,4 +1,5 @@
import { selectRole } from '@roleypoly/interactions/utils/api'; import { selectRole } from '@roleypoly/interactions/utils/api';
import { asyncResponse } from '@roleypoly/interactions/utils/interactions';
import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses'; import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses';
import { import {
InteractionCallbackType, InteractionCallbackType,
@ -7,8 +8,8 @@ import {
InteractionResponse, InteractionResponse,
} from '@roleypoly/types'; } from '@roleypoly/types';
export const pickRole = export const pickRole = (mode: 'add' | 'remove') =>
(mode: 'add' | 'remove') => asyncResponse(
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => { async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
if (!interaction.guild_id) { if (!interaction.guild_id) {
return mustBeInGuild(); return mustBeInGuild();
@ -77,4 +78,5 @@ export const pickRole =
flags: InteractionFlags.EPHEMERAL, flags: InteractionFlags.EPHEMERAL,
}, },
}; };
}; }
);

View file

@ -1,5 +1,6 @@
import { getPickableRoles } from '@roleypoly/interactions/utils/api'; import { getPickableRoles } from '@roleypoly/interactions/utils/api';
import { uiPublicURI } from '@roleypoly/interactions/utils/config'; import { uiPublicURI } from '@roleypoly/interactions/utils/config';
import { asyncResponse } from '@roleypoly/interactions/utils/interactions';
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses'; import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
import { import {
CategoryType, CategoryType,
@ -10,9 +11,8 @@ import {
InteractionResponse, InteractionResponse,
} from '@roleypoly/types'; } from '@roleypoly/types';
export const pickableRoles = async ( export const pickableRoles = asyncResponse(
interaction: InteractionRequestCommand async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
): Promise<InteractionResponse> => {
if (!interaction.guild_id) { if (!interaction.guild_id) {
return mustBeInGuild(); return mustBeInGuild();
} }
@ -55,4 +55,5 @@ export const pickableRoles = async (
], ],
}, },
}; };
}; }
);

View file

@ -25,5 +25,5 @@ router.addFallback('root', () => {
}); });
addEventListener('fetch', (event: FetchEvent) => { addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(router.handle(event.request)); event.respondWith(router.handle(event));
}); });

View file

@ -1,5 +1,12 @@
import { publicKey } from '@roleypoly/interactions/utils/config'; 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'; import nacl from 'tweetnacl';
export const verifyRequest = ( export const verifyRequest = (
@ -25,3 +32,47 @@ export const verifyRequest = (
return true; return true;
}; };
export type RequestInfo = HandlerTools & { request: Request };
export type CommandHandler = (
request: InteractionRequestCommand,
requestInfo: RequestInfo
) => Promise<InteractionResponse>;
export const asyncResponse =
(handler: CommandHandler): CommandHandler =>
async (
command: InteractionRequestCommand,
requestInfo: RequestInfo
): Promise<InteractionResponse> => {
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',
},
});
};

View file

@ -5,6 +5,7 @@ export const discordAPIBase = 'https://discordapp.com/api/v9';
export enum AuthType { export enum AuthType {
Bearer = 'Bearer', Bearer = 'Bearer',
Bot = 'Bot', Bot = 'Bot',
None = 'None',
} }
export const discordFetch = async <T>( export const discordFetch = async <T>(
@ -17,7 +18,11 @@ export const discordFetch = async <T>(
...(init || {}), ...(init || {}),
headers: { headers: {
...(init?.headers || {}), ...(init?.headers || {}),
...(authType !== AuthType.None
? {
authorization: `${AuthType[authType]} ${auth}`, authorization: `${AuthType[authType]} ${auth}`,
}
: {}),
'user-agent': userAgent, 'user-agent': userAgent,
}, },
}); });

View file

@ -1,4 +1,11 @@
export type Handler = (request: Request) => Promise<Response> | Response; export type Handler = (
request: Request,
tools: HandlerTools
) => Promise<Response> | Response;
export type HandlerTools = {
waitUntil: FetchEvent['waitUntil'];
};
type RoutingTree = { type RoutingTree = {
[method: string]: { [method: string]: {
@ -40,17 +47,17 @@ export class Router {
this.routingTree[lowerMethod][rootPath] = handler; this.routingTree[lowerMethod][rootPath] = handler;
} }
async handle(request: Request): Promise<Response> { async handle(event: FetchEvent): Promise<Response> {
const response = await this.processRequest(request); const response = await this.processRequest(event);
this.injectCORSHeaders(request, response.headers); this.injectCORSHeaders(event.request, response.headers);
return response; return response;
} }
private async processRequest(request: Request): Promise<Response> { private async processRequest({ request, waitUntil }: FetchEvent): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === '/' || url.pathname === '') { if (url.pathname === '/' || url.pathname === '') {
return this.fallbacks.root(request); return this.fallbacks.root(request, { waitUntil });
} }
const lowerMethod = request.method.toLowerCase(); const lowerMethod = request.method.toLowerCase();
const rootPath = url.pathname.split('/')[1]; const rootPath = url.pathname.split('/')[1];
@ -58,11 +65,11 @@ export class Router {
if (handler) { if (handler) {
try { try {
const response = await handler(request); const response = await handler(request, { waitUntil });
return response; return response;
} catch (e) { } catch (e) {
console.error(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 new Response(null, {});
} }
return this.fallbacks[404](request); return this.fallbacks[404](request, { waitUntil });
} }
private respondToRoot(): Response { private respondToRoot(): Response {

View file

@ -2,23 +2,23 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.terraform.io/cloudflare/cloudflare" { provider "registry.terraform.io/cloudflare/cloudflare" {
version = "2.24.0" version = "2.25.0"
constraints = ">= 2.23.0" constraints = ">= 2.23.0"
hashes = [ hashes = [
"h1:+fGNZaqk0IPH3M5yOsu978u5t9Q5YP1PrGXSggJUlFQ=", "h1:raEhY+rMHKBNMwXBWYZp4DUua3JNDq5frLu6VOl71Zg=",
"zh:10bb13bff60c8c9e234b64ea3d8c37be512459f40fdd97aafc5d60631377b46e", "zh:0c2e4665615fa732cd7a025599afa788cf980d1e3e89b20127f8dd5198a3e124",
"zh:1ba01e5636fe79c205908e55a966cb6249f66a657aca62ea040b5b41717a1763", "zh:1d09a74f6ee533ae9db4064a9a6ec466ca7e7f53ed2033f95ce66ff066becc9e",
"zh:1f5870e2602ebaeca40f048c1466e976ac0db66e41297b327ac816001c4090d5", "zh:21f9c1b5d30dc691909b4d1f4635171dc53009109cd7b15ac3ee9c23d84dcc10",
"zh:203f03b9aa58e9a7516f09f13cff08c00e8e534921ac597cf05634e793f6c9fe", "zh:332cbf7da45231fda97d4ca9d840d70558055b45fb63247f57717572c09edde4",
"zh:2cae731aeee1c511ba26aa64ecb4537931f5ab467e4bc8e07bbbdf82fe11e6c0", "zh:4056c8fd7b6ca9f2b96a583ee11e3ee5e11ec0706a4f5fa1816f2bacda359a31",
"zh:89f0eb8df82407fb48add3fe4dd38817e4625f5986a69259535bdf5b6ac6d281", "zh:5a6e134331acc5c9833993f98cbe48e05c995b8d09ac5d4a11fe4c1488fa34ed",
"zh:952c5a213acdace04f86ca4a79a99f476b7da6f69edd0e616e47fb75aa3b77f9", "zh:71c30ce22b906f9130b7d1afe6ca5be281f7dda1b46c6565b9acd22ca3e30fcb",
"zh:958d08bc7a3ca6275106db0d4251a19fa8a5ad0302652439b3c2cc57a80fed74", "zh:83e53ed8489a8c65d8e39e43ba5c8298b19eba0dfa30c0d2f99d69e81e6516af",
"zh:b7797b2fa0377a5c2610d42bf9a1306c1dec4895d5d52e8f7e50340d072d3065", "zh:89900baa4735eb9c678815689979765653ff639a528ac0bbe3fceee50028bea8",
"zh:c95a1680531f3c1640d7869d69a4abbc184f36a060462920acc756b6ac6c91d9", "zh:8ba0ea263f0e04589ec95de2381913e6a3b71d7b67c2e2ddbdd78a023ce5949f",
"zh:ca7e8438967d31afb8a73473fff237dcddcefc5e5ca3a3159ab941df1e683de1", "zh:a98c952cda50a7286e8286697b195d1dc8c016090023b4b8cd6f772734b7fd71",
"zh:ddfae3c9305aa7299744992e70b61e4919cbe44a4ca561161f77e011a77a0233", "zh:c670798e60fd4807524bf7d407ad284c5674b32f42e56005f63cd356233a2310",
"zh:e85b3814322e1f0a73718fec46bbf3a3d0bda3ea86cc6a875b4b584517558051", "zh:dadd6787f390379f7f231e1176d24ff7841ac9c6b1e742c6bf506309492f89e1",
] ]
} }