feat: add /pickable-roles and /pick-role basis

This commit is contained in:
41666 2021-08-01 18:57:57 -04:00
parent c2ee4f380a
commit a4207d5713
22 changed files with 343 additions and 23 deletions

View file

@ -49,7 +49,7 @@ jobs:
selected="${targetArtifact:-$currentHash}"
mkdir worker-dist
gsutil cp gs://roleypoly-artifacts/workers/$selected worker-dist
gsutil cp -r "gs://roleypoly-artifacts/workers/$selected/*" worker-dist/
- name: Terraform init
working-directory: ./terraform
@ -60,7 +60,7 @@ jobs:
working-directory: ./terraform
run: |
echo \
'{"bot_tag": "${{github.event.inputs.bot_tag}}", "api_path_to_worker": "./worker-dist/backend-worker.js"}' \
'{"bot_tag": "${{github.event.inputs.bot_tag}}", "worker_tag": "${{github.event.inputs.worker_tag}}", "api_path_to_worker": "./worker-dist/api.js", "interactions_path_to_worker": "./worker-dist/interactions.js"}' \
| jq . \
| tee tags.auto.tfvars.json

View file

@ -0,0 +1,23 @@
import { interactionsEndpoint } from '../utils/api-tools';
import { getGuildData } from '../utils/guild';
import { invalid, notFound, ok } from '../utils/responses';
export const InteractionsPickRole = interactionsEndpoint(
async (request: Request): Promise<Response> => {
const reqURL = new URL(request.url);
const [, , serverID, userID, roleID] = reqURL.pathname.split('/');
if (!serverID || !userID || !roleID) {
return invalid();
}
const guildData = await getGuildData(serverID);
if (!guildData) {
return notFound();
}
// We get exactly one role, but we have to interact with it the same way as UI does.
// So check for safety, disable any "single" mode roles
return ok();
}
);

View file

@ -0,0 +1,33 @@
import { Category, CategorySlug } from '@roleypoly/types';
import { respond } from '@roleypoly/worker-utils';
import { interactionsEndpoint } from '../utils/api-tools';
import { getGuildData } from '../utils/guild';
import { notFound } from '../utils/responses';
export const InteractionsPickableRoles = interactionsEndpoint(
async (request: Request): Promise<Response> => {
const reqURL = new URL(request.url);
const [, , serverID] = reqURL.pathname.split('/');
const guildData = await getGuildData(serverID);
if (!guildData) {
return notFound();
}
const roleMap: Record<Category['name'], CategorySlug> = {};
for (let category of guildData.categories) {
if (category.hidden) {
continue;
}
// TODO: role safety?
roleMap[category.name] = {
roles: category.roles,
type: category.type,
};
}
return respond(roleMap);
}
);

View file

@ -1,3 +1,5 @@
import { InteractionsPickRole } from '@roleypoly/api/handlers/interactions-pick-role';
import { InteractionsPickableRoles } from '@roleypoly/api/handlers/interactions-pickable-roles';
import { Router } from '@roleypoly/worker-utils/router';
import { BotJoin } from './handlers/bot-join';
import { ClearGuildCache } from './handlers/clear-guild-cache';
@ -32,6 +34,10 @@ router.add('PATCH', 'update-guild', UpdateGuild);
router.add('POST', 'sync-from-legacy', SyncFromLegacy);
router.add('POST', 'clear-guild-cache', ClearGuildCache);
// Interactions endpoints
router.add('GET', 'interactions-pickable-roles', InteractionsPickableRoles);
router.add('POST', 'interactions-pick-role', InteractionsPickRole);
// Tester Routes
router.add('GET', 'x-headers', (request) => {
const headers: { [x: string]: string } = {};

View file

@ -1,3 +1,4 @@
import { notAuthenticated } from '@roleypoly/api/utils/responses';
import {
evaluatePermission,
permissions as Permissions,
@ -5,7 +6,12 @@ import {
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
import { Handler, WrappedKVNamespace } from '@roleypoly/worker-utils';
import KSUID from 'ksuid';
import { allowedCallbackHosts, apiPublicURI, rootUsers } from './config';
import {
allowedCallbackHosts,
apiPublicURI,
interactionsSharedKey,
rootUsers,
} from './config';
import { Sessions } from './kv';
export const formData = (obj: Record<string, any>): string => {
@ -157,3 +163,14 @@ export const isAllowedCallbackHost = (host: string): boolean => {
null
);
};
export const interactionsEndpoint =
(handler: Handler): Handler =>
async (request: Request): Promise<Response> => {
const authHeader = request.headers.get('authorization') || '';
if (authHeader !== `Shared ${interactionsSharedKey}`) {
return notAuthenticated();
}
return handler(request);
};

View file

@ -2,6 +2,7 @@ import { uiPublicURI } from '@roleypoly/api/utils/config';
import {
Category,
DiscordUser,
Embed,
GuildData,
GuildDataUpdate,
GuildSlug,
@ -11,25 +12,10 @@ import { userAgent } from '@roleypoly/worker-utils';
import deepEqual from 'deep-equal';
import { sortBy, uniq } from 'lodash';
type WebhookEmbed = {
fields: {
name: string;
value: string;
inline: boolean;
}[];
timestamp: string;
title: string;
color: number;
author?: {
name: string;
icon_url: string;
};
};
type WebhookPayload = {
username: string;
avatar_url: string;
embeds: WebhookEmbed[];
embeds: Embed[];
provider: {
name: string;
url: string;
@ -39,7 +25,7 @@ type WebhookPayload = {
type ChangeHandler = (
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
newValue: GuildData[keyof GuildDataUpdate]
) => WebhookEmbed[];
) => Embed[];
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
message: (oldValue, newValue) => [

View file

@ -13,3 +13,4 @@ export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
export const rootUsers = list(env('ROOT_USERS'));
export const allowedCallbackHosts = list(env('ALLOWED_CALLBACK_HOSTS'));
export const importSharedKey = env('BOT_IMPORT_TOKEN');
export const interactionsSharedKey = env('INTERACTIONS_SHARED_KEY');

View file

@ -20,3 +20,6 @@ export const rateLimited = () =>
export const invalid = (obj: any = {}) =>
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
export const notAuthenticated = () =>
respond({ err: 'not authenticated' }, { status: 403 });

View file

@ -12,6 +12,7 @@ module.exports = {
'API_PUBLIC_URI',
'ROOT_USERS',
'ALLOWED_CALLBACK_HOSTS',
'INTERACTIONS_SHARED_KEY',
]),
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
};

View file

@ -1,5 +1,8 @@
import { helloWorld } from '@roleypoly/interactions/handlers/interactions/hello-world';
import { pickableRoles } from '@roleypoly/interactions/handlers/interactions/pickable-roles';
import { roleypoly } from '@roleypoly/interactions/handlers/interactions/roleypoly';
import { verifyRequest } from '@roleypoly/interactions/utils/interactions';
import { somethingWentWrong } from '@roleypoly/interactions/utils/responses';
import {
InteractionData,
InteractionRequest,
@ -14,6 +17,8 @@ const commands: Record<
(request: InteractionRequestCommand) => Promise<InteractionResponse>
> = {
'hello-world': helloWorld,
roleypoly: roleypoly,
'pickable-roles': pickableRoles,
};
export const interactionHandler = async (request: Request): Promise<Response> => {
@ -44,6 +49,7 @@ export const interactionHandler = async (request: Request): Promise<Response> =>
const response = await handler(interaction as InteractionRequestCommand);
return respond(response);
} catch (e) {
return respond({ err: 'command errored' }, { status: 500 });
console.error(e);
return respond(somethingWentWrong());
}
};

View file

@ -0,0 +1,16 @@
import {
InteractionCallbackType,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const helloWorld = async (
interaction: InteractionRequestCommand
): Promise<InteractionResponse> => {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`,
},
};
};

View file

@ -0,0 +1,58 @@
import { getPickableRoles } from '@roleypoly/interactions/utils/api';
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
import {
CategoryType,
Embed,
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const pickableRoles = 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,
},
],
},
],
},
};
};

View file

@ -0,0 +1,28 @@
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
import {
InteractionCallbackType,
InteractionFlags,
InteractionRequestCommand,
InteractionResponse,
} from '@roleypoly/types';
export const roleypoly = async (
interaction: InteractionRequestCommand
): Promise<InteractionResponse> => {
if (interaction.guild_id) {
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:beginner: Assign your roles here! ${uiPublicURI}/s/${interaction.guild_id}`,
flags: InteractionFlags.EPHEMERAL,
},
};
}
return {
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `:beginner: Hey! I don't know what server you're in, so check out ${uiPublicURI}`,
},
};
};

View file

@ -0,0 +1,25 @@
import { Category, CategorySlug } from '@roleypoly/types';
import { apiPublicURI, interactionsSharedKey } from './config';
export const apiFetch = (url: string, init: RequestInit = {}) =>
fetch(`${apiPublicURI}${url}`, {
...init,
headers: {
...(init.headers || {}),
authorization: `Shared ${interactionsSharedKey}`,
},
});
export const getPickableRoles = async (
guildID: string
): Promise<Record<Category['name'], CategorySlug>> => {
const response = await apiFetch(`/interactions-pickable-roles/${guildID}`);
if (response.status !== 200) {
throw new Error(
`API request failed to /interactions-pickable-roles, got code: ${response.status}`
);
}
return (await response.json()) as Record<Category['name'], CategorySlug>;
};

View file

@ -8,3 +8,4 @@ const list = (x: string) => x.split(',');
export const uiPublicURI = safeURI(env('UI_PUBLIC_URI'));
export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
export const publicKey = safeURI(env('DISCORD_PUBLIC_KEY'));
export const interactionsSharedKey = env('INTERACTIONS_SHARED_KEY');

View file

@ -0,0 +1,21 @@
import {
InteractionCallbackType,
InteractionFlags,
InteractionResponse,
} from '@roleypoly/types';
export const mustBeInGuild = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: ':x: This command has to be used in a server.',
flags: InteractionFlags.EPHEMERAL,
},
});
export const somethingWentWrong = (): InteractionResponse => ({
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
flags: InteractionFlags.EPHEMERAL,
},
});

View file

@ -3,5 +3,10 @@ const reexportEnv = (keys = []) => {
};
module.exports = {
environment: reexportEnv(['DISCORD_PUBLIC_KEY', 'UI_PUBLIC_URI', 'API_PUBLIC_URI']),
environment: reexportEnv([
'DISCORD_PUBLIC_KEY',
'UI_PUBLIC_URI',
'API_PUBLIC_URI',
'INTERACTIONS_SHARED_KEY',
]),
};

View file

@ -11,3 +11,8 @@ export type Category = {
type: CategoryType;
position: number;
};
export type CategorySlug = {
roles: Category['roles'];
type: Category['type'];
};

View file

@ -41,12 +41,16 @@ export enum InteractionCallbackType {
UPDATE_MESSAGE = 7,
}
export enum InteractionFlags {
EPHEMERAL = 1 << 6,
}
export type InteractionCallbackData = {
tts?: boolean;
content?: string;
embeds?: {};
allowed_mentions?: {};
flags?: number;
flags?: InteractionFlags;
components?: {}[];
};
@ -54,3 +58,18 @@ export type InteractionResponse = {
type: InteractionCallbackType;
data?: InteractionCallbackData;
};
export type Embed = {
fields: {
name: string;
value: string;
inline?: boolean;
}[];
timestamp?: string;
title: string;
color: number;
author?: {
name: string;
icon_url: string;
};
};

View file

@ -68,6 +68,11 @@ resource "cloudflare_worker_script" "backend" {
name = "ROOT_USERS"
text = join(",", var.root_users)
}
secret_text_binding {
name = "INTERACTIONS_SHARED_KEY"
text = random_password.interactions_token.result
}
}
resource "cloudflare_record" "api" {

View file

@ -4,6 +4,13 @@ locals {
])
}
resource "random_password" "interactions_token" {
length = 64
keepers = {
"worker_tag" = var.worker_tag
}
}
resource "discord-interactions_guild_command" "hello-world" {
for_each = local.internalTestingGuilds
guild_id = each.value
@ -12,6 +19,49 @@ resource "discord-interactions_guild_command" "hello-world" {
description = "Say hello!"
}
resource "discord-interactions_global_command" "roleypoly" {
name = "roleypoly"
description = "Sends you a link to pick your roles in your browser"
}
resource "discord-interactions_global_command" "pick-role" {
name = "pick-role"
description = "Pick a role! (See which ones can be picked with /pickable-roles)"
option {
name = "role"
description = "The role you want"
type = 8
}
}
resource "discord-interactions_guild_command" "pick-role" {
for_each = local.internalTestingGuilds
guild_id = each.value
name = "pick-role"
description = "Pick a role! (See which ones can be picked with /pickable-roles)"
option {
name = "role"
description = "The role you want"
type = 8
}
}
resource "discord-interactions_global_command" "pickable-roles" {
name = "pickable-roles"
description = "See all the roles you can pick with /pick-roles"
}
resource "discord-interactions_guild_command" "pickable-roles" {
for_each = local.internalTestingGuilds
guild_id = each.value
name = "pickable-roles"
description = "See all the roles you can pick with /pick-roles"
}
resource "cloudflare_worker_script" "interactions" {
name = "roleypoly-interactions-${var.environment_tag}"
content = file("${path.module}/${var.interactions_path_to_worker}")
@ -21,6 +71,11 @@ resource "cloudflare_worker_script" "interactions" {
text = var.discord_public_key
}
secret_text_binding {
name = "INTERACTIONS_SHARED_KEY"
text = random_password.interactions_token.result
}
plain_text_binding {
name = "UI_PUBLIC_URI"
text = var.ui_public_uri

View file

@ -92,6 +92,12 @@ variable "bot_tag" {
description = ":tag or @sha265: of ghcr.io/roleypoly/bot"
}
variable "worker_tag" {
type = string
default = ""
description = "Usually the commit hash, this invalidates some secrets that can always be rotated"
}
variable "allowed_callback_hosts" {
type = string
default = ""