mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 11:29:12 +00:00
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
This commit is contained in:
parent
dde05c402e
commit
066f68ffef
59 changed files with 1219 additions and 248 deletions
|
@ -2,6 +2,7 @@
|
||||||
BOT_CLIENT_ID=000000000000000000
|
BOT_CLIENT_ID=000000000000000000
|
||||||
BOT_CLIENT_SECRET=RnX8pXXXXXXXXXXXXXXXXXXXXXXXXXu-
|
BOT_CLIENT_SECRET=RnX8pXXXXXXXXXXXXXXXXXXXXXXXXXu-
|
||||||
BOT_TOKEN=Mzk2MjI3MTM0MjI3NXXXXXXXXXXXXXXXXXXXXXPUlYoARXXXXXXXXXXXXXX
|
BOT_TOKEN=Mzk2MjI3MTM0MjI3NXXXXXXXXXXXXXXXXXXXXXPUlYoARXXXXXXXXXXXXXX
|
||||||
|
DISCORD_PUBLIC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
|
||||||
|
|
||||||
# Comma separated; put your user ID here. Gives elevated permissions to everything.
|
# Comma separated; put your user ID here. Gives elevated permissions to everything.
|
||||||
ROOT_USERS=62601275618889728
|
ROOT_USERS=62601275618889728
|
||||||
|
|
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
|
@ -38,6 +38,11 @@ jobs:
|
||||||
name: Worker Build & Publish
|
name: Worker Build & Publish
|
||||||
needs:
|
needs:
|
||||||
- node_test
|
- node_test
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
worker:
|
||||||
|
- api
|
||||||
|
- interactions
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@master
|
- uses: actions/checkout@master
|
||||||
|
|
||||||
|
@ -55,7 +60,7 @@ jobs:
|
||||||
- name: Check if already deployed
|
- name: Check if already deployed
|
||||||
id: check
|
id: check
|
||||||
run: |
|
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::1 \
|
||||||
|| echo ::set-output name=skip::0
|
|| echo ::set-output name=skip::0
|
||||||
|
|
||||||
|
@ -81,16 +86,17 @@ jobs:
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
wrangler init
|
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
|
wrangler build
|
||||||
|
mv worker/script.js worker/${{ matrix.worker }}.js
|
||||||
if: steps.check.outputs.skip == '0'
|
if: steps.check.outputs.skip == '0'
|
||||||
|
|
||||||
- id: upload-file
|
- id: upload-file
|
||||||
if: github.event_name == 'push' && steps.check.outputs.skip == '0'
|
if: github.event_name == 'push' && steps.check.outputs.skip == '0'
|
||||||
uses: google-github-actions/upload-cloud-storage@main
|
uses: google-github-actions/upload-cloud-storage@main
|
||||||
with:
|
with:
|
||||||
path: worker/script.js
|
path: worker/${{ matrix.worker }}.js
|
||||||
destination: roleypoly-artifacts/backend-worker/${{ github.sha }}
|
destination: roleypoly-artifacts/workers/${{ github.sha }}
|
||||||
credentials: ${{ secrets.GCS_TF_KEY }}
|
credentials: ${{ secrets.GCS_TF_KEY }}
|
||||||
|
|
||||||
docker_build:
|
docker_build:
|
||||||
|
|
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
|
@ -49,7 +49,7 @@ jobs:
|
||||||
selected="${targetArtifact:-$currentHash}"
|
selected="${targetArtifact:-$currentHash}"
|
||||||
|
|
||||||
mkdir worker-dist
|
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
|
- name: Terraform init
|
||||||
working-directory: ./terraform
|
working-directory: ./terraform
|
||||||
|
@ -60,7 +60,7 @@ jobs:
|
||||||
working-directory: ./terraform
|
working-directory: ./terraform
|
||||||
run: |
|
run: |
|
||||||
echo \
|
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 . \
|
| jq . \
|
||||||
| tee tags.auto.tfvars.json
|
| tee tags.auto.tfvars.json
|
||||||
|
|
||||||
|
|
|
@ -28,10 +28,13 @@
|
||||||
"lint:terraform": "terraform fmt -recursive -check ./terraform",
|
"lint:terraform": "terraform fmt -recursive -check ./terraform",
|
||||||
"lint:types": "tsc --noEmit",
|
"lint:types": "tsc --noEmit",
|
||||||
"lint:types-api": "yarn workspace @roleypoly/api run lint:types",
|
"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",
|
"postinstall": "is-ci || husky install",
|
||||||
"start": "run-p -c start:*",
|
"start": "run-p -c start:*",
|
||||||
"start:bot": "yarn workspace @roleypoly/bot start",
|
"start:bot": "yarn workspace @roleypoly/bot start",
|
||||||
"start:design-system": "yarn workspace @roleypoly/design-system 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:web": "yarn workspace @roleypoly/web start",
|
||||||
"start:worker": "yarn workspace @roleypoly/api start",
|
"start:worker": "yarn workspace @roleypoly/api start",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
|
|
|
@ -7,10 +7,13 @@ type URLParams = {
|
||||||
clientID: string;
|
clientID: string;
|
||||||
permissions: number;
|
permissions: number;
|
||||||
guildID?: string;
|
guildID?: string;
|
||||||
|
scopes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildURL = (params: URLParams) => {
|
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) {
|
if (params.guildID) {
|
||||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||||
|
@ -31,6 +34,7 @@ export const BotJoin = (request: Request): Response => {
|
||||||
clientID: botClientID,
|
clientID: botClientID,
|
||||||
permissions: 268435456,
|
permissions: 268435456,
|
||||||
guildID,
|
guildID,
|
||||||
|
scopes: ['bot', 'application.commands'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
|
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
|
||||||
import { accessControlViolation } from '@roleypoly/api/utils/responses';
|
import { accessControlViolation } from '@roleypoly/api/utils/responses';
|
||||||
import { DiscordUser, GuildSlug, PresentableGuild, SessionData } from '@roleypoly/types';
|
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';
|
import { getGuild, getGuildData, getGuildMember } from '../utils/guild';
|
||||||
|
|
||||||
const fail = () => respond({ error: 'guild not found' }, { status: 404 });
|
const fail = () => respond({ error: 'guild not found' }, { status: 404 });
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { SessionData } from '@roleypoly/types';
|
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 => {
|
export const GetSession = withSession((session?: SessionData) => (): Response => {
|
||||||
const { user, guilds, sessionID } = session || {};
|
const { user, guilds, sessionID } = session || {};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { GuildSlug } from '@roleypoly/types';
|
import { GuildSlug } from '@roleypoly/types';
|
||||||
import { respond } from '../utils/api-tools';
|
import { respond } from '@roleypoly/worker-utils';
|
||||||
import { getGuild } from '../utils/guild';
|
import { getGuild } from '../utils/guild';
|
||||||
|
|
||||||
export const GetSlug = async (request: Request): Promise<Response> => {
|
export const GetSlug = async (request: Request): Promise<Response> => {
|
||||||
|
|
104
packages/api/handlers/interactions-pick-role.ts
Normal file
104
packages/api/handlers/interactions-pick-role.ts
Normal file
|
@ -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<Response> => {
|
||||||
|
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<Member>(
|
||||||
|
`/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();
|
||||||
|
}
|
||||||
|
);
|
33
packages/api/handlers/interactions-pickable-roles.ts
Normal file
33
packages/api/handlers/interactions-pickable-roles.ts
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
|
@ -1,5 +1,6 @@
|
||||||
import { StateSession } from '@roleypoly/types';
|
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 { Bounce } from '../utils/bounce';
|
||||||
import { apiPublicURI, botClientID } from '../utils/config';
|
import { apiPublicURI, botClientID } from '../utils/config';
|
||||||
|
|
||||||
|
|
|
@ -5,25 +5,22 @@ import {
|
||||||
SessionData,
|
SessionData,
|
||||||
StateSession,
|
StateSession,
|
||||||
} from '@roleypoly/types';
|
} from '@roleypoly/types';
|
||||||
import KSUID from 'ksuid';
|
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
|
discordAPIBase,
|
||||||
discordFetch,
|
discordFetch,
|
||||||
|
userAgent,
|
||||||
|
} from '@roleypoly/worker-utils';
|
||||||
|
import KSUID from 'ksuid';
|
||||||
|
import {
|
||||||
formData,
|
formData,
|
||||||
getStateSession,
|
getStateSession,
|
||||||
isAllowedCallbackHost,
|
isAllowedCallbackHost,
|
||||||
parsePermissions,
|
parsePermissions,
|
||||||
resolveFailures,
|
resolveFailures,
|
||||||
userAgent,
|
|
||||||
} from '../utils/api-tools';
|
} from '../utils/api-tools';
|
||||||
import { Bounce } from '../utils/bounce';
|
import { Bounce } from '../utils/bounce';
|
||||||
import {
|
import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config';
|
||||||
apiPublicURI,
|
|
||||||
botClientID,
|
|
||||||
botClientSecret,
|
|
||||||
discordAPIBase,
|
|
||||||
uiPublicURI,
|
|
||||||
} from '../utils/config';
|
|
||||||
import { Sessions } from '../utils/kv';
|
import { Sessions } from '../utils/kv';
|
||||||
|
|
||||||
const AuthErrorResponse = (extra?: string) =>
|
const AuthErrorResponse = (extra?: string) =>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { SessionData } from '@roleypoly/types';
|
import { SessionData } from '@roleypoly/types';
|
||||||
import { formData, respond, userAgent, withSession } from '../utils/api-tools';
|
import { discordAPIBase, respond, userAgent } from '@roleypoly/worker-utils';
|
||||||
import { botClientID, botClientSecret, discordAPIBase } from '../utils/config';
|
import { formData, withSession } from '../utils/api-tools';
|
||||||
|
import { botClientID, botClientSecret } from '../utils/config';
|
||||||
import { Sessions } from '../utils/kv';
|
import { Sessions } from '../utils/kv';
|
||||||
|
|
||||||
export const RevokeSession = withSession(
|
export const RevokeSession = withSession(
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
|
|
||||||
import { accessControlViolation } from '@roleypoly/api/utils/responses';
|
|
||||||
import {
|
import {
|
||||||
GuildData,
|
GuildData,
|
||||||
Member,
|
Member,
|
||||||
|
@ -10,9 +8,10 @@ import {
|
||||||
SessionData,
|
SessionData,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
} from '@roleypoly/types';
|
} from '@roleypoly/types';
|
||||||
|
import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils';
|
||||||
import { difference, groupBy, keyBy, union } from 'lodash';
|
import { difference, groupBy, keyBy, union } from 'lodash';
|
||||||
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
|
import { withSession } from '../utils/api-tools';
|
||||||
import { botToken } from '../utils/config';
|
import { botToken, uiPublicURI } from '../utils/config';
|
||||||
import {
|
import {
|
||||||
getGuild,
|
getGuild,
|
||||||
getGuildData,
|
getGuildData,
|
||||||
|
@ -57,10 +56,6 @@ export const UpdateRoles = withSession(
|
||||||
|
|
||||||
const guildData = await getGuildData(guildID);
|
const guildData = await getGuildData(guildID);
|
||||||
|
|
||||||
if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) {
|
|
||||||
return accessControlViolation();
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRoles = calculateNewRoles({
|
const newRoles = calculateNewRoles({
|
||||||
currentRoles: guildMember.roles,
|
currentRoles: guildMember.roles,
|
||||||
guildRoles: guild.roles,
|
guildRoles: guild.roles,
|
||||||
|
@ -76,7 +71,7 @@ export const UpdateRoles = withSession(
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'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({
|
body: JSON.stringify({
|
||||||
roles: newRoles,
|
roles: newRoles,
|
||||||
|
|
|
@ -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 { BotJoin } from './handlers/bot-join';
|
||||||
import { ClearGuildCache } from './handlers/clear-guild-cache';
|
import { ClearGuildCache } from './handlers/clear-guild-cache';
|
||||||
import { GetPickerData } from './handlers/get-picker-data';
|
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 { SyncFromLegacy } from './handlers/sync-from-legacy';
|
||||||
import { UpdateGuild } from './handlers/update-guild';
|
import { UpdateGuild } from './handlers/update-guild';
|
||||||
import { UpdateRoles } from './handlers/update-roles';
|
import { UpdateRoles } from './handlers/update-roles';
|
||||||
import { Router } from './router';
|
|
||||||
import { respond } from './utils/api-tools';
|
import { respond } from './utils/api-tools';
|
||||||
import { uiPublicURI } from './utils/config';
|
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', 'sync-from-legacy', SyncFromLegacy);
|
||||||
router.add('POST', 'clear-guild-cache', ClearGuildCache);
|
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
|
// Tester Routes
|
||||||
router.add('GET', 'x-headers', (request) => {
|
router.add('GET', 'x-headers', (request) => {
|
||||||
const headers: { [x: string]: string } = {};
|
const headers: { [x: string]: string } = {};
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
"@roleypoly/misc-utils": "*",
|
"@roleypoly/misc-utils": "*",
|
||||||
"@roleypoly/types": "*",
|
"@roleypoly/types": "*",
|
||||||
"@roleypoly/worker-emulator": "*",
|
"@roleypoly/worker-emulator": "*",
|
||||||
|
"@roleypoly/worker-utils": "*",
|
||||||
"@types/deep-equal": "^1.0.1",
|
"@types/deep-equal": "^1.0.1",
|
||||||
"deep-equal": "^2.0.5",
|
"deep-equal": "^2.0.5",
|
||||||
"ksuid": "^2.0.0",
|
"ksuid": "^2.0.0",
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
|
import { notAuthenticated } from '@roleypoly/api/utils/responses';
|
||||||
import {
|
import {
|
||||||
evaluatePermission,
|
evaluatePermission,
|
||||||
permissions as Permissions,
|
permissions as Permissions,
|
||||||
} from '@roleypoly/misc-utils/hasPermission';
|
} from '@roleypoly/misc-utils/hasPermission';
|
||||||
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
|
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
|
||||||
|
import { Handler, WrappedKVNamespace } from '@roleypoly/worker-utils';
|
||||||
import KSUID from 'ksuid';
|
import KSUID from 'ksuid';
|
||||||
import { Handler } from '../router';
|
import {
|
||||||
import { allowedCallbackHosts, apiPublicURI, discordAPIBase, rootUsers } from './config';
|
allowedCallbackHosts,
|
||||||
import { Sessions, WrappedKVNamespace } from './kv';
|
apiPublicURI,
|
||||||
|
interactionsSharedKey,
|
||||||
|
rootUsers,
|
||||||
|
} from './config';
|
||||||
|
import { Sessions } from './kv';
|
||||||
|
|
||||||
export const formData = (obj: Record<string, any>): string => {
|
export const formData = (obj: Record<string, any>): string => {
|
||||||
return Object.keys(obj)
|
return Object.keys(obj)
|
||||||
|
@ -14,18 +20,8 @@ export const formData = (obj: Record<string, any>): string => {
|
||||||
.join('&');
|
.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<string, any>, init: ResponseInit = {}) =>
|
export const respond = (obj: Record<string, any>, init: ResponseInit = {}) =>
|
||||||
new Response(JSON.stringify(obj), addCORS(init));
|
new Response(JSON.stringify(obj), init);
|
||||||
|
|
||||||
export const resolveFailures =
|
export const resolveFailures =
|
||||||
(
|
(
|
||||||
|
@ -70,44 +66,6 @@ export const getSessionID = (request: Request): { type: string; id: string } | n
|
||||||
return { type, id };
|
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 <T>(
|
|
||||||
url: string,
|
|
||||||
auth: string,
|
|
||||||
authType: AuthType = AuthType.Bearer,
|
|
||||||
init?: RequestInit
|
|
||||||
): Promise<T | null> => {
|
|
||||||
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 = {
|
export type CacheLayerOptions = {
|
||||||
skipCachePull?: boolean;
|
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 => {
|
export const isAllowedCallbackHost = (host: string): boolean => {
|
||||||
return (
|
return (
|
||||||
host === apiPublicURI ||
|
host === apiPublicURI ||
|
||||||
|
@ -215,3 +163,14 @@ export const isAllowedCallbackHost = (host: string): boolean => {
|
||||||
null
|
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);
|
||||||
|
};
|
||||||
|
|
|
@ -1,35 +1,21 @@
|
||||||
import { userAgent } from '@roleypoly/api/utils/api-tools';
|
|
||||||
import { uiPublicURI } from '@roleypoly/api/utils/config';
|
import { uiPublicURI } from '@roleypoly/api/utils/config';
|
||||||
import {
|
import {
|
||||||
Category,
|
Category,
|
||||||
DiscordUser,
|
DiscordUser,
|
||||||
|
Embed,
|
||||||
GuildData,
|
GuildData,
|
||||||
GuildDataUpdate,
|
GuildDataUpdate,
|
||||||
GuildSlug,
|
GuildSlug,
|
||||||
WebhookValidationStatus,
|
WebhookValidationStatus,
|
||||||
} from '@roleypoly/types';
|
} from '@roleypoly/types';
|
||||||
|
import { userAgent } from '@roleypoly/worker-utils';
|
||||||
import deepEqual from 'deep-equal';
|
import deepEqual from 'deep-equal';
|
||||||
import { sortBy, uniq } from 'lodash';
|
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 = {
|
type WebhookPayload = {
|
||||||
username: string;
|
username: string;
|
||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
embeds: WebhookEmbed[];
|
embeds: Embed[];
|
||||||
provider: {
|
provider: {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
@ -39,7 +25,7 @@ type WebhookPayload = {
|
||||||
type ChangeHandler = (
|
type ChangeHandler = (
|
||||||
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
|
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
|
||||||
newValue: GuildData[keyof GuildDataUpdate]
|
newValue: GuildData[keyof GuildDataUpdate]
|
||||||
) => WebhookEmbed[];
|
) => Embed[];
|
||||||
|
|
||||||
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
|
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
|
||||||
message: (oldValue, newValue) => [
|
message: (oldValue, newValue) => [
|
||||||
|
|
|
@ -13,5 +13,4 @@ export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
|
||||||
export const rootUsers = list(env('ROOT_USERS'));
|
export const rootUsers = list(env('ROOT_USERS'));
|
||||||
export const allowedCallbackHosts = list(env('ALLOWED_CALLBACK_HOSTS'));
|
export const allowedCallbackHosts = list(env('ALLOWED_CALLBACK_HOSTS'));
|
||||||
export const importSharedKey = env('BOT_IMPORT_TOKEN');
|
export const importSharedKey = env('BOT_IMPORT_TOKEN');
|
||||||
|
export const interactionsSharedKey = env('INTERACTIONS_SHARED_KEY');
|
||||||
export const discordAPIBase = 'https://discordapp.com/api/v9';
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { Handler } from '@roleypoly/api/router';
|
|
||||||
import {
|
import {
|
||||||
lowPermissions,
|
lowPermissions,
|
||||||
missingParameters,
|
missingParameters,
|
||||||
|
@ -18,14 +17,8 @@ import {
|
||||||
SessionData,
|
SessionData,
|
||||||
UserGuildPermissions,
|
UserGuildPermissions,
|
||||||
} from '@roleypoly/types';
|
} from '@roleypoly/types';
|
||||||
import {
|
import { AuthType, discordFetch, Handler } from '@roleypoly/worker-utils';
|
||||||
AuthType,
|
import { cacheLayer, CacheLayerOptions, isRoot, withSession } from './api-tools';
|
||||||
cacheLayer,
|
|
||||||
CacheLayerOptions,
|
|
||||||
discordFetch,
|
|
||||||
isRoot,
|
|
||||||
withSession,
|
|
||||||
} from './api-tools';
|
|
||||||
import { botClientID, botToken } from './config';
|
import { botClientID, botToken } from './config';
|
||||||
import { GuildData, Guilds } from './kv';
|
import { GuildData, Guilds } from './kv';
|
||||||
import { useRateLimiter } from './rate-limiting';
|
import { useRateLimiter } from './rate-limiting';
|
||||||
|
|
|
@ -1,83 +1,4 @@
|
||||||
export class WrappedKVNamespace {
|
import { kvOrLocal, WrappedKVNamespace } from '@roleypoly/worker-utils';
|
||||||
constructor(private kvNamespace: KVNamespace) {}
|
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
|
||||||
const data = await this.kvNamespace.get(key, 'text');
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(data) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async put<T>(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<string, any> = new Map();
|
|
||||||
|
|
||||||
async get<T>(key: string): Promise<T | null> {
|
|
||||||
if (!this.data.has(key)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.data.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWithMetadata<T, Metadata = unknown>(
|
|
||||||
key: string
|
|
||||||
): KVValueWithMetadata<T, Metadata> {
|
|
||||||
return {
|
|
||||||
value: await this.get<T>(key),
|
|
||||||
metadata: {} as Metadata,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(key: string, value: string | ReadableStream<any> | 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();
|
|
||||||
|
|
||||||
const self = global as any as Record<string, any>;
|
const self = global as any as Record<string, any>;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { WrappedKVNamespace } from './kv';
|
import { WrappedKVNamespace } from '@roleypoly/worker-utils';
|
||||||
|
|
||||||
export const useRateLimiter =
|
export const useRateLimiter =
|
||||||
(kv: WrappedKVNamespace, key: string, timeoutSeconds: number) =>
|
(kv: WrappedKVNamespace, key: string, timeoutSeconds: number) =>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { respond } from './api-tools';
|
import { respond } from '@roleypoly/worker-utils';
|
||||||
|
|
||||||
export const ok = () => respond({ ok: true });
|
export const ok = () => respond({ ok: true });
|
||||||
|
|
||||||
|
@ -20,3 +20,6 @@ export const rateLimited = () =>
|
||||||
|
|
||||||
export const invalid = (obj: any = {}) =>
|
export const invalid = (obj: any = {}) =>
|
||||||
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
|
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
|
||||||
|
|
||||||
|
export const notAuthenticated = () =>
|
||||||
|
respond({ err: 'not authenticated' }, { status: 403 });
|
||||||
|
|
|
@ -12,6 +12,7 @@ module.exports = {
|
||||||
'API_PUBLIC_URI',
|
'API_PUBLIC_URI',
|
||||||
'ROOT_USERS',
|
'ROOT_USERS',
|
||||||
'ALLOWED_CALLBACK_HOSTS',
|
'ALLOWED_CALLBACK_HOSTS',
|
||||||
|
'INTERACTIONS_SHARED_KEY',
|
||||||
]),
|
]),
|
||||||
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
|
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
|
||||||
};
|
};
|
||||||
|
|
|
@ -204,5 +204,6 @@ fork(async () => {
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('starting on http://localhost:6609');
|
const port = args.port || 6609;
|
||||||
server.listen(6609, '0.0.0.0');
|
console.log(`starting on http://localhost:${port}`);
|
||||||
|
server.listen(port, '0.0.0.0');
|
||||||
|
|
7
packages/interactions/bindings.d.ts
vendored
Normal file
7
packages/interactions/bindings.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export {};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
const DISCORD_PUBLIC_KEY: string;
|
||||||
|
const UI_PUBLIC_URI: string;
|
||||||
|
const API_PUBLIC_URI: string;
|
||||||
|
}
|
5
packages/interactions/handlers/healthz.ts
Normal file
5
packages/interactions/handlers/healthz.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { respond } from '@roleypoly/worker-utils';
|
||||||
|
|
||||||
|
export const healthz = async (request: Request): Promise<Response> => {
|
||||||
|
return respond({ ok: true });
|
||||||
|
};
|
58
packages/interactions/handlers/interaction.ts
Normal file
58
packages/interactions/handlers/interaction.ts
Normal file
|
@ -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<InteractionResponse>
|
||||||
|
> = {
|
||||||
|
'hello-world': helloWorld,
|
||||||
|
roleypoly: roleypoly,
|
||||||
|
'pickable-roles': pickableRoles,
|
||||||
|
'pick-role': pickRole('add'),
|
||||||
|
'remove-role': pickRole('remove'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const interactionHandler = async (request: Request): Promise<Response> => {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
};
|
16
packages/interactions/handlers/interactions/hello-world.ts
Normal file
16
packages/interactions/handlers/interactions/hello-world.ts
Normal 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}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
80
packages/interactions/handlers/interactions/pick-role.ts
Normal file
80
packages/interactions/handlers/interactions/pick-role.ts
Normal file
|
@ -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<InteractionResponse> => {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
|
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
56
packages/interactions/handlers/interactions/roleypoly.ts
Normal file
56
packages/interactions/handlers/interactions/roleypoly.ts
Normal file
|
@ -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<InteractionResponse> => {
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
29
packages/interactions/index.ts
Normal file
29
packages/interactions/index.ts
Normal file
|
@ -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));
|
||||||
|
});
|
17
packages/interactions/package.json
Normal file
17
packages/interactions/package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
15
packages/interactions/tsconfig.json
Normal file
15
packages/interactions/tsconfig.json
Normal file
|
@ -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"
|
||||||
|
}
|
41
packages/interactions/utils/api.ts
Normal file
41
packages/interactions/utils/api.ts
Normal file
|
@ -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<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>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectRole = async (
|
||||||
|
mode: 'add' | 'remove',
|
||||||
|
guildID: string,
|
||||||
|
userID: string,
|
||||||
|
roleID: string
|
||||||
|
): Promise<number> => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/interactions-pick-role/${guildID}/${userID}/${roleID}`,
|
||||||
|
{
|
||||||
|
method: mode === 'add' ? 'PUT' : 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.status;
|
||||||
|
};
|
11
packages/interactions/utils/config.ts
Normal file
11
packages/interactions/utils/config.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
const self = global as any as Record<string, string>;
|
||||||
|
|
||||||
|
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');
|
27
packages/interactions/utils/interactions.ts
Normal file
27
packages/interactions/utils/interactions.ts
Normal file
|
@ -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;
|
||||||
|
};
|
29
packages/interactions/utils/responses.ts
Normal file
29
packages/interactions/utils/responses.ts
Normal file
|
@ -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: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
|
||||||
|
flags: InteractionFlags.EPHEMERAL,
|
||||||
|
},
|
||||||
|
});
|
28
packages/interactions/webpack.config.js
Normal file
28
packages/interactions/webpack.config.js
Normal file
|
@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
12
packages/interactions/worker.config.js
Normal file
12
packages/interactions/worker.config.js
Normal file
|
@ -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',
|
||||||
|
]),
|
||||||
|
};
|
|
@ -11,3 +11,8 @@ export type Category = {
|
||||||
type: CategoryType;
|
type: CategoryType;
|
||||||
position: number;
|
position: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CategorySlug = {
|
||||||
|
roles: Category['roles'];
|
||||||
|
type: Category['type'];
|
||||||
|
};
|
||||||
|
|
79
packages/types/Interactions.ts
Normal file
79
packages/types/Interactions.ts
Normal file
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './Category';
|
export * from './Category';
|
||||||
export * from './Guild';
|
export * from './Guild';
|
||||||
|
export * from './Interactions';
|
||||||
export * from './Role';
|
export * from './Role';
|
||||||
export * from './Session';
|
export * from './Session';
|
||||||
export * from './User';
|
export * from './User';
|
||||||
|
|
21
packages/worker-utils/api.ts
Normal file
21
packages/worker-utils/api.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
export const respond = (obj: Record<string, any>, 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;
|
||||||
|
};
|
38
packages/worker-utils/discord.ts
Normal file
38
packages/worker-utils/discord.ts
Normal file
|
@ -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 <T>(
|
||||||
|
url: string,
|
||||||
|
auth: string,
|
||||||
|
authType: AuthType = AuthType.Bearer,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<T | null> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
4
packages/worker-utils/index.ts
Normal file
4
packages/worker-utils/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './api';
|
||||||
|
export * from './discord';
|
||||||
|
export * from './kv';
|
||||||
|
export * from './router';
|
80
packages/worker-utils/kv.ts
Normal file
80
packages/worker-utils/kv.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
export class WrappedKVNamespace {
|
||||||
|
constructor(private kvNamespace: KVNamespace) {}
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const data = await this.kvNamespace.get(key, 'text');
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(data) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(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<string, any> = new Map();
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
if (!this.data.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.data.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWithMetadata<T, Metadata = unknown>(
|
||||||
|
key: string
|
||||||
|
): KVValueWithMetadata<T, Metadata> {
|
||||||
|
return {
|
||||||
|
value: await this.get<T>(key),
|
||||||
|
metadata: {} as Metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(key: string, value: string | ReadableStream<any> | 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();
|
10
packages/worker-utils/package.json
Normal file
10
packages/worker-utils/package.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "@roleypoly/worker-utils",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"lint:types": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^2.2.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,3 @@
|
||||||
import { addCORS } from './utils/api-tools';
|
|
||||||
import { uiPublicURI } from './utils/config';
|
|
||||||
|
|
||||||
export type Handler = (request: Request) => Promise<Response> | Response;
|
export type Handler = (request: Request) => Promise<Response> | Response;
|
||||||
|
|
||||||
type RoutingTree = {
|
type RoutingTree = {
|
||||||
|
@ -23,7 +20,11 @@ export class Router {
|
||||||
500: this.serverError,
|
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) {
|
addFallback(which: keyof Fallbacks, handler: Handler) {
|
||||||
this.fallbacks[which] = handler;
|
this.fallbacks[which] = handler;
|
||||||
|
@ -40,6 +41,12 @@ export class Router {
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(request: Request): Promise<Response> {
|
async handle(request: Request): Promise<Response> {
|
||||||
|
const response = await this.processRequest(request);
|
||||||
|
this.injectCORSHeaders(request, response.headers);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processRequest(request: Request): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (url.pathname === '/' || url.pathname === '') {
|
if (url.pathname === '/' || url.pathname === '') {
|
||||||
|
@ -60,7 +67,7 @@ export class Router {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lowerMethod === 'options') {
|
if (lowerMethod === 'options') {
|
||||||
return new Response(null, addCORS({}));
|
return new Response(null, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fallbacks[404](request);
|
return this.fallbacks[404](request);
|
||||||
|
@ -81,4 +88,24 @@ export class Router {
|
||||||
status: 500,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
15
packages/worker-utils/tsconfig.json
Normal file
15
packages/worker-utils/tsconfig.json
Normal file
|
@ -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"
|
||||||
|
}
|
2
terraform/.gitignore
vendored
2
terraform/.gitignore
vendored
|
@ -31,3 +31,5 @@ override.tf.json
|
||||||
# Ignore CLI configuration files
|
# Ignore CLI configuration files
|
||||||
.terraformrc
|
.terraformrc
|
||||||
terraform.rc
|
terraform.rc
|
||||||
|
|
||||||
|
worker-dist
|
86
terraform/.terraform.lock.hcl
generated
86
terraform/.terraform.lock.hcl
generated
|
@ -2,48 +2,48 @@
|
||||||
# Manual edits may be lost in future updates.
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
provider "registry.terraform.io/cloudflare/cloudflare" {
|
provider "registry.terraform.io/cloudflare/cloudflare" {
|
||||||
version = "2.23.0"
|
version = "2.24.0"
|
||||||
constraints = ">= 2.17.0"
|
constraints = ">= 2.23.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:J96tC2Kxa/7rwixUSFX8fXXRiacmIwRMOt7RGfxGUqA=",
|
"h1:+fGNZaqk0IPH3M5yOsu978u5t9Q5YP1PrGXSggJUlFQ=",
|
||||||
"zh:11008f1455ec8f7d3637d60e3b90d0e367b816289d6d99e61370e7fd2f906ba3",
|
"zh:10bb13bff60c8c9e234b64ea3d8c37be512459f40fdd97aafc5d60631377b46e",
|
||||||
"zh:1d84ee317977ea925d5249cd33ce78258fd0c0e34892e0658ce2b9ea6172c006",
|
"zh:1ba01e5636fe79c205908e55a966cb6249f66a657aca62ea040b5b41717a1763",
|
||||||
"zh:207e2b21b8a9b48e1dbaf088da18a9ce197f54759fb5a6d73869e2089981b351",
|
"zh:1f5870e2602ebaeca40f048c1466e976ac0db66e41297b327ac816001c4090d5",
|
||||||
"zh:25d13eba622c684ad3fc79ac2581b7dae25fa563b26004291150212f551b82d7",
|
"zh:203f03b9aa58e9a7516f09f13cff08c00e8e534921ac597cf05634e793f6c9fe",
|
||||||
"zh:268a0d143825630665063d99d5ca053298be3fdf01e553e0bff098dc971ede4b",
|
"zh:2cae731aeee1c511ba26aa64ecb4537931f5ab467e4bc8e07bbbdf82fe11e6c0",
|
||||||
"zh:2c3e231a0b4091801b3fbec8b49cc43248e63222bd18ed87eecdd0c91ad37be7",
|
"zh:89f0eb8df82407fb48add3fe4dd38817e4625f5986a69259535bdf5b6ac6d281",
|
||||||
"zh:49452aa77543b7a623080b072c2fc5dbab21ea13304230a91547a536749a8201",
|
"zh:952c5a213acdace04f86ca4a79a99f476b7da6f69edd0e616e47fb75aa3b77f9",
|
||||||
"zh:6550cbdea723eec29530ef9430fcc9c6ceb2f9dd76e7e44dece0755dbc0cfed6",
|
"zh:958d08bc7a3ca6275106db0d4251a19fa8a5ad0302652439b3c2cc57a80fed74",
|
||||||
"zh:7dac5f84a2bb20d1ef3c5dc53c622c4b7cfef51eb93f90379184b14accd1feff",
|
"zh:b7797b2fa0377a5c2610d42bf9a1306c1dec4895d5d52e8f7e50340d072d3065",
|
||||||
"zh:813dd50b61bb3fce7594359a2c40cd3f7971d0c33eeda1f8c7c29775e35366ce",
|
"zh:c95a1680531f3c1640d7869d69a4abbc184f36a060462920acc756b6ac6c91d9",
|
||||||
"zh:982a5629e8362a5ed7a21bb98339af918215ab8dfe6e7221109d8af87cc44006",
|
"zh:ca7e8438967d31afb8a73473fff237dcddcefc5e5ca3a3159ab941df1e683de1",
|
||||||
"zh:a0446f88d88ddee7bf058638f59238bde9a0d3f447f78cafe6764265513b931c",
|
"zh:ddfae3c9305aa7299744992e70b61e4919cbe44a4ca561161f77e011a77a0233",
|
||||||
"zh:af6e9acb939fec7151ba1be0c43ccbb7d8fb122d6fb7f4c85a1872a2f8b88edc",
|
"zh:e85b3814322e1f0a73718fec46bbf3a3d0bda3ea86cc6a875b4b584517558051",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "registry.terraform.io/hashicorp/google" {
|
provider "registry.terraform.io/hashicorp/google" {
|
||||||
version = "3.74.0"
|
version = "3.77.0"
|
||||||
constraints = ">= 3.51.1"
|
constraints = ">= 3.74.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:iHcaHU3r7c8tAG1tNIqR4frT1g1Tc392IHusr02VlEM=",
|
"h1:4hiayrO14LjGUzCEOHLRP/+Znuq+/mFsNaOPIvm+nnU=",
|
||||||
"zh:0169f9853e54deeb6b65907b8e6d1d0bb015b257de88b9e84f98e6da1021d094",
|
"zh:0dfa53acdc6cd81973424e5b4497e37c4538db1e6ed5818ed0e96f837a31b286",
|
||||||
"zh:1615f8c7463b79c24dcc068bfa13cc75d1a401d5506b75d1cf86169d7ffb6da7",
|
"zh:1e54cffecddf069d682f7f45d99c18a49d86afd590af6be02d50397b04e468ec",
|
||||||
"zh:356962bacea47ea82a640df88bbc0f07ab170fcc552a5852169be0539a166dd3",
|
"zh:21be65dd260ebf5f4130e4b9a719e3b260fc6f2e80c16a50f73a47fdbfe69c97",
|
||||||
"zh:6f2f3d88b46d99f6c527e3746b1f0fe208a8ce57cd18b1bb1679dbefeade00a0",
|
"zh:2955f3af0db620eb63f8c631448d2fd4566c4a270e655ce7e6bf8fa13806d7c6",
|
||||||
"zh:88843d6863ddf1ee6fb7b13c5d211ba6c02c17d19ec49a99131a06eef839f865",
|
"zh:2d3e9b876557c7d2406a438114b2ddf24a805418c3601ef7c550980508965650",
|
||||||
"zh:9165dd6e02fd05ba824002cc32653f6b5d5ebf999c63adf3ded0955c0a8ca7c5",
|
"zh:2f6cf592606e7a198fa275e93ce39dbf8a76f916f4a0842543f45ebd5a3d281c",
|
||||||
"zh:b0a7f3808a0a3796260bff1b84e5230f680bf8fc92236911b8a5760c698174e1",
|
"zh:59a7d05f3309078735b82640582dd4683605c7c10eaa41136c348bfa2d1e54a6",
|
||||||
"zh:bf47f15d89cc44c176e4e8a285dfc99e913156062c93139932763124fc86b5a1",
|
"zh:6fc3d947db6bbd222bbfc658bf7a27ac9f144570bebe0ce41ce6df95bee63635",
|
||||||
"zh:da10d6e6f0a19198984573214c949fd7e5d7204b9f188fcd20660809cbce9a0a",
|
"zh:83b1eca52c25971d2fd2ad0a733156236383680832ef54d3c59d3f385a05f510",
|
||||||
"zh:dd10891b9acef8e047df2e41418014adb17c08d48a03eb31ebf952dcb150d4ec",
|
"zh:86e4c542c4ddebca82668dd8bfe3f86808b60bbd9c4edf0c08d37c758f6d57d3",
|
||||||
"zh:f7a856f000f62867acb4c891256a14c00e6c40d071bb0aca3a35a0027bc00707",
|
"zh:8bd36a0df91862c003ca6a204ad5715a36d72b9a26a63e1378c18139f34b39c1",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "registry.terraform.io/hashicorp/null" {
|
provider "registry.terraform.io/hashicorp/null" {
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
constraints = ">= 3.0.0"
|
constraints = ">= 3.1.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:vpC6bgUQoJ0znqIKVFevOdq+YQw42bRq0u+H3nto8nA=",
|
"h1:vpC6bgUQoJ0znqIKVFevOdq+YQw42bRq0u+H3nto8nA=",
|
||||||
"zh:02a1675fd8de126a00460942aaae242e65ca3380b5bb192e8773ef3da9073fd2",
|
"zh:02a1675fd8de126a00460942aaae242e65ca3380b5bb192e8773ef3da9073fd2",
|
||||||
|
@ -62,7 +62,7 @@ provider "registry.terraform.io/hashicorp/null" {
|
||||||
|
|
||||||
provider "registry.terraform.io/hashicorp/random" {
|
provider "registry.terraform.io/hashicorp/random" {
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
constraints = ">= 3.0.0"
|
constraints = ">= 3.1.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:BZMEPucF+pbu9gsPk0G0BHx7YP04+tKdq2MrRDF1EDM=",
|
"h1:BZMEPucF+pbu9gsPk0G0BHx7YP04+tKdq2MrRDF1EDM=",
|
||||||
"zh:2bbb3339f0643b5daa07480ef4397bd23a79963cc364cdfbb4e86354cb7725bc",
|
"zh:2bbb3339f0643b5daa07480ef4397bd23a79963cc364cdfbb4e86354cb7725bc",
|
||||||
|
@ -81,7 +81,7 @@ provider "registry.terraform.io/hashicorp/random" {
|
||||||
|
|
||||||
provider "registry.terraform.io/hashicorp/tls" {
|
provider "registry.terraform.io/hashicorp/tls" {
|
||||||
version = "3.1.0"
|
version = "3.1.0"
|
||||||
constraints = ">= 3.0.0"
|
constraints = ">= 3.1.0"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:fUJX8Zxx38e2kBln+zWr1Tl41X+OuiE++REjrEyiOM4=",
|
"h1:fUJX8Zxx38e2kBln+zWr1Tl41X+OuiE++REjrEyiOM4=",
|
||||||
"zh:3d46616b41fea215566f4a957b6d3a1aa43f1f75c26776d72a98bdba79439db6",
|
"zh:3d46616b41fea215566f4a957b6d3a1aa43f1f75c26776d72a98bdba79439db6",
|
||||||
|
@ -97,3 +97,23 @@ provider "registry.terraform.io/hashicorp/tls" {
|
||||||
"zh:fc1e12b713837b85daf6c3bb703d7795eaf1c5177aebae1afcf811dd7009f4b0",
|
"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",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
|
@ -68,6 +68,11 @@ resource "cloudflare_worker_script" "backend" {
|
||||||
name = "ROOT_USERS"
|
name = "ROOT_USERS"
|
||||||
text = join(",", var.root_users)
|
text = join(",", var.root_users)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
secret_text_binding {
|
||||||
|
name = "INTERACTIONS_SHARED_KEY"
|
||||||
|
text = random_password.interactions_token.result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "cloudflare_record" "api" {
|
resource "cloudflare_record" "api" {
|
120
terraform/interactions.tf
Normal file
120
terraform/interactions.tf
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -30,6 +30,11 @@ terraform {
|
||||||
version = ">=3.1.0"
|
version = ">=3.1.0"
|
||||||
source = "hashicorp/tls"
|
source = "hashicorp/tls"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discord-interactions = {
|
||||||
|
source = "roleypoly/discord-interactions"
|
||||||
|
version = ">=0.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
backend "gcs" {
|
backend "gcs" {
|
||||||
|
@ -83,3 +88,7 @@ provider "google-beta" {
|
||||||
region = var.gcp_region
|
region = var.gcp_region
|
||||||
}
|
}
|
||||||
|
|
||||||
|
provider "discord-interactions" {
|
||||||
|
application_id = var.bot_client_id
|
||||||
|
bot_token = var.bot_token
|
||||||
|
}
|
||||||
|
|
|
@ -59,8 +59,14 @@ variable "api_public_uri" {
|
||||||
|
|
||||||
variable "api_path_to_worker" {
|
variable "api_path_to_worker" {
|
||||||
type = string
|
type = string
|
||||||
description = "Path to worker JS, relative to this file/terraform folder."
|
description = "Path to API worker JS, relative to this file/terraform folder."
|
||||||
default = "worker-dist/backend-worker.js"
|
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" {
|
variable "root_users" {
|
||||||
|
@ -86,7 +92,18 @@ variable "bot_tag" {
|
||||||
description = ":tag or @sha265: of ghcr.io/roleypoly/bot"
|
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" {
|
variable "allowed_callback_hosts" {
|
||||||
type = string
|
type = string
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "discord_public_key" {
|
||||||
|
type = string
|
||||||
|
description = "Discord Interactions Public Key"
|
||||||
|
}
|
||||||
|
|
|
@ -28,5 +28,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -3585,6 +3585,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.0.1.tgz#70cedfda26af7a2ca073fdcc9beb2fff4aa693f8"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.0.1.tgz#70cedfda26af7a2ca073fdcc9beb2fff4aa693f8"
|
||||||
integrity sha512-hBOx4SUlEPKwRi6PrXuTGw1z6lz0fjsibcWCM378YxsSu/6+C30L6CR49zIBKHiwNWCYIcOLjg4OHKZaFeLAug==
|
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":
|
"@types/normalize-package-data@^2.4.0":
|
||||||
version "2.4.0"
|
version "2.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
|
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
|
||||||
|
|
Loading…
Add table
Reference in a new issue