mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-16 01:29:09 +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
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',
|
||||
]),
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue