Skip to content

Commit c8b9b66

Browse files
authored
Add some Discord API calls for managing guilds (#147)
This is essentially WIP, but it's in a merge-able state so I'm opening it for PR. It has some useful (but incomplete) functions and concepts around identity and permissions, but there's still work to be done on it.
1 parent ba4ef84 commit c8b9b66

File tree

10 files changed

+223
-77
lines changed

10 files changed

+223
-77
lines changed

app/components/login.tsx

Lines changed: 0 additions & 27 deletions
This file was deleted.

app/components/logout.tsx

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,3 @@
1-
import { Logout } from "#~/basics/logout";
2-
31
export function ServerOverview() {
4-
return (
5-
<main className="">
6-
<nav>
7-
<Logout />
8-
</nav>
9-
<section></section>
10-
<footer></footer>
11-
</main>
12-
);
2+
return <div>butts (ServerOverview)</div>;
133
}

app/helpers/array.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,8 @@ export const partition = <Data>(predicate: (d: Data) => boolean, xs: Data[]) =>
1414
},
1515
[[], []] as [Data[], Data[]],
1616
);
17+
18+
/**
19+
* ElementType is a helper type to turn `Array<T>` into `T`
20+
*/
21+
export type ElementType<T> = T extends (infer U)[] ? U : T;

app/helpers/sets.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,38 @@
11
export const difference = <T>(a: Set<T>, b: Set<T>) =>
22
new Set(Array.from(a).filter((x) => !b.has(x)));
3+
4+
/**
5+
* Returns the intersection of two sets - elements that exist in both sets
6+
* @param setA First set
7+
* @param setB Second set
8+
* @returns A new Set containing elements present in both input sets
9+
*/
10+
export function intersection<T>(setA: Set<T>, setB: Set<T>): Set<T> {
11+
const result = new Set<T>();
12+
13+
for (const elem of setA) {
14+
if (setB.has(elem)) {
15+
result.add(elem);
16+
}
17+
}
18+
19+
return result;
20+
}
21+
22+
/**
23+
* Returns the complement (difference) of two sets - elements in setA that are not in setB
24+
* @param setA First set
25+
* @param setB Second set
26+
* @returns A new Set containing elements present in setA but not in setB
27+
*/
28+
export function complement<T>(setA: Set<T>, setB: Set<T>): Set<T> {
29+
const result = new Set<T>();
30+
31+
for (const elem of setA) {
32+
if (!setB.has(elem)) {
33+
result.add(elem);
34+
}
35+
}
36+
37+
return result;
38+
}

app/models/discord.server.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import type { GuildMember } from "discord.js";
1+
import {
2+
Routes,
3+
type APIGuild,
4+
PermissionFlagsBits,
5+
} from "discord-api-types/v10";
6+
7+
import type { REST } from "@discordjs/rest";
8+
import { type GuildMember } from "discord.js";
9+
10+
import { complement, intersection } from "#~/helpers/sets.js";
11+
212
import type { AccessToken } from "simple-oauth2";
313
import { fetchSettings, SETTINGS } from "#~/models/guilds.server";
414

@@ -88,3 +98,101 @@ export const timeout = async (member: GuildMember | null) => {
8898
}
8999
return member.timeout(OVERNIGHT);
90100
};
101+
102+
const authzRoles = {
103+
mod: "MOD",
104+
admin: "ADMIN",
105+
manager: "MANAGER",
106+
manageChannels: "MANAGE_CHANNELS",
107+
manageGuild: "MANAGE_GUILD",
108+
manageRoles: "MANAGE_ROLES",
109+
} as const;
110+
111+
const isUndefined = (x: unknown): x is undefined => typeof x === "undefined";
112+
113+
const processGuild = (g: APIGuild) => {
114+
const perms = BigInt(g.permissions || 0);
115+
const authz = new Set<(typeof authzRoles)[keyof typeof authzRoles]>();
116+
117+
if (perms & PermissionFlagsBits.Administrator) {
118+
authz.add(authzRoles.admin);
119+
}
120+
if (perms & PermissionFlagsBits.ModerateMembers) {
121+
authz.add(authzRoles.mod);
122+
}
123+
if (perms & PermissionFlagsBits.ManageChannels) {
124+
authz.add(authzRoles.manageChannels);
125+
authz.add(authzRoles.manager);
126+
}
127+
if (perms & PermissionFlagsBits.ManageGuild) {
128+
authz.add(authzRoles.manageGuild);
129+
authz.add(authzRoles.manager);
130+
}
131+
if (perms & PermissionFlagsBits.ManageRoles) {
132+
authz.add(authzRoles.manageRoles);
133+
authz.add(authzRoles.manager);
134+
}
135+
136+
return {
137+
id: g.id as string,
138+
icon: g.icon,
139+
name: g.name as string,
140+
authz: [...authz.values()],
141+
};
142+
};
143+
144+
export interface Guild extends ReturnType<typeof processGuild> {
145+
hasBot: boolean;
146+
}
147+
148+
export const fetchGuilds = async (
149+
userRest: REST,
150+
botRest: REST,
151+
): Promise<Guild[]> => {
152+
const [rawUserGuilds, rawBotGuilds] = (await Promise.all([
153+
userRest.get(Routes.userGuilds()),
154+
botRest.get(Routes.userGuilds()),
155+
])) as [APIGuild[], APIGuild[]];
156+
157+
const botGuilds = new Map(
158+
rawBotGuilds.reduce(
159+
(accum, val) => {
160+
const guild = processGuild(val);
161+
if (guild.authz.length > 0) {
162+
accum.push([val.id, guild]);
163+
}
164+
return accum;
165+
},
166+
[] as [string, Omit<Guild, "hasBot">][],
167+
),
168+
);
169+
const userGuilds = new Map(
170+
rawUserGuilds.reduce(
171+
(accum, val) => {
172+
const guild = processGuild(val);
173+
if (guild.authz.includes("MANAGER")) {
174+
accum.push([val.id, guild]);
175+
}
176+
return accum;
177+
},
178+
[] as [string, Omit<Guild, "hasBot">][],
179+
),
180+
);
181+
182+
const botGuildIds = new Set(botGuilds.keys());
183+
const userGuildIds = new Set(userGuilds.keys());
184+
185+
const manageableGuilds = intersection(botGuildIds, userGuildIds);
186+
const invitableGuilds = complement(userGuildIds, botGuildIds);
187+
188+
return [
189+
...[...manageableGuilds].map((gId) => {
190+
const guild = botGuilds.get(gId);
191+
return guild ? { ...guild, hasBot: true } : undefined;
192+
}),
193+
...[...invitableGuilds].map((gId) => {
194+
const guild = botGuilds.get(gId);
195+
return guild ? { ...guild, hasBot: false } : undefined;
196+
}),
197+
].filter((g) => !isUndefined(g));
198+
};

app/models/session.server.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -107,18 +107,18 @@ const {
107107
export type DbSession = Awaited<ReturnType<typeof getDbSession>>;
108108

109109
export const CookieSessionKeys = {
110-
userId: "userId",
111110
discordToken: "discordToken",
111+
authState: "state",
112+
authRedirect: "redirectTo",
112113
} as const;
113114

114115
export const DbSessionKeys = {
115-
authState: "state",
116-
authRedirect: "redirectTo",
116+
userId: "userId",
117117
} as const;
118118

119119
async function getUserId(request: Request): Promise<string | undefined> {
120-
const session = await getCookieSession(request.headers.get("Cookie"));
121-
const userId = session.get(CookieSessionKeys.userId);
120+
const session = await getDbSession(request.headers.get("Cookie"));
121+
const userId = session.get(DbSessionKeys.userId);
122122
return userId;
123123
}
124124

@@ -162,14 +162,14 @@ export async function initOauthLogin({
162162
redirectTo?: string;
163163
}) {
164164
const { origin } = new URL(request.url);
165-
const dbSession = await getDbSession(request.headers.get("Cookie"));
165+
const cookieSession = await getCookieSession(request.headers.get("Cookie"));
166166

167167
const state = randomUUID();
168-
dbSession.set(DbSessionKeys.authState, state);
168+
cookieSession.set(CookieSessionKeys.authState, state);
169169
if (redirectTo) {
170-
dbSession.set(DbSessionKeys.authRedirect, redirectTo);
170+
cookieSession.set(CookieSessionKeys.authRedirect, redirectTo);
171171
}
172-
const cookie = await commitDbSession(dbSession, {
172+
const cookie = await commitCookieSession(cookieSession, {
173173
maxAge: 60 * 60 * 1, // 1 hour
174174
});
175175
return redirect(
@@ -221,17 +221,16 @@ export async function completeOauthLogin(
221221
getDbSession(reqCookie),
222222
]);
223223

224-
const dbState = dbSession.get(DbSessionKeys.authState);
224+
const dbState = cookieSession.get(CookieSessionKeys.authState);
225225
// Redirect to login if the state arg doesn't match
226226
if (dbState !== state) {
227227
console.error("DB state didn’t match cookie state");
228228
throw redirect("/login");
229229
}
230230

231-
cookieSession.set(CookieSessionKeys.userId, userId);
231+
dbSession.set(DbSessionKeys.userId, userId);
232232
// @ts-expect-error token.toJSON() isn't in the types but it works
233233
cookieSession.set(CookieSessionKeys.discordToken, token.toJSON());
234-
dbSession.unset(DbSessionKeys.authState);
235234
const [cookie, dbCookie] = await Promise.all([
236235
commitCookieSession(cookieSession, {
237236
maxAge: 60 * 60 * 24 * 7, // 7 days
@@ -242,7 +241,7 @@ export async function completeOauthLogin(
242241
headers.append("Set-Cookie", cookie);
243242
headers.append("Set-Cookie", dbCookie);
244243

245-
return redirect(dbSession.get(DbSessionKeys.authRedirect) ?? "/", {
244+
return redirect(cookieSession.get(CookieSessionKeys.authRedirect) ?? "/", {
246245
headers,
247246
});
248247
}

app/routes/__auth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Route } from "./+types/__auth";
22
import { Outlet, useLocation, type LoaderFunctionArgs } from "react-router";
3-
import { Login } from "#~/components/login";
3+
import { Login } from "#~/basics/login";
44
import { isProd } from "#~/helpers/env.server";
55
import { getUser } from "#~/models/session.server";
66
import { useOptionalUser } from "#~/utils";

app/routes/auth.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Route } from "./+types/auth";
22
import { redirect } from "react-router";
33

44
import { initOauthLogin } from "#~/models/session.server";
5-
import { Login } from "#~/components/login";
5+
import { Login } from "#~/basics/login";
66

77
// eslint-disable-next-line no-empty-pattern
88
export async function loader({}: Route.LoaderArgs) {

0 commit comments

Comments
 (0)