mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 19:39:11 +00:00
feat: add /pickable-roles and /pick-role basis
This commit is contained in:
parent
c2ee4f380a
commit
a4207d5713
22 changed files with 343 additions and 23 deletions
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
|
@ -49,7 +49,7 @@ jobs:
|
|||
selected="${targetArtifact:-$currentHash}"
|
||||
|
||||
mkdir worker-dist
|
||||
gsutil cp gs://roleypoly-artifacts/workers/$selected worker-dist
|
||||
gsutil cp -r "gs://roleypoly-artifacts/workers/$selected/*" worker-dist/
|
||||
|
||||
- name: Terraform init
|
||||
working-directory: ./terraform
|
||||
|
@ -60,7 +60,7 @@ jobs:
|
|||
working-directory: ./terraform
|
||||
run: |
|
||||
echo \
|
||||
'{"bot_tag": "${{github.event.inputs.bot_tag}}", "api_path_to_worker": "./worker-dist/backend-worker.js"}' \
|
||||
'{"bot_tag": "${{github.event.inputs.bot_tag}}", "worker_tag": "${{github.event.inputs.worker_tag}}", "api_path_to_worker": "./worker-dist/api.js", "interactions_path_to_worker": "./worker-dist/interactions.js"}' \
|
||||
| jq . \
|
||||
| tee tags.auto.tfvars.json
|
||||
|
||||
|
|
23
packages/api/handlers/interactions-pick-role.ts
Normal file
23
packages/api/handlers/interactions-pick-role.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { interactionsEndpoint } from '../utils/api-tools';
|
||||
import { getGuildData } from '../utils/guild';
|
||||
import { invalid, notFound, ok } from '../utils/responses';
|
||||
|
||||
export const InteractionsPickRole = interactionsEndpoint(
|
||||
async (request: Request): Promise<Response> => {
|
||||
const reqURL = new URL(request.url);
|
||||
const [, , serverID, userID, roleID] = reqURL.pathname.split('/');
|
||||
if (!serverID || !userID || !roleID) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const guildData = await getGuildData(serverID);
|
||||
if (!guildData) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// We get exactly one role, but we have to interact with it the same way as UI does.
|
||||
// So check for safety, disable any "single" mode roles
|
||||
|
||||
return ok();
|
||||
}
|
||||
);
|
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,3 +1,5 @@
|
|||
import { InteractionsPickRole } from '@roleypoly/api/handlers/interactions-pick-role';
|
||||
import { InteractionsPickableRoles } from '@roleypoly/api/handlers/interactions-pickable-roles';
|
||||
import { Router } from '@roleypoly/worker-utils/router';
|
||||
import { BotJoin } from './handlers/bot-join';
|
||||
import { ClearGuildCache } from './handlers/clear-guild-cache';
|
||||
|
@ -32,6 +34,10 @@ router.add('PATCH', 'update-guild', UpdateGuild);
|
|||
router.add('POST', 'sync-from-legacy', SyncFromLegacy);
|
||||
router.add('POST', 'clear-guild-cache', ClearGuildCache);
|
||||
|
||||
// Interactions endpoints
|
||||
router.add('GET', 'interactions-pickable-roles', InteractionsPickableRoles);
|
||||
router.add('POST', 'interactions-pick-role', InteractionsPickRole);
|
||||
|
||||
// Tester Routes
|
||||
router.add('GET', 'x-headers', (request) => {
|
||||
const headers: { [x: string]: string } = {};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { notAuthenticated } from '@roleypoly/api/utils/responses';
|
||||
import {
|
||||
evaluatePermission,
|
||||
permissions as Permissions,
|
||||
|
@ -5,7 +6,12 @@ import {
|
|||
import { SessionData, UserGuildPermissions } from '@roleypoly/types';
|
||||
import { Handler, WrappedKVNamespace } from '@roleypoly/worker-utils';
|
||||
import KSUID from 'ksuid';
|
||||
import { allowedCallbackHosts, apiPublicURI, rootUsers } from './config';
|
||||
import {
|
||||
allowedCallbackHosts,
|
||||
apiPublicURI,
|
||||
interactionsSharedKey,
|
||||
rootUsers,
|
||||
} from './config';
|
||||
import { Sessions } from './kv';
|
||||
|
||||
export const formData = (obj: Record<string, any>): string => {
|
||||
|
@ -157,3 +163,14 @@ export const isAllowedCallbackHost = (host: string): boolean => {
|
|||
null
|
||||
);
|
||||
};
|
||||
|
||||
export const interactionsEndpoint =
|
||||
(handler: Handler): Handler =>
|
||||
async (request: Request): Promise<Response> => {
|
||||
const authHeader = request.headers.get('authorization') || '';
|
||||
if (authHeader !== `Shared ${interactionsSharedKey}`) {
|
||||
return notAuthenticated();
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import { uiPublicURI } from '@roleypoly/api/utils/config';
|
|||
import {
|
||||
Category,
|
||||
DiscordUser,
|
||||
Embed,
|
||||
GuildData,
|
||||
GuildDataUpdate,
|
||||
GuildSlug,
|
||||
|
@ -11,25 +12,10 @@ import { userAgent } from '@roleypoly/worker-utils';
|
|||
import deepEqual from 'deep-equal';
|
||||
import { sortBy, uniq } from 'lodash';
|
||||
|
||||
type WebhookEmbed = {
|
||||
fields: {
|
||||
name: string;
|
||||
value: string;
|
||||
inline: boolean;
|
||||
}[];
|
||||
timestamp: string;
|
||||
title: string;
|
||||
color: number;
|
||||
author?: {
|
||||
name: string;
|
||||
icon_url: string;
|
||||
};
|
||||
};
|
||||
|
||||
type WebhookPayload = {
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
embeds: WebhookEmbed[];
|
||||
embeds: Embed[];
|
||||
provider: {
|
||||
name: string;
|
||||
url: string;
|
||||
|
@ -39,7 +25,7 @@ type WebhookPayload = {
|
|||
type ChangeHandler = (
|
||||
oldValue: GuildDataUpdate[keyof GuildDataUpdate],
|
||||
newValue: GuildData[keyof GuildDataUpdate]
|
||||
) => WebhookEmbed[];
|
||||
) => Embed[];
|
||||
|
||||
const changeHandlers: Record<keyof GuildDataUpdate, ChangeHandler> = {
|
||||
message: (oldValue, newValue) => [
|
||||
|
|
|
@ -13,3 +13,4 @@ export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
|
|||
export const rootUsers = list(env('ROOT_USERS'));
|
||||
export const allowedCallbackHosts = list(env('ALLOWED_CALLBACK_HOSTS'));
|
||||
export const importSharedKey = env('BOT_IMPORT_TOKEN');
|
||||
export const interactionsSharedKey = env('INTERACTIONS_SHARED_KEY');
|
||||
|
|
|
@ -20,3 +20,6 @@ export const rateLimited = () =>
|
|||
|
||||
export const invalid = (obj: any = {}) =>
|
||||
respond({ err: 'client sent something invalid', data: obj }, { status: 400 });
|
||||
|
||||
export const notAuthenticated = () =>
|
||||
respond({ err: 'not authenticated' }, { status: 403 });
|
||||
|
|
|
@ -12,6 +12,7 @@ module.exports = {
|
|||
'API_PUBLIC_URI',
|
||||
'ROOT_USERS',
|
||||
'ALLOWED_CALLBACK_HOSTS',
|
||||
'INTERACTIONS_SHARED_KEY',
|
||||
]),
|
||||
kv: ['KV_SESSIONS', 'KV_GUILDS', 'KV_GUILD_DATA'],
|
||||
};
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { helloWorld } from '@roleypoly/interactions/handlers/interactions/hello-world';
|
||||
import { pickableRoles } from '@roleypoly/interactions/handlers/interactions/pickable-roles';
|
||||
import { roleypoly } from '@roleypoly/interactions/handlers/interactions/roleypoly';
|
||||
import { verifyRequest } from '@roleypoly/interactions/utils/interactions';
|
||||
import { somethingWentWrong } from '@roleypoly/interactions/utils/responses';
|
||||
import {
|
||||
InteractionData,
|
||||
InteractionRequest,
|
||||
|
@ -14,6 +17,8 @@ const commands: Record<
|
|||
(request: InteractionRequestCommand) => Promise<InteractionResponse>
|
||||
> = {
|
||||
'hello-world': helloWorld,
|
||||
roleypoly: roleypoly,
|
||||
'pickable-roles': pickableRoles,
|
||||
};
|
||||
|
||||
export const interactionHandler = async (request: Request): Promise<Response> => {
|
||||
|
@ -44,6 +49,7 @@ export const interactionHandler = async (request: Request): Promise<Response> =>
|
|||
const response = await handler(interaction as InteractionRequestCommand);
|
||||
return respond(response);
|
||||
} catch (e) {
|
||||
return respond({ err: 'command errored' }, { status: 500 });
|
||||
console.error(e);
|
||||
return respond(somethingWentWrong());
|
||||
}
|
||||
};
|
||||
|
|
16
packages/interactions/handlers/interactions/pick-role.ts
Normal file
16
packages/interactions/handlers/interactions/pick-role.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}`,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
28
packages/interactions/handlers/interactions/roleypoly.ts
Normal file
28
packages/interactions/handlers/interactions/roleypoly.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionRequestCommand,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const roleypoly = async (
|
||||
interaction: InteractionRequestCommand
|
||||
): Promise<InteractionResponse> => {
|
||||
if (interaction.guild_id) {
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:beginner: Assign your roles here! ${uiPublicURI}/s/${interaction.guild_id}`,
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: `:beginner: Hey! I don't know what server you're in, so check out ${uiPublicURI}`,
|
||||
},
|
||||
};
|
||||
};
|
25
packages/interactions/utils/api.ts
Normal file
25
packages/interactions/utils/api.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { Category, CategorySlug } from '@roleypoly/types';
|
||||
import { apiPublicURI, interactionsSharedKey } from './config';
|
||||
|
||||
export const apiFetch = (url: string, init: RequestInit = {}) =>
|
||||
fetch(`${apiPublicURI}${url}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
authorization: `Shared ${interactionsSharedKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
export const getPickableRoles = async (
|
||||
guildID: string
|
||||
): Promise<Record<Category['name'], CategorySlug>> => {
|
||||
const response = await apiFetch(`/interactions-pickable-roles/${guildID}`);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`API request failed to /interactions-pickable-roles, got code: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as Record<Category['name'], CategorySlug>;
|
||||
};
|
|
@ -8,3 +8,4 @@ const list = (x: string) => x.split(',');
|
|||
export const uiPublicURI = safeURI(env('UI_PUBLIC_URI'));
|
||||
export const apiPublicURI = safeURI(env('API_PUBLIC_URI'));
|
||||
export const publicKey = safeURI(env('DISCORD_PUBLIC_KEY'));
|
||||
export const interactionsSharedKey = env('INTERACTIONS_SHARED_KEY');
|
||||
|
|
21
packages/interactions/utils/responses.ts
Normal file
21
packages/interactions/utils/responses.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const mustBeInGuild = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: ':x: This command has to be used in a server.',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
||||
|
||||
export const somethingWentWrong = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
|
@ -3,5 +3,10 @@ const reexportEnv = (keys = []) => {
|
|||
};
|
||||
|
||||
module.exports = {
|
||||
environment: reexportEnv(['DISCORD_PUBLIC_KEY', 'UI_PUBLIC_URI', 'API_PUBLIC_URI']),
|
||||
environment: reexportEnv([
|
||||
'DISCORD_PUBLIC_KEY',
|
||||
'UI_PUBLIC_URI',
|
||||
'API_PUBLIC_URI',
|
||||
'INTERACTIONS_SHARED_KEY',
|
||||
]),
|
||||
};
|
||||
|
|
|
@ -11,3 +11,8 @@ export type Category = {
|
|||
type: CategoryType;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type CategorySlug = {
|
||||
roles: Category['roles'];
|
||||
type: Category['type'];
|
||||
};
|
||||
|
|
|
@ -41,12 +41,16 @@ export enum InteractionCallbackType {
|
|||
UPDATE_MESSAGE = 7,
|
||||
}
|
||||
|
||||
export enum InteractionFlags {
|
||||
EPHEMERAL = 1 << 6,
|
||||
}
|
||||
|
||||
export type InteractionCallbackData = {
|
||||
tts?: boolean;
|
||||
content?: string;
|
||||
embeds?: {};
|
||||
allowed_mentions?: {};
|
||||
flags?: number;
|
||||
flags?: InteractionFlags;
|
||||
components?: {}[];
|
||||
};
|
||||
|
||||
|
@ -54,3 +58,18 @@ export type InteractionResponse = {
|
|||
type: InteractionCallbackType;
|
||||
data?: InteractionCallbackData;
|
||||
};
|
||||
|
||||
export type Embed = {
|
||||
fields: {
|
||||
name: string;
|
||||
value: string;
|
||||
inline?: boolean;
|
||||
}[];
|
||||
timestamp?: string;
|
||||
title: string;
|
||||
color: number;
|
||||
author?: {
|
||||
name: string;
|
||||
icon_url: string;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -68,6 +68,11 @@ resource "cloudflare_worker_script" "backend" {
|
|||
name = "ROOT_USERS"
|
||||
text = join(",", var.root_users)
|
||||
}
|
||||
|
||||
secret_text_binding {
|
||||
name = "INTERACTIONS_SHARED_KEY"
|
||||
text = random_password.interactions_token.result
|
||||
}
|
||||
}
|
||||
|
||||
resource "cloudflare_record" "api" {
|
||||
|
|
|
@ -4,6 +4,13 @@ locals {
|
|||
])
|
||||
}
|
||||
|
||||
resource "random_password" "interactions_token" {
|
||||
length = 64
|
||||
keepers = {
|
||||
"worker_tag" = var.worker_tag
|
||||
}
|
||||
}
|
||||
|
||||
resource "discord-interactions_guild_command" "hello-world" {
|
||||
for_each = local.internalTestingGuilds
|
||||
guild_id = each.value
|
||||
|
@ -12,6 +19,49 @@ resource "discord-interactions_guild_command" "hello-world" {
|
|||
description = "Say hello!"
|
||||
}
|
||||
|
||||
resource "discord-interactions_global_command" "roleypoly" {
|
||||
name = "roleypoly"
|
||||
description = "Sends you a link to pick your roles in your browser"
|
||||
}
|
||||
|
||||
resource "discord-interactions_global_command" "pick-role" {
|
||||
name = "pick-role"
|
||||
description = "Pick a role! (See which ones can be picked with /pickable-roles)"
|
||||
|
||||
option {
|
||||
name = "role"
|
||||
description = "The role you want"
|
||||
type = 8
|
||||
}
|
||||
}
|
||||
|
||||
resource "discord-interactions_guild_command" "pick-role" {
|
||||
for_each = local.internalTestingGuilds
|
||||
guild_id = each.value
|
||||
|
||||
name = "pick-role"
|
||||
description = "Pick a role! (See which ones can be picked with /pickable-roles)"
|
||||
|
||||
option {
|
||||
name = "role"
|
||||
description = "The role you want"
|
||||
type = 8
|
||||
}
|
||||
}
|
||||
|
||||
resource "discord-interactions_global_command" "pickable-roles" {
|
||||
name = "pickable-roles"
|
||||
description = "See all the roles you can pick with /pick-roles"
|
||||
}
|
||||
|
||||
resource "discord-interactions_guild_command" "pickable-roles" {
|
||||
for_each = local.internalTestingGuilds
|
||||
guild_id = each.value
|
||||
|
||||
name = "pickable-roles"
|
||||
description = "See all the roles you can pick with /pick-roles"
|
||||
}
|
||||
|
||||
resource "cloudflare_worker_script" "interactions" {
|
||||
name = "roleypoly-interactions-${var.environment_tag}"
|
||||
content = file("${path.module}/${var.interactions_path_to_worker}")
|
||||
|
@ -21,6 +71,11 @@ resource "cloudflare_worker_script" "interactions" {
|
|||
text = var.discord_public_key
|
||||
}
|
||||
|
||||
secret_text_binding {
|
||||
name = "INTERACTIONS_SHARED_KEY"
|
||||
text = random_password.interactions_token.result
|
||||
}
|
||||
|
||||
plain_text_binding {
|
||||
name = "UI_PUBLIC_URI"
|
||||
text = var.ui_public_uri
|
||||
|
|
|
@ -92,6 +92,12 @@ variable "bot_tag" {
|
|||
description = ":tag or @sha265: of ghcr.io/roleypoly/bot"
|
||||
}
|
||||
|
||||
variable "worker_tag" {
|
||||
type = string
|
||||
default = ""
|
||||
description = "Usually the commit hash, this invalidates some secrets that can always be rotated"
|
||||
}
|
||||
|
||||
variable "allowed_callback_hosts" {
|
||||
type = string
|
||||
default = ""
|
||||
|
|
Loading…
Add table
Reference in a new issue