diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5ff21c1..0eb67c8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -35,6 +35,7 @@ model Player { lifetimeAchievementsUnlocked Float @default(0) lastLoginDate String? loginStreak Int @default(1) + inGuild Boolean @default(false) } model GameState { diff --git a/apps/api/prod.env b/apps/api/prod.env index d3843dc..8817364 100644 --- a/apps/api/prod.env +++ b/apps/api/prod.env @@ -1,6 +1,4 @@ -DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id" DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret" -DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri" JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret" DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url" ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret" @@ -8,6 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port" CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" -DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" -DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e579b94..e04bda5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { profileRouter } from "./routes/profile.js"; import { transcendenceRouter } from "./routes/transcendence.js"; +import { connectGateway } from "./services/gateway.js"; import { logger } from "./services/logger.js"; const app = new Hono(); @@ -68,6 +69,7 @@ const port = Number(process.env.PORT ?? 3001); try { serve({ fetch: app.fetch, port: port }, () => { process.stdout.write(`Elysium API running on port ${String(port)}\n`); + connectGateway(); }); } catch (error) { void logger.error( diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index d974509..eeb07cd 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -16,6 +16,7 @@ import { } from "../services/discord.js"; import { signToken } from "../services/jwt.js"; import { logger } from "../services/logger.js"; +import { grantElysianRole } from "../services/webhook.js"; import type { Player } from "@elysium/types"; const authRouter = new Hono(); @@ -92,6 +93,12 @@ authRouter.get("/callback", async(context) => { }, }); + const inGuild = await grantElysianRole(player.discordId); + await prisma.player.update({ + data: { inGuild }, + where: { discordId: player.discordId }, + }); + const jwtToken = signToken(player.discordId); void logger.log("info", `New player registered: ${player.discordId}`); void logger.metric("user_registered", 1, { discordId: player.discordId }); @@ -104,10 +111,12 @@ authRouter.get("/callback", async(context) => { ); } + const inGuild = await grantElysianRole(discordUser.id); const updated = await prisma.player.update({ data: { avatar: discordUser.avatar, discriminator: discordUser.discriminator, + inGuild: inGuild, username: discordUser.username, }, where: { discordId: discordUser.id }, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index f6affe1..f74e31e 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -760,6 +760,7 @@ gameRouter.get("/load", async(context) => { : computeHmac(JSON.stringify(freshState), secret); return context.json({ currentSchemaVersion: currentSchemaVersion, + inGuild: playerRecord.inGuild, loginBonus: null, loginStreak: playerRecord.loginStreak, offlineEssence: 0, @@ -898,8 +899,10 @@ gameRouter.get("/load", async(context) => { const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(state), secret); + const inGuild = playerRecord?.inGuild ?? false; return context.json({ currentSchemaVersion, + inGuild, loginBonus, loginStreak, offlineEssence, diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index 8b82ae8..dcf02fe 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -7,6 +7,9 @@ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ import { logger } from "./logger.js"; +const discordClientId = "1479551654264049908"; +const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback"; + interface DiscordTokenResponse { access_token: string; token_type: string; @@ -31,24 +34,18 @@ interface DiscordUser { const exchangeCode = async( code: string, ): Promise => { - const clientId = process.env.DISCORD_CLIENT_ID; const clientSecret = process.env.DISCORD_CLIENT_SECRET; - const redirectUri = process.env.DISCORD_REDIRECT_URI; - if ( - clientId === undefined || clientId === "" - || clientSecret === undefined || clientSecret === "" - || redirectUri === undefined || redirectUri === "" - ) { + if (clientSecret === undefined || clientSecret === "") { throw new Error("Discord OAuth environment variables are required"); } const parameters = new URLSearchParams({ - client_id: clientId, + client_id: discordClientId, client_secret: clientSecret, code: code, grant_type: "authorization_code", - redirect_uri: redirectUri, + redirect_uri: discordRedirectUri, }); try { @@ -146,19 +143,9 @@ const fetchDiscordUserById = async( * @throws {Error} If OAuth environment variables are missing. */ const buildOAuthUrl = (): string => { - const clientId = process.env.DISCORD_CLIENT_ID; - const redirectUri = process.env.DISCORD_REDIRECT_URI; - - if ( - clientId === undefined || clientId === "" - || redirectUri === undefined || redirectUri === "" - ) { - throw new Error("Discord OAuth environment variables are required"); - } - const parameters = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, + client_id: discordClientId, + redirect_uri: discordRedirectUri, response_type: "code", scope: "identify", }); diff --git a/apps/api/src/services/gateway.ts b/apps/api/src/services/gateway.ts new file mode 100644 index 0000000..5f0f6bc --- /dev/null +++ b/apps/api/src/services/gateway.ts @@ -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 => { + 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 }; diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index b7ef8cd..5bbdcee 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -15,6 +15,49 @@ const discordApi = "https://discord.com/api/v10"; */ const suppressNotifications = 4096; +/** + * The Discord role ID for the Elysian role granted to all Elysium players. + */ +const discordGuildId = "1354624415861833870"; +const elysianRoleId = "1486144823684628490"; +const apotheosisRoleId = "1479966598210129991"; + +/** + * Grants the Elysian Discord role to the given player and returns whether they are in the guild. + * Fails silently so role grant errors do not affect the auth flow. + * @param discordId - The Discord user ID to grant the role to. + * @returns True if the player is in the guild and the role was granted, false otherwise. + */ +const grantElysianRole = async(discordId: string): Promise => { + const botToken = process.env.DISCORD_BOT_TOKEN; + + if (botToken === undefined || botToken === "") { + return false; + } + + try { + const response = await fetch( + `${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`, + { + headers: { + "Authorization": `Bot ${botToken}`, + "User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)", + }, + method: "PUT", + }, + ); + return response.ok || response.status === 204; + } catch (error) { + void logger.error( + "webhook_elysian_role", + error instanceof Error + ? error + : new Error(String(error)), + ); + return false; + } +}; + /** * Grants the apotheosis Discord role to the given player if configured. * Fails silently so role grant errors do not affect the game action. @@ -23,23 +66,20 @@ const suppressNotifications = 4096; */ const grantApotheosisRole = async(discordId: string): Promise => { const botToken = process.env.DISCORD_BOT_TOKEN; - const guildId = process.env.DISCORD_GUILD_ID; - const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID; - if ( - botToken === undefined || botToken === "" - || guildId === undefined || guildId === "" - || roleId === undefined || roleId === "" - ) { + if (botToken === undefined || botToken === "") { return; } try { await fetch( - `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, + `${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`, { - headers: { Authorization: `Bot ${botToken}` }, - method: "PUT", + headers: { + "Authorization": `Bot ${botToken}`, + "User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)", + }, + method: "PUT", }, ); } catch (error) { @@ -109,4 +149,4 @@ const postMilestoneWebhook = async( } }; -export { grantApotheosisRole, postMilestoneWebhook }; +export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook }; diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index cf924ae..e1a09a4 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -18,51 +18,31 @@ describe("discord service", () => { }); describe("buildOAuthUrl", () => { - it("throws when DISCORD_CLIENT_ID is missing", async () => { - delete process.env["DISCORD_CLIENT_ID"]; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback"; - const { buildOAuthUrl } = await import("../../src/services/discord.js"); - expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required"); - }); - - it("throws when DISCORD_REDIRECT_URI is missing", async () => { - process.env["DISCORD_CLIENT_ID"] = "client123"; - delete process.env["DISCORD_REDIRECT_URI"]; - const { buildOAuthUrl } = await import("../../src/services/discord.js"); - expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required"); - }); - it("returns a URL with correct query params", async () => { - process.env["DISCORD_CLIENT_ID"] = "client123"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback"; const { buildOAuthUrl } = await import("../../src/services/discord.js"); const url = buildOAuthUrl(); - expect(url).toContain("client_id=client123"); + expect(url).toContain("client_id=1479551654264049908"); expect(url).toContain("response_type=code"); expect(url).toContain("scope=identify"); }); }); describe("exchangeCode", () => { - it("throws when env vars are missing", async () => { - delete process.env["DISCORD_CLIENT_ID"]; + it("throws when DISCORD_CLIENT_SECRET is missing", async () => { + delete process.env["DISCORD_CLIENT_SECRET"]; const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required"); }); it("throws when response is not ok", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" }); const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed"); }); it("returns parsed body on success", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) }); const { exchangeCode } = await import("../../src/services/discord.js"); @@ -96,9 +76,7 @@ describe("discord service", () => { describe("exchangeCode non-Error throw", () => { it("re-throws when fetch rejects with a non-Error value", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; mockFetch.mockRejectedValueOnce("raw string error"); const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); diff --git a/apps/api/test/services/gateway.spec.ts b/apps/api/test/services/gateway.spec.ts new file mode 100644 index 0000000..1112bf4 --- /dev/null +++ b/apps/api/test/services/gateway.spec.ts @@ -0,0 +1,105 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + player: { updateMany: vi.fn() }, + }, +})); + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + error: vi.fn().mockResolvedValue(undefined), + log: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { prisma } from "../../src/db/client.js"; + +const discordGuildId = "1354624415861833870"; + +describe("gateway service", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("handleGuildMemberAdd", () => { + it("sets inGuild to true for the matching guild", async () => { + vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + await handleGuildMemberAdd("user123", discordGuildId); + expect(prisma.player.updateMany).toHaveBeenCalledWith({ + data: { inGuild: true }, + where: { discordId: "user123" }, + }); + }); + + it("no-ops when guild id does not match the configured guild", async () => { + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + await handleGuildMemberAdd("user123", "other_guild"); + expect(prisma.player.updateMany).not.toHaveBeenCalled(); + }); + + it("logs error when prisma throws an Error", async () => { + const dbError = new Error("DB failure"); + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberAdd("user123", discordGuildId); + expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError); + }); + + it("logs error when prisma throws a non-Error", async () => { + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberAdd("user123", discordGuildId); + expect(logger.error).toHaveBeenCalledWith( + "gateway_member_add", + new Error("raw error"), + ); + }); + }); + + describe("handleGuildMemberRemove", () => { + it("sets inGuild to false for the matching guild", async () => { + vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + await handleGuildMemberRemove("user123", discordGuildId); + expect(prisma.player.updateMany).toHaveBeenCalledWith({ + data: { inGuild: false }, + where: { discordId: "user123" }, + }); + }); + + it("no-ops when guild id does not match the configured guild", async () => { + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + await handleGuildMemberRemove("user123", "other_guild"); + expect(prisma.player.updateMany).not.toHaveBeenCalled(); + }); + + it("logs error when prisma throws an Error", async () => { + const dbError = new Error("DB failure"); + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberRemove("user123", discordGuildId); + expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError); + }); + + it("logs error when prisma throws a non-Error", async () => { + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberRemove("user123", discordGuildId); + expect(logger.error).toHaveBeenCalledWith( + "gateway_member_remove", + new Error("raw error"), + ); + }); + }); +}); diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index a68a90a..4e9f045 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -20,42 +20,20 @@ describe("webhook service", () => { describe("grantApotheosisRole", () => { it("does nothing when bot token is missing", async () => { delete process.env["DISCORD_BOT_TOKEN"]; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123"; const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await grantApotheosisRole("user123"); expect(mockFetch).not.toHaveBeenCalled(); }); - it("does nothing when guild id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - delete process.env["DISCORD_GUILD_ID"]; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123"; - const { grantApotheosisRole } = await import("../../src/services/webhook.js"); - await grantApotheosisRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("does nothing when role id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; - delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"]; - const { grantApotheosisRole } = await import("../../src/services/webhook.js"); - await grantApotheosisRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("calls Discord API with correct URL and auth when env vars are set", async () => { + it("calls Discord API with correct URL and auth when bot token is set", async () => { process.env["DISCORD_BOT_TOKEN"] = "bot_token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456"; mockFetch.mockResolvedValueOnce({ ok: true }); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await grantApotheosisRole("user789"); expect(mockFetch).toHaveBeenCalledWith( - "https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456", + "https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991", expect.objectContaining({ - method: "PUT", + method: "PUT", headers: expect.objectContaining({ Authorization: "Bot bot_token" }), }), ); @@ -63,8 +41,6 @@ describe("webhook service", () => { it("swallows fetch errors gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce(new Error("Network error")); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); @@ -72,14 +48,69 @@ describe("webhook service", () => { it("swallows non-Error fetch rejections gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce("raw string error"); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); }); }); + describe("grantElysianRole", () => { + it("does nothing when bot token is missing", async () => { + delete process.env["DISCORD_BOT_TOKEN"]; + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user123"); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns true when Discord API responds with ok", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user789"); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ Authorization: "Bot bot_token" }), + }), + ); + expect(result).toBe(true); + }); + + it("returns true when Discord API responds with 204", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + mockFetch.mockResolvedValueOnce({ ok: false, status: 204 }); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(true); + }); + + it("returns false when Discord API responds with an error status", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(false); + }); + + it("returns false and swallows fetch errors gracefully", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + mockFetch.mockRejectedValueOnce(new Error("Network error")); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(false); + }); + + it("returns false and swallows non-Error fetch rejections", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(false); + }); + }); + describe("postMilestoneWebhook", () => { const counts = { prestige: 1, transcendence: 0, apotheosis: 0 }; diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 37162da..90aaabc 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -27,6 +27,7 @@ import { DebugPanel } from "./debugPanel.js"; import { EditProfileModal } from "./editProfileModal.js"; import { EquipmentPanel } from "./equipmentPanel.js"; import { ExplorationPanel } from "./explorationPanel.js"; +import { JoinCommunityModal } from "./joinCommunityModal.js"; import { LoginBonusModal } from "./loginBonusModal.js"; import { MilestoneToast } from "./milestoneToast.js"; import { OfflineModal } from "./offlineModal.js"; @@ -164,6 +165,7 @@ const GameLayout = (): JSX.Element => { transcendenceCount={state.transcendence?.count ?? 0} /> + {schemaOutdated && !dismissedOutdatedWarning ? : null} diff --git a/apps/web/src/components/game/joinCommunityModal.tsx b/apps/web/src/components/game/joinCommunityModal.tsx new file mode 100644 index 0000000..af03bd9 --- /dev/null +++ b/apps/web/src/components/game/joinCommunityModal.tsx @@ -0,0 +1,70 @@ +/** + * @file Modal prompting players to join the NHCarrigan Discord community. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { useCallback, useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; + +const sessionKey = "elysium_join_community_dismissed"; + +/** + * Renders a modal prompting the player to join the NHCarrigan Discord server. + * Shown once per session when the player is not already in the guild. + * @returns The JSX element or null if the player is in the guild or dismissed. + */ +const JoinCommunityModal = (): JSX.Element | null => { + const { inGuild } = useGame(); + const [ dismissed, setDismissed ] = useState( + () => { + return sessionStorage.getItem(sessionKey) === "true"; + }, + ); + + const handleDismiss = useCallback((): void => { + sessionStorage.setItem(sessionKey, "true"); + setDismissed(true); + }, []); + + if (inGuild || dismissed) { + return null; + } + + return ( +
+
+

{"Join Our Community!"}

+

+ {"Did you know Elysium has an active Discord community? "} + {"Join to chat with other players, get updates, and earn "} + {"the exclusive Elysian role!"} +

+

+ {"You already earn the Elysian role just by playing — "} + {"joining lets us show it off in the server!"} +

+
+ + {"Join Discord"} + + +
+
+
+ ); +}; + +export { JoinCommunityModal }; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index f6c23fb..fd89b84 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -243,6 +243,11 @@ interface GameContextValue { isLoading: boolean; error: string | null; + /** + * Whether the player is currently a member of the NHCarrigan Discord server. + */ + inGuild: boolean; + /** * Click the crystal to earn gold. */ @@ -694,6 +699,7 @@ export const GameProvider = ({ const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); + const [ inGuild, setInGuild ] = useState(false); const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< Array >([]); @@ -731,6 +737,7 @@ export const GameProvider = ({ setSchemaOutdated(data.schemaOutdated); setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); + setInGuild(data.inGuild); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`). @@ -2303,6 +2310,7 @@ export const GameProvider = ({ forceUnlocks, formatNumber, handleClick, + inGuild, isLoading, isSyncing, lastSavedAt, @@ -2374,6 +2382,7 @@ export const GameProvider = ({ error, flushBossLoreToasts, forceSync, + inGuild, forceUnlocks, handleClick, isLoading, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 378cd73..976a301 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -70,6 +70,11 @@ interface LoginBonusResult { interface LoadResponse { state: GameState; + /** + * Whether the player is currently a member of the NHCarrigan Discord server. + */ + inGuild: boolean; + /** * Offline gold earned since last save (server-calculated). */