mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-16 17:49: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,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'],
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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 || {};
|
||||
|
|
|
@ -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> => {
|
||||
|
|
104
packages/api/handlers/interactions-pick-role.ts
Normal file
104
packages/api/handlers/interactions-pick-role.ts
Normal 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();
|
||||
}
|
||||
);
|
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,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';
|
||||
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue