diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8c089d6..512d8f9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/packages/api/handlers/interactions-pick-role.ts b/packages/api/handlers/interactions-pick-role.ts new file mode 100644 index 0000000..5eda7e5 --- /dev/null +++ b/packages/api/handlers/interactions-pick-role.ts @@ -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 => { + 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(); + } +); diff --git a/packages/api/handlers/interactions-pickable-roles.ts b/packages/api/handlers/interactions-pickable-roles.ts new file mode 100644 index 0000000..6f2027c --- /dev/null +++ b/packages/api/handlers/interactions-pickable-roles.ts @@ -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 => { + const reqURL = new URL(request.url); + const [, , serverID] = reqURL.pathname.split('/'); + + const guildData = await getGuildData(serverID); + if (!guildData) { + return notFound(); + } + + const roleMap: Record = {}; + + 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); + } +); diff --git a/packages/api/index.ts b/packages/api/index.ts index e5bd0bf..1c4cf11 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -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 } = {}; diff --git a/packages/api/utils/api-tools.ts b/packages/api/utils/api-tools.ts index 4d00ff2..f1a6efa 100644 --- a/packages/api/utils/api-tools.ts +++ b/packages/api/utils/api-tools.ts @@ -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 => { @@ -157,3 +163,14 @@ export const isAllowedCallbackHost = (host: string): boolean => { null ); }; + +export const interactionsEndpoint = + (handler: Handler): Handler => + async (request: Request): Promise => { + const authHeader = request.headers.get('authorization') || ''; + if (authHeader !== `Shared ${interactionsSharedKey}`) { + return notAuthenticated(); + } + + return handler(request); + }; diff --git a/packages/api/utils/audit-log.ts b/packages/api/utils/audit-log.ts index 90a5a14..91c0d7a 100644 --- a/packages/api/utils/audit-log.ts +++ b/packages/api/utils/audit-log.ts @@ -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 = { message: (oldValue, newValue) => [ diff --git a/packages/api/utils/config.ts b/packages/api/utils/config.ts index 935bebd..57ba3e5 100644 --- a/packages/api/utils/config.ts +++ b/packages/api/utils/config.ts @@ -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'); diff --git a/packages/api/utils/responses.ts b/packages/api/utils/responses.ts index 72bb764..6ad2fd5 100644 --- a/packages/api/utils/responses.ts +++ b/packages/api/utils/responses.ts @@ -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 }); diff --git a/packages/api/worker.config.js b/packages/api/worker.config.js index b73f304..ec8e41f 100644 --- a/packages/api/worker.config.js +++ b/packages/api/worker.config.js @@ -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'], }; diff --git a/packages/interactions/handlers/interaction.ts b/packages/interactions/handlers/interaction.ts index 70da95b..9daeef5 100644 --- a/packages/interactions/handlers/interaction.ts +++ b/packages/interactions/handlers/interaction.ts @@ -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 > = { 'hello-world': helloWorld, + roleypoly: roleypoly, + 'pickable-roles': pickableRoles, }; export const interactionHandler = async (request: Request): Promise => { @@ -44,6 +49,7 @@ export const interactionHandler = async (request: Request): Promise => 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()); } }; diff --git a/packages/interactions/handlers/interactions/pick-role.ts b/packages/interactions/handlers/interactions/pick-role.ts new file mode 100644 index 0000000..990ffe7 --- /dev/null +++ b/packages/interactions/handlers/interactions/pick-role.ts @@ -0,0 +1,16 @@ +import { + InteractionCallbackType, + InteractionRequestCommand, + InteractionResponse, +} from '@roleypoly/types'; + +export const helloWorld = async ( + interaction: InteractionRequestCommand +): Promise => { + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`, + }, + }; +}; diff --git a/packages/interactions/handlers/interactions/pickable-roles.ts b/packages/interactions/handlers/interactions/pickable-roles.ts new file mode 100644 index 0000000..fdfa8cf --- /dev/null +++ b/packages/interactions/handlers/interactions/pickable-roles.ts @@ -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 => { + 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, + }, + ], + }, + ], + }, + }; +}; diff --git a/packages/interactions/handlers/interactions/roleypoly.ts b/packages/interactions/handlers/interactions/roleypoly.ts new file mode 100644 index 0000000..bbf5333 --- /dev/null +++ b/packages/interactions/handlers/interactions/roleypoly.ts @@ -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 => { + 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}`, + }, + }; +}; diff --git a/packages/interactions/utils/api.ts b/packages/interactions/utils/api.ts new file mode 100644 index 0000000..ea8031b --- /dev/null +++ b/packages/interactions/utils/api.ts @@ -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> => { + 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; +}; diff --git a/packages/interactions/utils/config.ts b/packages/interactions/utils/config.ts index 9064e79..cba2f1c 100644 --- a/packages/interactions/utils/config.ts +++ b/packages/interactions/utils/config.ts @@ -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'); diff --git a/packages/interactions/utils/responses.ts b/packages/interactions/utils/responses.ts new file mode 100644 index 0000000..4c6a25b --- /dev/null +++ b/packages/interactions/utils/responses.ts @@ -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: ' Something went terribly wrong.', + flags: InteractionFlags.EPHEMERAL, + }, +}); diff --git a/packages/interactions/worker.config.js b/packages/interactions/worker.config.js index ec8f372..8baa69e 100644 --- a/packages/interactions/worker.config.js +++ b/packages/interactions/worker.config.js @@ -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', + ]), }; diff --git a/packages/types/Category.ts b/packages/types/Category.ts index 89b673e..bdef432 100644 --- a/packages/types/Category.ts +++ b/packages/types/Category.ts @@ -11,3 +11,8 @@ export type Category = { type: CategoryType; position: number; }; + +export type CategorySlug = { + roles: Category['roles']; + type: Category['type']; +}; diff --git a/packages/types/Interactions.ts b/packages/types/Interactions.ts index 8ae3e00..37cf34c 100644 --- a/packages/types/Interactions.ts +++ b/packages/types/Interactions.ts @@ -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; + }; +}; diff --git a/terraform/api.tf b/terraform/api.tf index e6edc0d..26b6bc6 100644 --- a/terraform/api.tf +++ b/terraform/api.tf @@ -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" { diff --git a/terraform/interactions.tf b/terraform/interactions.tf index de791aa..c97ba66 100644 --- a/terraform/interactions.tf +++ b/terraform/interactions.tf @@ -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 diff --git a/terraform/variables.tf b/terraform/variables.tf index 03a757e..4efee8b 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -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 = ""