/** * @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 => { 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 => { 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 | 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 };