generated from nhcarrigan/template
6bf1ac5e7d
## 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>
183 lines
5.5 KiB
TypeScript
183 lines
5.5 KiB
TypeScript
/**
|
|
* @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 };
|