From 066f68ffef2cdfcb25fd4e478749603fc07e00b3 Mon Sep 17 00:00:00 2001 From: Katalina Date: Sun, 1 Aug 2021 20:26:47 -0400 Subject: [PATCH] feat: Slash Commands (#337) * feat: add discord interactions worker * feat(interactions): update CI/CD and terraform to add interactions * chore: fix lint issues * chore: fix build & emulation * fix(interactions): deployment + handler * chore: remove worker-dist via gitignore * feat: add /pickable-roles and /pick-role basis * feat: add pick, remove, and update the general /roleypoly command * fix: lint missing Member import --- .env.example | 1 + .github/workflows/build.yml | 14 +- .github/workflows/deploy.yml | 4 +- package.json | 3 + packages/api/handlers/bot-join.ts | 6 +- packages/api/handlers/get-picker-data.ts | 3 +- packages/api/handlers/get-session.ts | 3 +- packages/api/handlers/get-slug.ts | 2 +- .../api/handlers/interactions-pick-role.ts | 104 +++++++++++++++ .../handlers/interactions-pickable-roles.ts | 33 +++++ packages/api/handlers/login-bounce.ts | 3 +- packages/api/handlers/login-callback.ts | 15 +-- packages/api/handlers/revoke-session.ts | 5 +- packages/api/handlers/update-roles.ts | 13 +- packages/api/index.ts | 9 +- packages/api/package.json | 1 + packages/api/utils/api-tools.ts | 83 +++--------- packages/api/utils/audit-log.ts | 22 +--- packages/api/utils/config.ts | 3 +- packages/api/utils/guild.ts | 11 +- packages/api/utils/kv.ts | 81 +----------- packages/api/utils/rate-limiting.ts | 2 +- packages/api/utils/responses.ts | 5 +- packages/api/worker.config.js | 1 + packages/backend-emulator/main.js | 5 +- packages/interactions/bindings.d.ts | 7 + packages/interactions/handlers/healthz.ts | 5 + packages/interactions/handlers/interaction.ts | 58 +++++++++ .../handlers/interactions/hello-world.ts | 16 +++ .../handlers/interactions/pick-role.ts | 80 ++++++++++++ .../handlers/interactions/pickable-roles.ts | 58 +++++++++ .../handlers/interactions/roleypoly.ts | 56 ++++++++ packages/interactions/index.ts | 29 +++++ packages/interactions/package.json | 17 +++ packages/interactions/tsconfig.json | 15 +++ packages/interactions/utils/api.ts | 41 ++++++ packages/interactions/utils/config.ts | 11 ++ packages/interactions/utils/interactions.ts | 27 ++++ packages/interactions/utils/responses.ts | 29 +++++ packages/interactions/webpack.config.js | 28 ++++ packages/interactions/worker.config.js | 12 ++ packages/types/Category.ts | 5 + packages/types/Interactions.ts | 79 ++++++++++++ packages/types/index.ts | 1 + packages/worker-utils/api.ts | 21 +++ packages/worker-utils/discord.ts | 38 ++++++ packages/worker-utils/index.ts | 4 + packages/worker-utils/kv.ts | 80 ++++++++++++ packages/worker-utils/package.json | 10 ++ packages/{api => worker-utils}/router.ts | 37 +++++- packages/worker-utils/tsconfig.json | 15 +++ terraform/.gitignore | 2 + terraform/.terraform.lock.hcl | 86 ++++++++----- terraform/{workers.tf => api.tf} | 5 + terraform/interactions.tf | 120 ++++++++++++++++++ terraform/providers.tf | 9 ++ terraform/variables.tf | 21 ++- tsconfig.json | 8 +- yarn.lock | 5 + 59 files changed, 1219 insertions(+), 248 deletions(-) create mode 100644 packages/api/handlers/interactions-pick-role.ts create mode 100644 packages/api/handlers/interactions-pickable-roles.ts create mode 100644 packages/interactions/bindings.d.ts create mode 100644 packages/interactions/handlers/healthz.ts create mode 100644 packages/interactions/handlers/interaction.ts create mode 100644 packages/interactions/handlers/interactions/hello-world.ts create mode 100644 packages/interactions/handlers/interactions/pick-role.ts create mode 100644 packages/interactions/handlers/interactions/pickable-roles.ts create mode 100644 packages/interactions/handlers/interactions/roleypoly.ts create mode 100644 packages/interactions/index.ts create mode 100644 packages/interactions/package.json create mode 100644 packages/interactions/tsconfig.json create mode 100644 packages/interactions/utils/api.ts create mode 100644 packages/interactions/utils/config.ts create mode 100644 packages/interactions/utils/interactions.ts create mode 100644 packages/interactions/utils/responses.ts create mode 100644 packages/interactions/webpack.config.js create mode 100644 packages/interactions/worker.config.js create mode 100644 packages/types/Interactions.ts create mode 100644 packages/worker-utils/api.ts create mode 100644 packages/worker-utils/discord.ts create mode 100644 packages/worker-utils/index.ts create mode 100644 packages/worker-utils/kv.ts create mode 100644 packages/worker-utils/package.json rename packages/{api => worker-utils}/router.ts (65%) create mode 100644 packages/worker-utils/tsconfig.json rename terraform/{workers.tf => api.tf} (94%) create mode 100644 terraform/interactions.tf diff --git a/.env.example b/.env.example index f9a7549..f6ac453 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ BOT_CLIENT_ID=000000000000000000 BOT_CLIENT_SECRET=RnX8pXXXXXXXXXXXXXXXXXXXXXXXXXu- BOT_TOKEN=Mzk2MjI3MTM0MjI3NXXXXXXXXXXXXXXXXXXXXXPUlYoARXXXXXXXXXXXXXX +DISCORD_PUBLIC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx # Comma separated; put your user ID here. Gives elevated permissions to everything. ROOT_USERS=62601275618889728 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d74bcbf..bf7e1a1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,11 @@ jobs: name: Worker Build & Publish needs: - node_test + strategy: + matrix: + worker: + - api + - interactions steps: - uses: actions/checkout@master @@ -55,7 +60,7 @@ jobs: - name: Check if already deployed id: check run: | - gsutil stat gs://roleypoly-artifacts/backend-worker/${{ github.sha }}/script.js \ + gsutil stat gs://roleypoly-artifacts/workers/${{ github.sha }}/${{ matrix.worker }}.js \ && echo ::set-output name=skip::1 \ || echo ::set-output name=skip::0 @@ -81,16 +86,17 @@ jobs: - run: | wrangler init - echo 'webpack_config = "packages/api/webpack.config.js"' | tee -a wrangler.toml + echo 'webpack_config = "packages/${{ matrix.worker }}/webpack.config.js"' | tee -a wrangler.toml wrangler build + mv worker/script.js worker/${{ matrix.worker }}.js if: steps.check.outputs.skip == '0' - id: upload-file if: github.event_name == 'push' && steps.check.outputs.skip == '0' uses: google-github-actions/upload-cloud-storage@main with: - path: worker/script.js - destination: roleypoly-artifacts/backend-worker/${{ github.sha }} + path: worker/${{ matrix.worker }}.js + destination: roleypoly-artifacts/workers/${{ github.sha }} credentials: ${{ secrets.GCS_TF_KEY }} docker_build: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 146e1ae..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/backend-worker/$selected/script.js worker-dist/backend-worker.js + 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/package.json b/package.json index 65423fb..93b8bb9 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,13 @@ "lint:terraform": "terraform fmt -recursive -check ./terraform", "lint:types": "tsc --noEmit", "lint:types-api": "yarn workspace @roleypoly/api run lint:types", + "lint:types-interactions": "yarn workspace @roleypoly/interactions run lint:types", + "lint:types-worker-utils": "yarn workspace @roleypoly/worker-utils run lint:types", "postinstall": "is-ci || husky install", "start": "run-p -c start:*", "start:bot": "yarn workspace @roleypoly/bot start", "start:design-system": "yarn workspace @roleypoly/design-system start", + "start:interactions": "yarn workspace @roleypoly/interactions start", "start:web": "yarn workspace @roleypoly/web start", "start:worker": "yarn workspace @roleypoly/api start", "test": "jest" diff --git a/packages/api/handlers/bot-join.ts b/packages/api/handlers/bot-join.ts index 3a6cc9e..30670fe 100644 --- a/packages/api/handlers/bot-join.ts +++ b/packages/api/handlers/bot-join.ts @@ -7,10 +7,13 @@ type URLParams = { clientID: string; permissions: number; guildID?: string; + scopes: string[]; }; const buildURL = (params: URLParams) => { - let url = `https://discord.com/api/oauth2/authorize?client_id=${params.clientID}&scope=bot&permissions=${params.permissions}`; + let url = `https://discord.com/api/oauth2/authorize?client_id=${ + params.clientID + }&scope=${params.scopes.join('%20')}&permissions=${params.permissions}`; if (params.guildID) { url += `&guild_id=${params.guildID}&disable_guild_select=true`; @@ -31,6 +34,7 @@ export const BotJoin = (request: Request): Response => { clientID: botClientID, permissions: 268435456, guildID, + scopes: ['bot', 'application.commands'], }) ); }; diff --git a/packages/api/handlers/get-picker-data.ts b/packages/api/handlers/get-picker-data.ts index a85c146..786f237 100644 --- a/packages/api/handlers/get-picker-data.ts +++ b/packages/api/handlers/get-picker-data.ts @@ -1,7 +1,8 @@ import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control'; import { accessControlViolation } from '@roleypoly/api/utils/responses'; import { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types'; -import { respond, withSession } from '../utils/api-tools'; +import { respond } from '@roleypoly/worker-utils'; +import { withSession } from '../utils/api-tools'; import { getGuild, getGuildData, getGuildMember } from '../utils/guild'; const fail = () => respond({ error: 'guild not found' }, { status: 404 }); diff --git a/packages/api/handlers/get-session.ts b/packages/api/handlers/get-session.ts index 9b44b19..3684ff7 100644 --- a/packages/api/handlers/get-session.ts +++ b/packages/api/handlers/get-session.ts @@ -1,5 +1,6 @@ import { SessionData } from '@roleypoly/types'; -import { respond, withSession } from '../utils/api-tools'; +import { respond } from '@roleypoly/worker-utils'; +import { withSession } from '../utils/api-tools'; export const GetSession = withSession((session?: SessionData) => (): Response => { const { user, guilds, sessionID } = session || {}; diff --git a/packages/api/handlers/get-slug.ts b/packages/api/handlers/get-slug.ts index f8b505b..860231c 100644 --- a/packages/api/handlers/get-slug.ts +++ b/packages/api/handlers/get-slug.ts @@ -1,5 +1,5 @@ import { GuildSlug } from '@roleypoly/types'; -import { respond } from '../utils/api-tools'; +import { respond } from '@roleypoly/worker-utils'; import { getGuild } from '../utils/guild'; export const GetSlug = async (request: Request): Promise => { diff --git a/packages/api/handlers/interactions-pick-role.ts b/packages/api/handlers/interactions-pick-role.ts new file mode 100644 index 0000000..8c6f807 --- /dev/null +++ b/packages/api/handlers/interactions-pick-role.ts @@ -0,0 +1,104 @@ +import { CategoryType, Member, RoleSafety } from '@roleypoly/types'; +import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils'; +import { difference, keyBy } from 'lodash'; +import { interactionsEndpoint } from '../utils/api-tools'; +import { botToken } from '../utils/config'; +import { + getGuild, + getGuildData, + getGuildMember, + updateGuildMember, +} from '../utils/guild'; +import { conflict, invalid, notAuthenticated, notFound, ok } from '../utils/responses'; + +export const InteractionsPickRole = interactionsEndpoint( + async (request: Request): Promise => { + const mode = request.method === 'PUT' ? 'add' : 'remove'; + const reqURL = new URL(request.url); + const [, , guildID, userID, roleID] = reqURL.pathname.split('/'); + if (!guildID || !userID || !roleID) { + return invalid(); + } + + const guildP = getGuild(guildID); + const guildDataP = getGuildData(guildID); + const guildMemberP = getGuildMember( + { serverID: guildID, userID }, + { skipCachePull: true } + ); + + const [guild, guildData, guildMember] = await Promise.all([ + guildP, + guildDataP, + guildMemberP, + ]); + + if (!guild || !guildData || !guildMember) { + return notFound(); + } + + let memberRoles = guildMember.roles; + + if ( + (mode === 'add' && memberRoles.includes(roleID)) || + (mode !== 'add' && !memberRoles.includes(roleID)) + ) { + return conflict(); + } + + const roleMap = keyBy(guild.roles, 'id'); + + const category = guildData.categories.find((category) => + category.roles.includes(roleID) + ); + // No category? illegal. + if (!category) { + return notFound(); + } + + // Category is hidden, this is illegal + if (category.hidden) { + return notFound(); + } + + // Role is unsafe, super illegal. + if (roleMap[roleID].safety !== RoleSafety.Safe) { + return notAuthenticated(); + } + + // In add mode, if the category is a single-mode, remove the other roles in the category. + if (mode === 'add' && category.type === CategoryType.Single) { + memberRoles = difference(memberRoles, category.roles); + } + + if (mode === 'add') { + memberRoles = [...memberRoles, roleID]; + } else { + memberRoles = memberRoles.filter((id) => id !== roleID); + } + + const patchMemberRoles = await discordFetch( + `/guilds/${guildID}/members/${userID}`, + botToken, + AuthType.Bot, + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + 'x-audit-log-reason': `Picked their roles via slash command`, + }, + body: JSON.stringify({ + roles: memberRoles, + }), + } + ); + + if (!patchMemberRoles) { + return respond({ error: 'discord rejected the request' }, { status: 500 }); + } + + await updateGuildMember({ serverID: guildID, userID }); + + 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/handlers/login-bounce.ts b/packages/api/handlers/login-bounce.ts index 00c959b..bbfb5e1 100644 --- a/packages/api/handlers/login-bounce.ts +++ b/packages/api/handlers/login-bounce.ts @@ -1,5 +1,6 @@ import { StateSession } from '@roleypoly/types'; -import { getQuery, isAllowedCallbackHost, setupStateSession } from '../utils/api-tools'; +import { getQuery } from '@roleypoly/worker-utils'; +import { isAllowedCallbackHost, setupStateSession } from '../utils/api-tools'; import { Bounce } from '../utils/bounce'; import { apiPublicURI, botClientID } from '../utils/config'; diff --git a/packages/api/handlers/login-callback.ts b/packages/api/handlers/login-callback.ts index 59c9876..4258f1d 100644 --- a/packages/api/handlers/login-callback.ts +++ b/packages/api/handlers/login-callback.ts @@ -5,25 +5,22 @@ import { SessionData, StateSession, } from '@roleypoly/types'; -import KSUID from 'ksuid'; import { AuthType, + discordAPIBase, discordFetch, + userAgent, +} from '@roleypoly/worker-utils'; +import KSUID from 'ksuid'; +import { formData, getStateSession, isAllowedCallbackHost, parsePermissions, resolveFailures, - userAgent, } from '../utils/api-tools'; import { Bounce } from '../utils/bounce'; -import { - apiPublicURI, - botClientID, - botClientSecret, - discordAPIBase, - uiPublicURI, -} from '../utils/config'; +import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config'; import { Sessions } from '../utils/kv'; const AuthErrorResponse = (extra?: string) => diff --git a/packages/api/handlers/revoke-session.ts b/packages/api/handlers/revoke-session.ts index 57593cf..c80acce 100644 --- a/packages/api/handlers/revoke-session.ts +++ b/packages/api/handlers/revoke-session.ts @@ -1,6 +1,7 @@ import { SessionData } from '@roleypoly/types'; -import { formData, respond, userAgent, withSession } from '../utils/api-tools'; -import { botClientID, botClientSecret, discordAPIBase } from '../utils/config'; +import { discordAPIBase, respond, userAgent } from '@roleypoly/worker-utils'; +import { formData, withSession } from '../utils/api-tools'; +import { botClientID, botClientSecret } from '../utils/config'; import { Sessions } from '../utils/kv'; export const RevokeSession = withSession( diff --git a/packages/api/handlers/update-roles.ts b/packages/api/handlers/update-roles.ts index 2ab9888..237b91b 100644 --- a/packages/api/handlers/update-roles.ts +++ b/packages/api/handlers/update-roles.ts @@ -1,5 +1,3 @@ -import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control'; -import { accessControlViolation } from '@roleypoly/api/utils/responses'; import { GuildData, Member, @@ -10,9 +8,10 @@ import { SessionData, TransactionType, } from '@roleypoly/types'; +import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils'; import { difference, groupBy, keyBy, union } from 'lodash'; -import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools'; -import { botToken } from '../utils/config'; +import { withSession } from '../utils/api-tools'; +import { botToken, uiPublicURI } from '../utils/config'; import { getGuild, getGuildData, @@ -57,10 +56,6 @@ export const UpdateRoles = withSession( const guildData = await getGuildData(guildID); - if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) { - return accessControlViolation(); - } - const newRoles = calculateNewRoles({ currentRoles: guildMember.roles, guildRoles: guild.roles, @@ -76,7 +71,7 @@ export const UpdateRoles = withSession( method: 'PATCH', headers: { 'content-type': 'application/json', - 'x-audit-log-reason': `${username}#${discriminator} changes their roles via ${url.hostname}`, + 'x-audit-log-reason': `Picked their roles via ${uiPublicURI}`, }, body: JSON.stringify({ roles: newRoles, diff --git a/packages/api/index.ts b/packages/api/index.ts index cd221d7..ef7200a 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -1,3 +1,6 @@ +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'; import { GetPickerData } from './handlers/get-picker-data'; @@ -9,7 +12,6 @@ import { RevokeSession } from './handlers/revoke-session'; import { SyncFromLegacy } from './handlers/sync-from-legacy'; import { UpdateGuild } from './handlers/update-guild'; import { UpdateRoles } from './handlers/update-roles'; -import { Router } from './router'; import { respond } from './utils/api-tools'; import { uiPublicURI } from './utils/config'; @@ -32,6 +34,11 @@ 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('PUT', 'interactions-pick-role', InteractionsPickRole); +router.add('DELETE', 'interactions-pick-role', InteractionsPickRole); + // Tester Routes router.add('GET', 'x-headers', (request) => { const headers: { [x: string]: string } = {}; diff --git a/packages/api/package.json b/packages/api/package.json index b5d0aee..6316916 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,6 +11,7 @@ "@roleypoly/misc-utils": "*", "@roleypoly/types": "*", "@roleypoly/worker-emulator": "*", + "@roleypoly/worker-utils": "*", "@types/deep-equal": "^1.0.1", "deep-equal": "^2.0.5", "ksuid": "^2.0.0", diff --git a/packages/api/utils/api-tools.ts b/packages/api/utils/api-tools.ts index 55f6b13..f1a6efa 100644 --- a/packages/api/utils/api-tools.ts +++ b/packages/api/utils/api-tools.ts @@ -1,12 +1,18 @@ +import { notAuthenticated } from '@roleypoly/api/utils/responses'; import { evaluatePermission, permissions as Permissions, } from '@roleypoly/misc-utils/hasPermission'; import { SessionData, UserGuildPermissions } from '@roleypoly/types'; +import { Handler, WrappedKVNamespace } from '@roleypoly/worker-utils'; import KSUID from 'ksuid'; -import { Handler } from '../router'; -import { allowedCallbackHosts, apiPublicURI, discordAPIBase, rootUsers } from './config'; -import { Sessions, WrappedKVNamespace } from './kv'; +import { + allowedCallbackHosts, + apiPublicURI, + interactionsSharedKey, + rootUsers, +} from './config'; +import { Sessions } from './kv'; export const formData = (obj: Record): string => { return Object.keys(obj) @@ -14,18 +20,8 @@ export const formData = (obj: Record): string => { .join('&'); }; -export const addCORS = (init: ResponseInit = {}) => ({ - ...init, - headers: { - ...(init.headers || {}), - 'access-control-allow-origin': '*', - 'access-control-allow-methods': '*', - 'access-control-allow-headers': '*', - }, -}); - export const respond = (obj: Record, init: ResponseInit = {}) => - new Response(JSON.stringify(obj), addCORS(init)); + new Response(JSON.stringify(obj), init); export const resolveFailures = ( @@ -70,44 +66,6 @@ export const getSessionID = (request: Request): { type: string; id: string } | n return { type, id }; }; -export const userAgent = - 'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)'; - -export enum AuthType { - Bearer = 'Bearer', - Bot = 'Bot', -} - -export const discordFetch = async ( - url: string, - auth: string, - authType: AuthType = AuthType.Bearer, - init?: RequestInit -): Promise => { - const response = await fetch(discordAPIBase + url, { - ...(init || {}), - headers: { - ...(init?.headers || {}), - authorization: `${AuthType[authType]} ${auth}`, - 'user-agent': userAgent, - }, - }); - - if (response.status >= 400) { - console.error('discordFetch failed', { - url, - authType, - payload: await response.text(), - }); - } - - if (response.ok) { - return (await response.json()) as T; - } else { - return null; - } -}; - export type CacheLayerOptions = { skipCachePull?: boolean; }; @@ -195,16 +153,6 @@ export const onlyRootUsers = (handler: Handler): Handler => ); }); -export const getQuery = (request: Request): { [x: string]: string } => { - const output: { [x: string]: string } = {}; - - for (let [key, value] of new URL(request.url).searchParams.entries()) { - output[key] = value; - } - - return output; -}; - export const isAllowedCallbackHost = (host: string): boolean => { return ( host === apiPublicURI || @@ -215,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 5e1869e..91c0d7a 100644 --- a/packages/api/utils/audit-log.ts +++ b/packages/api/utils/audit-log.ts @@ -1,35 +1,21 @@ -import { userAgent } from '@roleypoly/api/utils/api-tools'; import { uiPublicURI } from '@roleypoly/api/utils/config'; import { Category, DiscordUser, + Embed, GuildData, GuildDataUpdate, GuildSlug, WebhookValidationStatus, } from '@roleypoly/types'; +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 5194504..57ba3e5 100644 --- a/packages/api/utils/config.ts +++ b/packages/api/utils/config.ts @@ -13,5 +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 discordAPIBase = 'https://discordapp.com/api/v9'; +export const interactionsSharedKey = env('INTERACTIONS_SHARED_KEY'); diff --git a/packages/api/utils/guild.ts b/packages/api/utils/guild.ts index b7edc87..d3633e0 100644 --- a/packages/api/utils/guild.ts +++ b/packages/api/utils/guild.ts @@ -1,4 +1,3 @@ -import { Handler } from '@roleypoly/api/router'; import { lowPermissions, missingParameters, @@ -18,14 +17,8 @@ import { SessionData, UserGuildPermissions, } from '@roleypoly/types'; -import { - AuthType, - cacheLayer, - CacheLayerOptions, - discordFetch, - isRoot, - withSession, -} from './api-tools'; +import { AuthType, discordFetch, Handler } from '@roleypoly/worker-utils'; +import { cacheLayer, CacheLayerOptions, isRoot, withSession } from './api-tools'; import { botClientID, botToken } from './config'; import { GuildData, Guilds } from './kv'; import { useRateLimiter } from './rate-limiting'; diff --git a/packages/api/utils/kv.ts b/packages/api/utils/kv.ts index db18ef5..33c75c9 100644 --- a/packages/api/utils/kv.ts +++ b/packages/api/utils/kv.ts @@ -1,83 +1,4 @@ -export class WrappedKVNamespace { - constructor(private kvNamespace: KVNamespace) {} - - async get(key: string): Promise { - const data = await this.kvNamespace.get(key, 'text'); - if (!data) { - return null; - } - - return JSON.parse(data) as T; - } - - async put(key: string, value: T, ttlSeconds?: number) { - await this.kvNamespace.put(key, JSON.stringify(value), { - expirationTtl: ttlSeconds, - }); - } - - list = this.kvNamespace.list; - getWithMetadata = this.kvNamespace.getWithMetadata; - delete = this.kvNamespace.delete; -} - -class EmulatedKV implements KVNamespace { - constructor() { - console.warn('EmulatedKV used. Data will be lost.'); - } - - private data: Map = new Map(); - - async get(key: string): Promise { - if (!this.data.has(key)) { - return null; - } - - return this.data.get(key); - } - - async getWithMetadata( - key: string - ): KVValueWithMetadata { - return { - value: await this.get(key), - metadata: {} as Metadata, - }; - } - - async put(key: string, value: string | ReadableStream | ArrayBuffer | FormData) { - this.data.set(key, value); - } - - async delete(key: string) { - this.data.delete(key); - } - - async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ - keys: { name: string; expiration?: number; metadata?: unknown }[]; - list_complete: boolean; - cursor: string; - }> { - let keys: { name: string }[] = []; - - for (let key of this.data.keys()) { - if (options?.prefix && !key.startsWith(options.prefix)) { - continue; - } - - keys.push({ name: key }); - } - - return { - keys, - cursor: '0', - list_complete: true, - }; - } -} - -const kvOrLocal = (namespace: KVNamespace | null): KVNamespace => - namespace || new EmulatedKV(); +import { kvOrLocal, WrappedKVNamespace } from '@roleypoly/worker-utils'; const self = global as any as Record; diff --git a/packages/api/utils/rate-limiting.ts b/packages/api/utils/rate-limiting.ts index 372cfb5..fa7f7c7 100644 --- a/packages/api/utils/rate-limiting.ts +++ b/packages/api/utils/rate-limiting.ts @@ -1,4 +1,4 @@ -import { WrappedKVNamespace } from './kv'; +import { WrappedKVNamespace } from '@roleypoly/worker-utils'; export const useRateLimiter = (kv: WrappedKVNamespace, key: string, timeoutSeconds: number) => diff --git a/packages/api/utils/responses.ts b/packages/api/utils/responses.ts index 0ec921d..6ad2fd5 100644 --- a/packages/api/utils/responses.ts +++ b/packages/api/utils/responses.ts @@ -1,4 +1,4 @@ -import { respond } from './api-tools'; +import { respond } from '@roleypoly/worker-utils'; export const ok = () => respond({ ok: true }); @@ -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/backend-emulator/main.js b/packages/backend-emulator/main.js index 0895e4e..ebd9613 100755 --- a/packages/backend-emulator/main.js +++ b/packages/backend-emulator/main.js @@ -204,5 +204,6 @@ fork(async () => { reload(); }); -console.log('starting on http://localhost:6609'); -server.listen(6609, '0.0.0.0'); +const port = args.port || 6609; +console.log(`starting on http://localhost:${port}`); +server.listen(port, '0.0.0.0'); diff --git a/packages/interactions/bindings.d.ts b/packages/interactions/bindings.d.ts new file mode 100644 index 0000000..f28be01 --- /dev/null +++ b/packages/interactions/bindings.d.ts @@ -0,0 +1,7 @@ +export {}; + +declare global { + const DISCORD_PUBLIC_KEY: string; + const UI_PUBLIC_URI: string; + const API_PUBLIC_URI: string; +} diff --git a/packages/interactions/handlers/healthz.ts b/packages/interactions/handlers/healthz.ts new file mode 100644 index 0000000..dc90791 --- /dev/null +++ b/packages/interactions/handlers/healthz.ts @@ -0,0 +1,5 @@ +import { respond } from '@roleypoly/worker-utils'; + +export const healthz = async (request: Request): Promise => { + return respond({ ok: true }); +}; diff --git a/packages/interactions/handlers/interaction.ts b/packages/interactions/handlers/interaction.ts new file mode 100644 index 0000000..70b8ddc --- /dev/null +++ b/packages/interactions/handlers/interaction.ts @@ -0,0 +1,58 @@ +import { + InteractionData, + InteractionRequest, + InteractionRequestCommand, + InteractionResponse, + InteractionType, +} from '@roleypoly/types'; +import { respond } from '@roleypoly/worker-utils'; +import { 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 +> = { + 'hello-world': helloWorld, + roleypoly: roleypoly, + 'pickable-roles': pickableRoles, + 'pick-role': pickRole('add'), + 'remove-role': pickRole('remove'), +}; + +export const interactionHandler = async (request: Request): Promise => { + const interaction = (await request.json()) as InteractionRequest; + + if (!verifyRequest(request, interaction)) { + return new Response('invalid request signature', { status: 401 }); + } + + if (interaction.type === InteractionType.PING) { + return respond({ type: 1 }); + } + + if (interaction.type !== InteractionType.APPLICATION_COMMAND) { + return respond({ err: 'not implemented' }, { status: 400 }); + } + + if (!interaction.data) { + return respond({ err: 'data missing' }, { status: 400 }); + } + + const handler = commands[interaction.data.name]; + if (!handler) { + return respond({ err: 'not implemented' }, { status: 400 }); + } + + try { + const response = await handler(interaction as InteractionRequestCommand); + return respond(response); + } catch (e) { + console.error(e); + return respond(somethingWentWrong()); + } +}; diff --git a/packages/interactions/handlers/interactions/hello-world.ts b/packages/interactions/handlers/interactions/hello-world.ts new file mode 100644 index 0000000..990ffe7 --- /dev/null +++ b/packages/interactions/handlers/interactions/hello-world.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/pick-role.ts b/packages/interactions/handlers/interactions/pick-role.ts new file mode 100644 index 0000000..2d7aca7 --- /dev/null +++ b/packages/interactions/handlers/interactions/pick-role.ts @@ -0,0 +1,80 @@ +import { selectRole } from '@roleypoly/interactions/utils/api'; +import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses'; +import { + InteractionCallbackType, + InteractionFlags, + InteractionRequestCommand, + InteractionResponse, +} from '@roleypoly/types'; + +export const pickRole = + (mode: 'add' | 'remove') => + async (interaction: InteractionRequestCommand): Promise => { + if (!interaction.guild_id) { + 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 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, + }, + }; + } + + 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 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..f31ce2d --- /dev/null +++ b/packages/interactions/handlers/interactions/roleypoly.ts @@ -0,0 +1,56 @@ +import { uiPublicURI } from '@roleypoly/interactions/utils/config'; +import { + Embed, + 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: { + embeds: [ + { + color: 0x453e3d, + title: `:beginner: Hey there, ${ + interaction.member?.nick || interaction.member?.user?.username || 'friend' + }!`, + description: `Try these slash commands, or pick roles from your browser!`, + fields: [ + { name: 'See all the roles', value: '/pickable-roles' }, + { name: 'Pick a role', value: '/pick-role' }, + { name: 'Remove a role', value: '/remove-role' }, + ], + } as Embed, + ], + components: [ + { + type: 1, + components: [ + // Link to Roleypoly + { + type: 2, + label: `Pick roles on ${new URL(uiPublicURI).hostname}`, + url: `${uiPublicURI}/s/${interaction.guild_id}`, + style: 5, + }, + ], + }, + ], + 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/index.ts b/packages/interactions/index.ts new file mode 100644 index 0000000..2fedfb2 --- /dev/null +++ b/packages/interactions/index.ts @@ -0,0 +1,29 @@ +import { interactionHandler } from '@roleypoly/interactions/handlers/interaction'; +import { respond } from '@roleypoly/worker-utils'; +import { Router } from '@roleypoly/worker-utils/router'; +import { healthz } from './handlers/healthz'; +import { uiPublicURI } from './utils/config'; + +const router = new Router(); + +router.add('GET', '_healthz', healthz); +router.add('POST', 'interactions', interactionHandler); + +// Root Zen <3 +router.addFallback('root', () => { + return respond({ + __warning: '🦊', + this: 'is', + a: 'fox-based', + web: 'application', + please: 'be', + mindful: 'of', + your: 'surroundings', + warning__: '🦊', + meta: uiPublicURI, + }); +}); + +addEventListener('fetch', (event: FetchEvent) => { + event.respondWith(router.handle(event.request)); +}); diff --git a/packages/interactions/package.json b/packages/interactions/package.json new file mode 100644 index 0000000..5b78e15 --- /dev/null +++ b/packages/interactions/package.json @@ -0,0 +1,17 @@ +{ + "name": "@roleypoly/interactions", + "version": "0.1.0", + "scripts": { + "build": "yarn workspace @roleypoly/worker-emulator build --basePath `pwd`", + "lint:types": "tsc --noEmit", + "start": "cfw-emulator" + }, + "devDependencies": { + "@cloudflare/workers-types": "^2.2.2", + "@roleypoly/types": "*", + "@roleypoly/worker-emulator": "*", + "@roleypoly/worker-utils": "*", + "@types/node": "^16.4.10", + "tweetnacl": "^1.0.3" + } +} diff --git a/packages/interactions/tsconfig.json b/packages/interactions/tsconfig.json new file mode 100644 index 0000000..2c330c0 --- /dev/null +++ b/packages/interactions/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"], + "types": ["@cloudflare/workers-types", "node"], + "target": "ES2019" + }, + "include": [ + "./*.ts", + "./**/*.ts", + "../../node_modules/@cloudflare/workers-types/index.d.ts" + ], + "exclude": ["./**/*.spec.ts", "./dist/**"], + "extends": "../../tsconfig.json" +} diff --git a/packages/interactions/utils/api.ts b/packages/interactions/utils/api.ts new file mode 100644 index 0000000..2ec79ed --- /dev/null +++ b/packages/interactions/utils/api.ts @@ -0,0 +1,41 @@ +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; +}; + +export const selectRole = async ( + mode: 'add' | 'remove', + guildID: string, + userID: string, + roleID: string +): Promise => { + const response = await apiFetch( + `/interactions-pick-role/${guildID}/${userID}/${roleID}`, + { + method: mode === 'add' ? 'PUT' : 'DELETE', + } + ); + + return response.status; +}; diff --git a/packages/interactions/utils/config.ts b/packages/interactions/utils/config.ts new file mode 100644 index 0000000..cba2f1c --- /dev/null +++ b/packages/interactions/utils/config.ts @@ -0,0 +1,11 @@ +const self = global as any as Record; + +const env = (key: string) => self[key] ?? ''; + +const safeURI = (x: string) => x.replace(/\/$/, ''); +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/interactions.ts b/packages/interactions/utils/interactions.ts new file mode 100644 index 0000000..ba982d1 --- /dev/null +++ b/packages/interactions/utils/interactions.ts @@ -0,0 +1,27 @@ +import { publicKey } from '@roleypoly/interactions/utils/config'; +import { InteractionRequest } from '@roleypoly/types'; +import nacl from 'tweetnacl'; + +export const verifyRequest = ( + request: Request, + interaction: InteractionRequest +): boolean => { + const timestamp = request.headers.get('x-signature-timestamp'); + const signature = request.headers.get('x-signature-ed25519'); + + if (!timestamp || !signature) { + return false; + } + + if ( + !nacl.sign.detached.verify( + Buffer.from(timestamp + JSON.stringify(interaction)), + Buffer.from(signature, 'hex'), + Buffer.from(publicKey, 'hex') + ) + ) { + return false; + } + + return true; +}; diff --git a/packages/interactions/utils/responses.ts b/packages/interactions/utils/responses.ts new file mode 100644 index 0000000..f79dccc --- /dev/null +++ b/packages/interactions/utils/responses.ts @@ -0,0 +1,29 @@ +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 invalid = (): InteractionResponse => ({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: ':x: You filled that command out wrong...', + 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/webpack.config.js b/packages/interactions/webpack.config.js new file mode 100644 index 0000000..bc319c2 --- /dev/null +++ b/packages/interactions/webpack.config.js @@ -0,0 +1,28 @@ +const path = require('path'); + +const mode = process.env.NODE_ENV || 'production'; + +module.exports = { + target: 'webworker', + entry: path.join(__dirname, 'index.ts'), + output: { + filename: `worker.${mode}.js`, + path: path.join(__dirname, 'dist'), + }, + mode, + resolve: { + extensions: ['.ts', '.tsx', '.js'], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + options: { + transpileOnly: true, + configFile: path.join(__dirname, 'tsconfig.json'), + }, + }, + ], + }, +}; diff --git a/packages/interactions/worker.config.js b/packages/interactions/worker.config.js new file mode 100644 index 0000000..8baa69e --- /dev/null +++ b/packages/interactions/worker.config.js @@ -0,0 +1,12 @@ +const reexportEnv = (keys = []) => { + return keys.reduce((acc, key) => ({ ...acc, [key]: process.env[key] }), {}); +}; + +module.exports = { + 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 new file mode 100644 index 0000000..b547144 --- /dev/null +++ b/packages/types/Interactions.ts @@ -0,0 +1,79 @@ +import { DiscordUser, Member } from '@roleypoly/types/User'; + +export enum InteractionType { + PING = 1, + APPLICATION_COMMAND = 2, + MESSAGE_COMPONENT = 3, +} + +export type InteractionRequest = { + id: string; + application_id: string; + token: string; + version: 1; + type: InteractionType; + data?: InteractionData; + guild_id?: string; + channel_id?: string; + member?: Member; + user?: DiscordUser; + message?: {}; +}; + +export type InteractionRequestCommand = InteractionRequest & { + data: InteractionData; +}; + +export type InteractionData = { + id: string; + name: string; + resolved?: {}; + options?: { + name: string; + type: number; + value?: string; + }[]; + custom_id: string; + component_type: string; +}; + +export enum InteractionCallbackType { + PONG = 1, + CHANNEL_MESSAGE_WITH_SOURCE = 4, + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, + DEFERRED_UPDATE_MESSAGE = 6, + UPDATE_MESSAGE = 7, +} + +export enum InteractionFlags { + EPHEMERAL = 1 << 6, +} + +export type InteractionCallbackData = { + tts?: boolean; + content?: string; + embeds?: {}; + allowed_mentions?: {}; + flags?: InteractionFlags; + components?: {}[]; +}; + +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/packages/types/index.ts b/packages/types/index.ts index 6d88788..f61a524 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -1,5 +1,6 @@ export * from './Category'; export * from './Guild'; +export * from './Interactions'; export * from './Role'; export * from './Session'; export * from './User'; diff --git a/packages/worker-utils/api.ts b/packages/worker-utils/api.ts new file mode 100644 index 0000000..26c7491 --- /dev/null +++ b/packages/worker-utils/api.ts @@ -0,0 +1,21 @@ +export const respond = (obj: Record, init: ResponseInit = {}) => + new Response(JSON.stringify(obj), { + ...init, + headers: { + ...(init.headers || {}), + 'content-type': 'application/json', + }, + }); + +export const userAgent = + 'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)'; + +export const getQuery = (request: Request): { [x: string]: string } => { + const output: { [x: string]: string } = {}; + + for (let [key, value] of new URL(request.url).searchParams.entries()) { + output[key] = value; + } + + return output; +}; diff --git a/packages/worker-utils/discord.ts b/packages/worker-utils/discord.ts new file mode 100644 index 0000000..4fef623 --- /dev/null +++ b/packages/worker-utils/discord.ts @@ -0,0 +1,38 @@ +import { userAgent } from './api'; + +export const discordAPIBase = 'https://discordapp.com/api/v9'; + +export enum AuthType { + Bearer = 'Bearer', + Bot = 'Bot', +} + +export const discordFetch = async ( + url: string, + auth: string, + authType: AuthType = AuthType.Bearer, + init?: RequestInit +): Promise => { + const response = await fetch(discordAPIBase + url, { + ...(init || {}), + headers: { + ...(init?.headers || {}), + authorization: `${AuthType[authType]} ${auth}`, + 'user-agent': userAgent, + }, + }); + + if (response.status >= 400) { + console.error('discordFetch failed', { + url, + authType, + payload: await response.text(), + }); + } + + if (response.ok) { + return (await response.json()) as T; + } else { + return null; + } +}; diff --git a/packages/worker-utils/index.ts b/packages/worker-utils/index.ts new file mode 100644 index 0000000..1bfa41f --- /dev/null +++ b/packages/worker-utils/index.ts @@ -0,0 +1,4 @@ +export * from './api'; +export * from './discord'; +export * from './kv'; +export * from './router'; diff --git a/packages/worker-utils/kv.ts b/packages/worker-utils/kv.ts new file mode 100644 index 0000000..7c9339b --- /dev/null +++ b/packages/worker-utils/kv.ts @@ -0,0 +1,80 @@ +export class WrappedKVNamespace { + constructor(private kvNamespace: KVNamespace) {} + + async get(key: string): Promise { + const data = await this.kvNamespace.get(key, 'text'); + if (!data) { + return null; + } + + return JSON.parse(data) as T; + } + + async put(key: string, value: T, ttlSeconds?: number) { + await this.kvNamespace.put(key, JSON.stringify(value), { + expirationTtl: ttlSeconds, + }); + } + + list = this.kvNamespace.list; + getWithMetadata = this.kvNamespace.getWithMetadata; + delete = this.kvNamespace.delete; +} + +class EmulatedKV implements KVNamespace { + constructor() { + console.warn('EmulatedKV used. Data will be lost.'); + } + + private data: Map = new Map(); + + async get(key: string): Promise { + if (!this.data.has(key)) { + return null; + } + + return this.data.get(key); + } + + async getWithMetadata( + key: string + ): KVValueWithMetadata { + return { + value: await this.get(key), + metadata: {} as Metadata, + }; + } + + async put(key: string, value: string | ReadableStream | ArrayBuffer | FormData) { + this.data.set(key, value); + } + + async delete(key: string) { + this.data.delete(key); + } + + async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ + keys: { name: string; expiration?: number; metadata?: unknown }[]; + list_complete: boolean; + cursor: string; + }> { + let keys: { name: string }[] = []; + + for (let key of this.data.keys()) { + if (options?.prefix && !key.startsWith(options.prefix)) { + continue; + } + + keys.push({ name: key }); + } + + return { + keys, + cursor: '0', + list_complete: true, + }; + } +} + +export const kvOrLocal = (namespace: KVNamespace | null): KVNamespace => + namespace || new EmulatedKV(); diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json new file mode 100644 index 0000000..657b76e --- /dev/null +++ b/packages/worker-utils/package.json @@ -0,0 +1,10 @@ +{ + "name": "@roleypoly/worker-utils", + "version": "0.1.0", + "scripts": { + "lint:types": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^2.2.2" + } +} diff --git a/packages/api/router.ts b/packages/worker-utils/router.ts similarity index 65% rename from packages/api/router.ts rename to packages/worker-utils/router.ts index dbd1313..8e841ad 100644 --- a/packages/api/router.ts +++ b/packages/worker-utils/router.ts @@ -1,6 +1,3 @@ -import { addCORS } from './utils/api-tools'; -import { uiPublicURI } from './utils/config'; - export type Handler = (request: Request) => Promise | Response; type RoutingTree = { @@ -23,7 +20,11 @@ export class Router { 500: this.serverError, }; - private uiURL = new URL(uiPublicURI); + private corsOrigins: string[] = []; + + addCORSOrigins(origins: string[]) { + this.corsOrigins = [...this.corsOrigins, ...origins]; + } addFallback(which: keyof Fallbacks, handler: Handler) { this.fallbacks[which] = handler; @@ -40,6 +41,12 @@ export class Router { } async handle(request: Request): Promise { + const response = await this.processRequest(request); + this.injectCORSHeaders(request, response.headers); + return response; + } + + private async processRequest(request: Request): Promise { const url = new URL(request.url); if (url.pathname === '/' || url.pathname === '') { @@ -60,7 +67,7 @@ export class Router { } if (lowerMethod === 'options') { - return new Response(null, addCORS({})); + return new Response(null, {}); } return this.fallbacks[404](request); @@ -81,4 +88,24 @@ export class Router { status: 500, }); } + + private injectCORSHeaders(request: Request, headers: Headers) { + headers.set('access-control-allow-methods', '*'); + headers.set('access-control-allow-headers', '*'); + + if (this.corsOrigins.length === 0) { + headers.set('access-control-allow-origin', '*'); + return; + } + + const originHeader = request.headers.get('origin'); + if (!originHeader) { + return; + } + + const originHostname = new URL(originHeader).hostname; + if (this.corsOrigins.includes(originHostname)) { + headers.set('access-control-allow-origin', originHostname); + } + } } diff --git a/packages/worker-utils/tsconfig.json b/packages/worker-utils/tsconfig.json new file mode 100644 index 0000000..1b989a0 --- /dev/null +++ b/packages/worker-utils/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"], + "types": ["@cloudflare/workers-types"], + "target": "ES2019" + }, + "include": [ + "./*.ts", + "./**/*.ts", + "../../node_modules/@cloudflare/workers-types/index.d.ts" + ], + "exclude": ["./**/*.spec.ts", "./dist/**"], + "extends": "../../tsconfig.json" +} diff --git a/terraform/.gitignore b/terraform/.gitignore index 0b9b63f..4270e02 100644 --- a/terraform/.gitignore +++ b/terraform/.gitignore @@ -31,3 +31,5 @@ override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc + +worker-dist \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index d1503a0..cb8e26b 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -2,48 +2,48 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { - version = "2.23.0" - constraints = ">= 2.17.0" + version = "2.24.0" + constraints = ">= 2.23.0" hashes = [ - "h1:J96tC2Kxa/7rwixUSFX8fXXRiacmIwRMOt7RGfxGUqA=", - "zh:11008f1455ec8f7d3637d60e3b90d0e367b816289d6d99e61370e7fd2f906ba3", - "zh:1d84ee317977ea925d5249cd33ce78258fd0c0e34892e0658ce2b9ea6172c006", - "zh:207e2b21b8a9b48e1dbaf088da18a9ce197f54759fb5a6d73869e2089981b351", - "zh:25d13eba622c684ad3fc79ac2581b7dae25fa563b26004291150212f551b82d7", - "zh:268a0d143825630665063d99d5ca053298be3fdf01e553e0bff098dc971ede4b", - "zh:2c3e231a0b4091801b3fbec8b49cc43248e63222bd18ed87eecdd0c91ad37be7", - "zh:49452aa77543b7a623080b072c2fc5dbab21ea13304230a91547a536749a8201", - "zh:6550cbdea723eec29530ef9430fcc9c6ceb2f9dd76e7e44dece0755dbc0cfed6", - "zh:7dac5f84a2bb20d1ef3c5dc53c622c4b7cfef51eb93f90379184b14accd1feff", - "zh:813dd50b61bb3fce7594359a2c40cd3f7971d0c33eeda1f8c7c29775e35366ce", - "zh:982a5629e8362a5ed7a21bb98339af918215ab8dfe6e7221109d8af87cc44006", - "zh:a0446f88d88ddee7bf058638f59238bde9a0d3f447f78cafe6764265513b931c", - "zh:af6e9acb939fec7151ba1be0c43ccbb7d8fb122d6fb7f4c85a1872a2f8b88edc", + "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", ] } provider "registry.terraform.io/hashicorp/google" { - version = "3.74.0" - constraints = ">= 3.51.1" + version = "3.77.0" + constraints = ">= 3.74.0" hashes = [ - "h1:iHcaHU3r7c8tAG1tNIqR4frT1g1Tc392IHusr02VlEM=", - "zh:0169f9853e54deeb6b65907b8e6d1d0bb015b257de88b9e84f98e6da1021d094", - "zh:1615f8c7463b79c24dcc068bfa13cc75d1a401d5506b75d1cf86169d7ffb6da7", - "zh:356962bacea47ea82a640df88bbc0f07ab170fcc552a5852169be0539a166dd3", - "zh:6f2f3d88b46d99f6c527e3746b1f0fe208a8ce57cd18b1bb1679dbefeade00a0", - "zh:88843d6863ddf1ee6fb7b13c5d211ba6c02c17d19ec49a99131a06eef839f865", - "zh:9165dd6e02fd05ba824002cc32653f6b5d5ebf999c63adf3ded0955c0a8ca7c5", - "zh:b0a7f3808a0a3796260bff1b84e5230f680bf8fc92236911b8a5760c698174e1", - "zh:bf47f15d89cc44c176e4e8a285dfc99e913156062c93139932763124fc86b5a1", - "zh:da10d6e6f0a19198984573214c949fd7e5d7204b9f188fcd20660809cbce9a0a", - "zh:dd10891b9acef8e047df2e41418014adb17c08d48a03eb31ebf952dcb150d4ec", - "zh:f7a856f000f62867acb4c891256a14c00e6c40d071bb0aca3a35a0027bc00707", + "h1:4hiayrO14LjGUzCEOHLRP/+Znuq+/mFsNaOPIvm+nnU=", + "zh:0dfa53acdc6cd81973424e5b4497e37c4538db1e6ed5818ed0e96f837a31b286", + "zh:1e54cffecddf069d682f7f45d99c18a49d86afd590af6be02d50397b04e468ec", + "zh:21be65dd260ebf5f4130e4b9a719e3b260fc6f2e80c16a50f73a47fdbfe69c97", + "zh:2955f3af0db620eb63f8c631448d2fd4566c4a270e655ce7e6bf8fa13806d7c6", + "zh:2d3e9b876557c7d2406a438114b2ddf24a805418c3601ef7c550980508965650", + "zh:2f6cf592606e7a198fa275e93ce39dbf8a76f916f4a0842543f45ebd5a3d281c", + "zh:59a7d05f3309078735b82640582dd4683605c7c10eaa41136c348bfa2d1e54a6", + "zh:6fc3d947db6bbd222bbfc658bf7a27ac9f144570bebe0ce41ce6df95bee63635", + "zh:83b1eca52c25971d2fd2ad0a733156236383680832ef54d3c59d3f385a05f510", + "zh:86e4c542c4ddebca82668dd8bfe3f86808b60bbd9c4edf0c08d37c758f6d57d3", + "zh:8bd36a0df91862c003ca6a204ad5715a36d72b9a26a63e1378c18139f34b39c1", ] } provider "registry.terraform.io/hashicorp/null" { version = "3.1.0" - constraints = ">= 3.0.0" + constraints = ">= 3.1.0" hashes = [ "h1:vpC6bgUQoJ0znqIKVFevOdq+YQw42bRq0u+H3nto8nA=", "zh:02a1675fd8de126a00460942aaae242e65ca3380b5bb192e8773ef3da9073fd2", @@ -62,7 +62,7 @@ provider "registry.terraform.io/hashicorp/null" { provider "registry.terraform.io/hashicorp/random" { version = "3.1.0" - constraints = ">= 3.0.0" + constraints = ">= 3.1.0" hashes = [ "h1:BZMEPucF+pbu9gsPk0G0BHx7YP04+tKdq2MrRDF1EDM=", "zh:2bbb3339f0643b5daa07480ef4397bd23a79963cc364cdfbb4e86354cb7725bc", @@ -81,7 +81,7 @@ provider "registry.terraform.io/hashicorp/random" { provider "registry.terraform.io/hashicorp/tls" { version = "3.1.0" - constraints = ">= 3.0.0" + constraints = ">= 3.1.0" hashes = [ "h1:fUJX8Zxx38e2kBln+zWr1Tl41X+OuiE++REjrEyiOM4=", "zh:3d46616b41fea215566f4a957b6d3a1aa43f1f75c26776d72a98bdba79439db6", @@ -97,3 +97,23 @@ provider "registry.terraform.io/hashicorp/tls" { "zh:fc1e12b713837b85daf6c3bb703d7795eaf1c5177aebae1afcf811dd7009f4b0", ] } + +provider "registry.terraform.io/roleypoly/discord-interactions" { + version = "0.0.2" + constraints = ">= 0.0.1" + hashes = [ + "h1:YjCqc/W26R2UX/7MOD542QKhqadtGJLddDkqNPz/bR4=", + "zh:0321498537de78f8e56ce201bf35f442ca16ad704a49c6540674603e932ad72b", + "zh:0d27a0fb3387ccf5ca36980015a0d18515e59dd0433265c2e6541e7578b1c197", + "zh:23308a7402a3fc0fd39bf3f77fc94236c11cef177c6d3d162da246a6fd3bc107", + "zh:3aaac8a98375f195e313bdb61956c01846cb6d63e6fbd6833a8e0dcd269d94e6", + "zh:40182705dabf1c1c4581a5a485b61b897695b7d046a5b576611c0559d1dedc74", + "zh:649183c25b6ce5a5683b2e3088c5ffa163bf36fcb8243326fd38beabe2f2ec9e", + "zh:69f74ae5fe75f045c1b0c9df63e306b049e3cdd437e73fadd6292e0d517c97db", + "zh:9c5c6f73d8d08fefdb3342b3da6ec8a8d3e2c3732a684bd4c6fe47123ab328ab", + "zh:b0803665993565705a732f787949f5e4c095300761c207325225ce6bcc1dcaa2", + "zh:d54ff5a38e252719934ad836b369135e7300675575369e07a39313b08a14aeab", + "zh:d583722f547c37a5ba8750ccc5ecd7c010c444965bac9716f052ffb445a96cb8", + "zh:e20aebc9cb94e513396d6b94881952b0761fca4bb73e51f19fb856cd395e91a7", + ] +} diff --git a/terraform/workers.tf b/terraform/api.tf similarity index 94% rename from terraform/workers.tf rename to terraform/api.tf index e6edc0d..26b6bc6 100644 --- a/terraform/workers.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 new file mode 100644 index 0000000..692a261 --- /dev/null +++ b/terraform/interactions.tf @@ -0,0 +1,120 @@ +locals { + internalTestingGuilds = toset([ + "386659935687147521" + ]) +} + +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 + + name = "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 + required = true + } +} + +resource "discord-interactions_guild_command" "pick-role" { + for_each = local.internalTestingGuilds + guild_id = each.value + + name = "pick-role" + description = "**[TEST]** Pick a role! (See which ones can be picked with /pickable-roles)" + + + option { + name = "role" + description = "The role you want" + type = 8 + required = true + } +} + +resource "discord-interactions_guild_command" "remove-role" { + for_each = local.internalTestingGuilds + guild_id = each.value + + name = "remove-role" + description = "**[TEST]** Pick a role to remove (See which ones can be removed with /pickable-roles)" + + option { + name = "role" + description = "The role you want to remove" + type = 8 + required = true + } +} + +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}") + + secret_text_binding { + name = "DISCORD_PUBLIC_KEY" + 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 + } + + plain_text_binding { + name = "API_PUBLIC_URI" + text = var.api_public_uri + } +} + +resource "cloudflare_record" "interactions" { + zone_id = var.cloudflare_zone_id + name = "interactions-${var.environment_tag}" + type = "AAAA" + value = "100::" + proxied = true +} + +resource "cloudflare_worker_route" "interactions" { + zone_id = var.cloudflare_zone_id + pattern = "interactions-${var.environment_tag}.roleypoly.com/*" + script_name = cloudflare_worker_script.interactions.name +} diff --git a/terraform/providers.tf b/terraform/providers.tf index 733ff67..2e3e5a0 100644 --- a/terraform/providers.tf +++ b/terraform/providers.tf @@ -30,6 +30,11 @@ terraform { version = ">=3.1.0" source = "hashicorp/tls" } + + discord-interactions = { + source = "roleypoly/discord-interactions" + version = ">=0.0.1" + } } backend "gcs" { @@ -83,3 +88,7 @@ provider "google-beta" { region = var.gcp_region } +provider "discord-interactions" { + application_id = var.bot_client_id + bot_token = var.bot_token +} diff --git a/terraform/variables.tf b/terraform/variables.tf index 11a6cfb..4efee8b 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -59,8 +59,14 @@ variable "api_public_uri" { variable "api_path_to_worker" { type = string - description = "Path to worker JS, relative to this file/terraform folder." - default = "worker-dist/backend-worker.js" + description = "Path to API worker JS, relative to this file/terraform folder." + default = "worker-dist/api.js" +} + +variable "interactions_path_to_worker" { + type = string + description = "Path to interactions worker JS, relative to this file/terraform folder." + default = "worker-dist/interactions.js" } variable "root_users" { @@ -86,7 +92,18 @@ 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 = "" } + +variable "discord_public_key" { + type = string + description = "Discord Interactions Public Key" +} diff --git a/tsconfig.json b/tsconfig.json index cc23a2a..1ed7420 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,5 +28,11 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "**/*.stories.tsx", "packages/api"] + "exclude": [ + "node_modules", + "**/*.stories.tsx", + "packages/api", + "packages/interactions", + "packages/worker-utils" + ] } diff --git a/yarn.lock b/yarn.lock index 0b2dcf4..4d2fbda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3585,6 +3585,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.0.1.tgz#70cedfda26af7a2ca073fdcc9beb2fff4aa693f8" integrity sha512-hBOx4SUlEPKwRi6PrXuTGw1z6lz0fjsibcWCM378YxsSu/6+C30L6CR49zIBKHiwNWCYIcOLjg4OHKZaFeLAug== +"@types/node@^16.4.10": + version "16.4.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.4.10.tgz#e57e2a54fc6da58da94b3571b1cb456d39f88597" + integrity sha512-TmVHsm43br64js9BqHWqiDZA+xMtbUpI1MBIA0EyiBmoV9pcEYFOSdj5fr6enZNfh4fChh+AGOLIzGwJnkshyQ== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"