generated from nhcarrigan/template
feat: grant Elysian role on auth and prompt non-members to join (#134)
## Summary - Grants the Elysian Discord role to players on login/registration and persists an `inGuild` flag on the Player record - Connects to the Discord Gateway via WebSocket to keep `inGuild` in sync as players join or leave the server - Shows a dismissible "Join our community" modal to players who are not yet in the guild - Hardens `inGuild` exposure through the load endpoint and game context - Moves all non-secret Discord IDs (guild, role, client, redirect URI) out of env vars and into hardcoded constants; removes them from `prod.env` ## Test plan - [ ] Lint, build, and test pipeline passes (100% coverage maintained) - [ ] New player auth grants Elysian role and sets `inGuild: true` - [ ] Existing player auth re-attempts role grant and updates `inGuild` - [ ] Join community modal appears for players not in the guild - [ ] Modal does not reappear within the same browser session after dismissal - [ ] Gateway correctly sets `inGuild: true/false` on member add/remove events ✨ This issue was created with help from Hikari~ 🌸 Reviewed-on: #134 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #134.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file Discord Gateway WebSocket client for listening to guild member events.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- WebSocket gateway requires sequential event handler setup */
|
||||
import { prisma } from "../db/client.js";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const discordGuildId = "1354624415861833870";
|
||||
|
||||
/**
|
||||
* Discord Gateway opcodes used by this client.
|
||||
*/
|
||||
const gatewayOpcodes = {
|
||||
dispatch: 0,
|
||||
heartbeat: 1,
|
||||
heartbeatAck: 11,
|
||||
hello: 10,
|
||||
identify: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* GUILD_MEMBERS privileged intent bitmask.
|
||||
*/
|
||||
/* eslint-disable-next-line no-bitwise -- Bitwise shift required for Discord intent bitmask */
|
||||
const guildMembersIntent = 1 << 1;
|
||||
|
||||
/**
|
||||
* Updates the inGuild flag for a player when they join the configured guild.
|
||||
* No-ops silently if the Discord user has no player record.
|
||||
* @param discordId - The Discord user ID of the member who joined.
|
||||
* @param guildId - The ID of the guild they joined.
|
||||
* @returns A promise that resolves when the update attempt completes.
|
||||
*/
|
||||
const handleGuildMemberAdd = async(
|
||||
discordId: string,
|
||||
guildId: string,
|
||||
): Promise<void> => {
|
||||
if (guildId !== discordGuildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.player.updateMany({
|
||||
data: { inGuild: true },
|
||||
where: { discordId },
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"gateway_member_add",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the inGuild flag for a player when they leave the configured guild.
|
||||
* No-ops silently if the Discord user has no player record.
|
||||
* @param discordId - The Discord user ID of the member who left.
|
||||
* @param guildId - The ID of the guild they left.
|
||||
* @returns A promise that resolves when the update attempt completes.
|
||||
*/
|
||||
const handleGuildMemberRemove = async(
|
||||
discordId: string,
|
||||
guildId: string,
|
||||
): Promise<void> => {
|
||||
if (guildId !== discordGuildId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.player.updateMany({
|
||||
data: { inGuild: false },
|
||||
where: { discordId },
|
||||
});
|
||||
} catch (error) {
|
||||
void logger.error(
|
||||
"gateway_member_remove",
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore directive must be lowercase
|
||||
/* v8 ignore next 95 -- @preserve */
|
||||
/**
|
||||
* Connects to the Discord Gateway and listens for guild member events.
|
||||
* Reconnects automatically on close or error.
|
||||
* Requires the GUILD_MEMBERS privileged intent to be enabled in the Discord Developer Portal.
|
||||
*/
|
||||
const connectGateway = (): void => {
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN;
|
||||
if (botToken === undefined || botToken === "") {
|
||||
void logger.log("info", "Gateway: no bot token configured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket("wss://gateway.discord.gg/?v=10&encoding=json");
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastSequence: number | null = null;
|
||||
|
||||
const stopHeartbeat = (): void => {
|
||||
if (heartbeatInterval !== null) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Gateway payload is JSON */
|
||||
const payload = JSON.parse(event.data as string) as {
|
||||
op: number;
|
||||
d: unknown;
|
||||
s: number | null;
|
||||
t: string | null;
|
||||
};
|
||||
|
||||
if (payload.s !== null) {
|
||||
lastSequence = payload.s;
|
||||
}
|
||||
|
||||
if (payload.op === gatewayOpcodes.hello) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- HELLO d shape; Discord API snake_case */
|
||||
const helloData = payload.d as { heartbeat_interval: number };
|
||||
const heartbeatMs = helloData.heartbeat_interval;
|
||||
heartbeatInterval = setInterval(() => {
|
||||
ws.send(JSON.stringify({
|
||||
d: lastSequence,
|
||||
op: gatewayOpcodes.heartbeat,
|
||||
}));
|
||||
}, heartbeatMs);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
d: {
|
||||
intents: guildMembersIntent,
|
||||
properties: { browser: "elysium", device: "elysium", os: "linux" },
|
||||
token: botToken,
|
||||
},
|
||||
op: gatewayOpcodes.identify,
|
||||
}));
|
||||
}
|
||||
|
||||
if (payload.op === gatewayOpcodes.dispatch && payload.t !== null) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- dispatch payload shape; Discord API snake_case */
|
||||
const data = payload.d as { user?: { id: string }; guild_id?: string };
|
||||
const discordId = data.user?.id;
|
||||
const guildId = data.guild_id;
|
||||
|
||||
if (discordId === undefined || guildId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.t === "GUILD_MEMBER_ADD") {
|
||||
void handleGuildMemberAdd(discordId, guildId);
|
||||
} else if (payload.t === "GUILD_MEMBER_REMOVE") {
|
||||
void handleGuildMemberRemove(discordId, guildId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
stopHeartbeat();
|
||||
void logger.log("info", "Gateway: connection closed, reconnecting in 5s");
|
||||
setTimeout(connectGateway, 5000);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
const message
|
||||
= event instanceof ErrorEvent
|
||||
? event.message
|
||||
: "WebSocket error";
|
||||
void logger.error("gateway_error", new Error(message));
|
||||
stopHeartbeat();
|
||||
ws.close();
|
||||
});
|
||||
};
|
||||
|
||||
export { connectGateway, handleGuildMemberAdd, handleGuildMemberRemove };
|
||||
Reference in New Issue
Block a user