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:
41666 2021-08-01 20:26:47 -04:00 committed by GitHub
parent dde05c402e
commit 066f68ffef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1219 additions and 248 deletions

View file

@ -7,10 +7,13 @@ type URLParams = {
clientID: string;
permissions: number;
guildID?: string;
scopes: string[];
};
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) {
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
@ -31,6 +34,7 @@ export const BotJoin = (request: Request): Response => {
clientID: botClientID,
permissions: 268435456,
guildID,
scopes: ['bot', 'application.commands'],
})
);
};

View file

@ -1,7 +1,8 @@
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
import { accessControlViolation } from '@roleypoly/api/utils/responses';
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';
const fail = () => respond({ error: 'guild not found' }, { status: 404 });

View file

@ -1,5 +1,6 @@
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 => {
const { user, guilds, sessionID } = session || {};

View file

@ -1,5 +1,5 @@
import { GuildSlug } from '@roleypoly/types';
import { respond } from '../utils/api-tools';
import { respond } from '@roleypoly/worker-utils';
import { getGuild } from '../utils/guild';
export const GetSlug = async (request: Request): Promise<Response> => {

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

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

View file

@ -1,5 +1,6 @@
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 { apiPublicURI, botClientID } from '../utils/config';

View file

@ -5,25 +5,22 @@ import {
SessionData,
StateSession,
} from '@roleypoly/types';
import KSUID from 'ksuid';
import {
AuthType,
discordAPIBase,
discordFetch,
userAgent,
} from '@roleypoly/worker-utils';
import KSUID from 'ksuid';
import {
formData,
getStateSession,
isAllowedCallbackHost,
parsePermissions,
resolveFailures,
userAgent,
} from '../utils/api-tools';
import { Bounce } from '../utils/bounce';
import {
apiPublicURI,
botClientID,
botClientSecret,
discordAPIBase,
uiPublicURI,
} from '../utils/config';
import { apiPublicURI, botClientID, botClientSecret, uiPublicURI } from '../utils/config';
import { Sessions } from '../utils/kv';
const AuthErrorResponse = (extra?: string) =>

View file

@ -1,6 +1,7 @@
import { SessionData } from '@roleypoly/types';
import { formData, respond, userAgent, withSession } from '../utils/api-tools';
import { botClientID, botClientSecret, discordAPIBase } from '../utils/config';
import { discordAPIBase, respond, userAgent } from '@roleypoly/worker-utils';
import { formData, withSession } from '../utils/api-tools';
import { botClientID, botClientSecret } from '../utils/config';
import { Sessions } from '../utils/kv';
export const RevokeSession = withSession(

View file

@ -1,5 +1,3 @@
import { memberPassesAccessControl } from '@roleypoly/api/utils/access-control';
import { accessControlViolation } from '@roleypoly/api/utils/responses';
import {
GuildData,
Member,
@ -10,9 +8,10 @@ import {
SessionData,
TransactionType,
} from '@roleypoly/types';
import { AuthType, discordFetch, respond } from '@roleypoly/worker-utils';
import { difference, groupBy, keyBy, union } from 'lodash';
import { AuthType, discordFetch, respond, withSession } from '../utils/api-tools';
import { botToken } from '../utils/config';
import { withSession } from '../utils/api-tools';
import { botToken, uiPublicURI } from '../utils/config';
import {
getGuild,
getGuildData,
@ -57,10 +56,6 @@ export const UpdateRoles = withSession(
const guildData = await getGuildData(guildID);
if (!memberPassesAccessControl(guildCheck, guildMember, guildData.accessControl)) {
return accessControlViolation();
}
const newRoles = calculateNewRoles({
currentRoles: guildMember.roles,
guildRoles: guild.roles,
@ -76,7 +71,7 @@ export const UpdateRoles = withSession(
method: 'PATCH',
headers: {
'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({
roles: newRoles,