generated from nhcarrigan/template
5025948530
- Grant Elysian Discord role to players on OAuth login (new and returning) - Add inGuild flag to Player schema, seeded from role grant response - Connect Discord Gateway WebSocket to keep inGuild in sync on join/leave - Return inGuild from load endpoint; expose in game context - Show join community modal once per session when inGuild is false
183 lines
5.6 KiB
TypeScript
183 lines
5.6 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";
|
|
|
|
/**
|
|
* 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> => {
|
|
const configuredGuildId = process.env.DISCORD_GUILD_ID;
|
|
if (guildId !== configuredGuildId) {
|
|
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> => {
|
|
const configuredGuildId = process.env.DISCORD_GUILD_ID;
|
|
if (guildId !== configuredGuildId) {
|
|
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 };
|