remove interactions & worker-utils package, update misc/types

This commit is contained in:
41666 2022-01-30 03:35:56 -05:00
parent 33df1c7edc
commit 503e908e36
25 changed files with 27 additions and 850 deletions

View file

@ -1,7 +0,0 @@
export {};
declare global {
const DISCORD_PUBLIC_KEY: string;
const UI_PUBLIC_URI: string;
const API_PUBLIC_URI: string;
}

View file

@ -1,5 +0,0 @@
import { respond } from '@roleypoly/worker-utils';
export const healthz = async (request: Request): Promise<Response> => {
return respond({ ok: true });
};

View file

@ -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());
}
};

View file

@ -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}`,
},
};
};

View file

@ -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
);

View file

@ -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
);

View file

@ -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}`,
},
};
};

View file

@ -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));
});

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -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;
};

View file

@ -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');

View file

@ -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',
},
});
};

View file

@ -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,
},
});

View file

@ -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'),
},
},
],
},
};

View file

@ -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',
]),
};

View 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);
};

View file

@ -21,7 +21,7 @@ export type SessionData = {
tokens: AuthTokenResponse;
user: DiscordUser;
guilds: GuildSlug[];
flags: SessionFlags;
// flags: SessionFlags;
};
export type StateSession = {

View file

@ -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;
};

View file

@ -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;
}
};

View file

@ -1,4 +0,0 @@
export * from './api';
export * from './discord';
export * from './kv';
export * from './router';

View file

@ -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();

View file

@ -1,10 +0,0 @@
{
"name": "@roleypoly/worker-utils",
"version": "0.1.0",
"scripts": {
"lint:types": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^2.2.2"
}
}

View file

@ -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);
}
}
}

View file

@ -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"
}