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) => {
event.respondWith(router.handle(event.request));
event.respondWith(router.handle(event));
});

View file

@ -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<Response> => {
async (request: Request, tools: HandlerTools): Promise<Response> => {
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 <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 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<Response> => {
async (request: Request, tools: HandlerTools): Promise<Response> => {
const authHeader = request.headers.get('authorization') || '';
if (authHeader !== `Shared ${interactionsSharedKey}`) {
return notAuthenticated();
}
return handler(request);
return handler(request, tools);
};

View file

@ -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}`),
{

View file

@ -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<InteractionResponse>
> = {
const commands: Record<InteractionData['name'], CommandHandler> = {
'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<Response> => {
export const interactionHandler = async (
request: Request,
{ waitUntil }: HandlerTools
): Promise<Response> => {
const interaction = (await request.json()) as InteractionRequest;
if (!verifyRequest(request, interaction)) {
@ -49,7 +48,10 @@ export const interactionHandler = async (request: Request): Promise<Response> =>
}
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);

View file

@ -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<InteractionResponse> => {
if (!interaction.guild_id) {
return mustBeInGuild();
}
export const pickRole = (mode: 'add' | 'remove') =>
asyncResponse(
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
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,
},
};
};
);

View file

@ -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<InteractionResponse> => {
if (!interaction.guild_id) {
return mustBeInGuild();
export const pickableRoles = asyncResponse(
async (interaction: InteractionRequestCommand): Promise<InteractionResponse> => {
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,
},
],
},
],
},
};
};
);

View file

@ -25,5 +25,5 @@ router.addFallback('root', () => {
});
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 { 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<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 {
Bearer = 'Bearer',
Bot = 'Bot',
None = 'None',
}
export const discordFetch = async <T>(
@ -17,7 +18,11 @@ export const discordFetch = async <T>(
...(init || {}),
headers: {
...(init?.headers || {}),
authorization: `${AuthType[authType]} ${auth}`,
...(authType !== AuthType.None
? {
authorization: `${AuthType[authType]} ${auth}`,
}
: {}),
'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 = {
[method: string]: {
@ -40,17 +47,17 @@ export class Router {
this.routingTree[lowerMethod][rootPath] = handler;
}
async handle(request: Request): Promise<Response> {
const response = await this.processRequest(request);
this.injectCORSHeaders(request, response.headers);
async handle(event: FetchEvent): Promise<Response> {
const response = await this.processRequest(event);
this.injectCORSHeaders(event.request, response.headers);
return response;
}
private async processRequest(request: Request): Promise<Response> {
private async processRequest({ request, waitUntil }: FetchEvent): Promise<Response> {
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 {