mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-25 03:49:11 +00:00
remove interactions & worker-utils package, update misc/types
This commit is contained in:
parent
33df1c7edc
commit
503e908e36
25 changed files with 27 additions and 850 deletions
7
packages/interactions/bindings.d.ts
vendored
7
packages/interactions/bindings.d.ts
vendored
|
@ -1,7 +0,0 @@
|
||||||
export {};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
const DISCORD_PUBLIC_KEY: string;
|
|
||||||
const UI_PUBLIC_URI: string;
|
|
||||||
const API_PUBLIC_URI: string;
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { respond } from '@roleypoly/worker-utils';
|
|
||||||
|
|
||||||
export const healthz = async (request: Request): Promise<Response> => {
|
|
||||||
return respond({ ok: true });
|
|
||||||
};
|
|
|
@ -1,60 +0,0 @@
|
||||||
import {
|
|
||||||
InteractionData,
|
|
||||||
InteractionRequest,
|
|
||||||
InteractionRequestCommand,
|
|
||||||
InteractionType,
|
|
||||||
} from '@roleypoly/types';
|
|
||||||
import { HandlerTools, respond } from '@roleypoly/worker-utils';
|
|
||||||
import { CommandHandler, 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'], CommandHandler> = {
|
|
||||||
'hello-world': helloWorld,
|
|
||||||
roleypoly: roleypoly,
|
|
||||||
'pickable-roles': pickableRoles,
|
|
||||||
'pick-role': pickRole('add'),
|
|
||||||
'remove-role': pickRole('remove'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const interactionHandler = async (
|
|
||||||
request: Request,
|
|
||||||
{ waitUntil }: HandlerTools
|
|
||||||
): 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, {
|
|
||||||
request,
|
|
||||||
waitUntil,
|
|
||||||
});
|
|
||||||
return respond(response);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return respond(somethingWentWrong());
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,16 +0,0 @@
|
||||||
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}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,86 +0,0 @@
|
||||||
import { selectRole } from '@roleypoly/interactions/utils/api';
|
|
||||||
import {
|
|
||||||
asyncPreflightEphemeral,
|
|
||||||
asyncResponse,
|
|
||||||
} from '@roleypoly/interactions/utils/interactions';
|
|
||||||
import { invalid, mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
|
||||||
import {
|
|
||||||
InteractionCallbackType,
|
|
||||||
InteractionFlags,
|
|
||||||
InteractionRequestCommand,
|
|
||||||
InteractionResponse,
|
|
||||||
} from '@roleypoly/types';
|
|
||||||
|
|
||||||
export const pickRole = (mode: 'add' | 'remove') =>
|
|
||||||
asyncResponse(
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
asyncPreflightEphemeral
|
|
||||||
);
|
|
|
@ -1,63 +0,0 @@
|
||||||
import { getPickableRoles } from '@roleypoly/interactions/utils/api';
|
|
||||||
import { uiPublicURI } from '@roleypoly/interactions/utils/config';
|
|
||||||
import {
|
|
||||||
asyncPreflightEphemeral,
|
|
||||||
asyncResponse,
|
|
||||||
} from '@roleypoly/interactions/utils/interactions';
|
|
||||||
import { mustBeInGuild } from '@roleypoly/interactions/utils/responses';
|
|
||||||
import {
|
|
||||||
CategoryType,
|
|
||||||
Embed,
|
|
||||||
InteractionCallbackType,
|
|
||||||
InteractionFlags,
|
|
||||||
InteractionRequestCommand,
|
|
||||||
InteractionResponse,
|
|
||||||
} from '@roleypoly/types';
|
|
||||||
|
|
||||||
export const pickableRoles = asyncResponse(
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
asyncPreflightEphemeral
|
|
||||||
);
|
|
|
@ -1,56 +0,0 @@
|
||||||
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}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,29 +0,0 @@
|
||||||
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));
|
|
||||||
});
|
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
|
@ -1,41 +0,0 @@
|
||||||
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;
|
|
||||||
};
|
|
|
@ -1,11 +0,0 @@
|
||||||
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');
|
|
|
@ -1,83 +0,0 @@
|
||||||
import { publicKey } from '@roleypoly/interactions/utils/config';
|
|
||||||
import {
|
|
||||||
InteractionCallbackType,
|
|
||||||
InteractionFlags,
|
|
||||||
InteractionRequest,
|
|
||||||
InteractionRequestCommand,
|
|
||||||
InteractionResponse,
|
|
||||||
} from '@roleypoly/types';
|
|
||||||
import { AuthType, discordFetch, HandlerTools } from '@roleypoly/worker-utils';
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RequestInfo = HandlerTools & { request: Request };
|
|
||||||
|
|
||||||
export type CommandHandler = (
|
|
||||||
request: InteractionRequestCommand,
|
|
||||||
requestInfo: RequestInfo
|
|
||||||
) => Promise<InteractionResponse>;
|
|
||||||
|
|
||||||
export const asyncResponse =
|
|
||||||
(
|
|
||||||
handler: CommandHandler,
|
|
||||||
preflight?: () => InteractionResponse['data']
|
|
||||||
): CommandHandler =>
|
|
||||||
async (
|
|
||||||
command: InteractionRequestCommand,
|
|
||||||
requestInfo: RequestInfo
|
|
||||||
): Promise<InteractionResponse> => {
|
|
||||||
requestInfo.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const response = await handler(command, requestInfo);
|
|
||||||
await updateOriginalMessage(command.application_id, command.token, response);
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE,
|
|
||||||
data: preflight ? preflight() : undefined,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const asyncPreflightEphemeral = () => ({
|
|
||||||
flags: InteractionFlags.EPHEMERAL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateOriginalMessage = async (
|
|
||||||
appID: string,
|
|
||||||
token: string,
|
|
||||||
response: InteractionResponse
|
|
||||||
) => {
|
|
||||||
const url = `/webhooks/${appID}/${token}/messages/@original`;
|
|
||||||
|
|
||||||
return await discordFetch(url, '', AuthType.None, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(response.data),
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -1,29 +0,0 @@
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,28 +0,0 @@
|
||||||
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'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,12 +0,0 @@
|
||||||
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',
|
|
||||||
]),
|
|
||||||
};
|
|
26
packages/misc-utils/collection-tools.ts
Normal file
26
packages/misc-utils/collection-tools.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
export const difference = <T>(...arrays: T[][]) => {
|
||||||
|
return arrays.reduce((a, b) => a.filter((v) => !b.includes(v)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const groupBy = (arr: Record<string, any>[], key: string) => {
|
||||||
|
return arr.reduce((r, a) => {
|
||||||
|
r[a[key]] = [...r[a[key]], a];
|
||||||
|
return r;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const keyBy = (arr: Record<string, any>[], key: string) => {
|
||||||
|
return arr.reduce((r, a) => {
|
||||||
|
r[a[key]] = a;
|
||||||
|
return r;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const union = <T>(...arrays: T[][]) => {
|
||||||
|
return arrays.reduce((a, b) => [...a, ...b]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isIdenticalArray = <T>(a1: T[], a2: T[]) => {
|
||||||
|
// DEIs: http://stackoverflow.com/a/40496893/308645
|
||||||
|
return a1.length === a2.length && a1.reduce((a, b) => a && a2.includes(b), true);
|
||||||
|
};
|
|
@ -21,7 +21,7 @@ export type SessionData = {
|
||||||
tokens: AuthTokenResponse;
|
tokens: AuthTokenResponse;
|
||||||
user: DiscordUser;
|
user: DiscordUser;
|
||||||
guilds: GuildSlug[];
|
guilds: GuildSlug[];
|
||||||
flags: SessionFlags;
|
// flags: SessionFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StateSession = {
|
export type StateSession = {
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
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;
|
|
||||||
};
|
|
|
@ -1,43 +0,0 @@
|
||||||
import { userAgent } from './api';
|
|
||||||
|
|
||||||
export const discordAPIBase = 'https://discordapp.com/api/v9';
|
|
||||||
|
|
||||||
export enum AuthType {
|
|
||||||
Bearer = 'Bearer',
|
|
||||||
Bot = 'Bot',
|
|
||||||
None = 'None',
|
|
||||||
}
|
|
||||||
|
|
||||||
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 || {}),
|
|
||||||
...(authType !== AuthType.None
|
|
||||||
? {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,4 +0,0 @@
|
||||||
export * from './api';
|
|
||||||
export * from './discord';
|
|
||||||
export * from './kv';
|
|
||||||
export * from './router';
|
|
|
@ -1,80 +0,0 @@
|
||||||
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();
|
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@roleypoly/worker-utils",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"scripts": {
|
|
||||||
"lint:types": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@cloudflare/workers-types": "^2.2.2"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,118 +0,0 @@
|
||||||
export type Handler = (
|
|
||||||
request: Request,
|
|
||||||
tools: HandlerTools
|
|
||||||
) => Promise<Response> | Response;
|
|
||||||
|
|
||||||
export type HandlerTools = {
|
|
||||||
waitUntil: FetchEvent['waitUntil'];
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoutingTree = {
|
|
||||||
[method: string]: {
|
|
||||||
[path: string]: Handler;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type Fallbacks = {
|
|
||||||
root: Handler;
|
|
||||||
404: Handler;
|
|
||||||
500: Handler;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Router {
|
|
||||||
private routingTree: RoutingTree = {};
|
|
||||||
private fallbacks: Fallbacks = {
|
|
||||||
root: this.respondToRoot,
|
|
||||||
404: this.notFound,
|
|
||||||
500: this.serverError,
|
|
||||||
};
|
|
||||||
|
|
||||||
private corsOrigins: string[] = [];
|
|
||||||
|
|
||||||
addCORSOrigins(origins: string[]) {
|
|
||||||
this.corsOrigins = [...this.corsOrigins, ...origins];
|
|
||||||
}
|
|
||||||
|
|
||||||
addFallback(which: keyof Fallbacks, handler: Handler) {
|
|
||||||
this.fallbacks[which] = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
add(method: string, rootPath: string, handler: Handler) {
|
|
||||||
const lowerMethod = method.toLowerCase();
|
|
||||||
|
|
||||||
if (!this.routingTree[lowerMethod]) {
|
|
||||||
this.routingTree[lowerMethod] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.routingTree[lowerMethod][rootPath] = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handle(event: FetchEvent): Promise<Response> {
|
|
||||||
const response = await this.processRequest(event);
|
|
||||||
this.injectCORSHeaders(event.request, response.headers);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processRequest({ request, waitUntil }: FetchEvent): Promise<Response> {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
if (url.pathname === '/' || url.pathname === '') {
|
|
||||||
return this.fallbacks.root(request, { waitUntil });
|
|
||||||
}
|
|
||||||
const lowerMethod = request.method.toLowerCase();
|
|
||||||
const rootPath = url.pathname.split('/')[1];
|
|
||||||
const handler = this.routingTree[lowerMethod]?.[rootPath];
|
|
||||||
|
|
||||||
if (handler) {
|
|
||||||
try {
|
|
||||||
const response = await handler(request, { waitUntil });
|
|
||||||
return response;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return this.fallbacks[500](request, { waitUntil });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lowerMethod === 'options') {
|
|
||||||
return new Response(null, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.fallbacks[404](request, { waitUntil });
|
|
||||||
}
|
|
||||||
|
|
||||||
private respondToRoot(): Response {
|
|
||||||
return new Response('Hi there!');
|
|
||||||
}
|
|
||||||
|
|
||||||
private notFound(): Response {
|
|
||||||
return new Response(JSON.stringify({ error: 'not_found' }), {
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private serverError(): Response {
|
|
||||||
return new Response(JSON.stringify({ error: 'internal_server_error' }), {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
Loading…
Add table
Reference in a new issue