Files
elysium/apps/api/src/services/webhook.ts
T
hikari 6bf1ac5e7d
CI / Lint, Build & Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
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>
2026-03-24 18:49:51 -07:00

153 lines
4.7 KiB
TypeScript

/**
* @file Discord webhook and role-grant utilities for milestone events.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case and Pascal-case header names */
import { logger } from "./logger.js";
const discordApi = "https://discord.com/api/v10";
/**
* Discord MessageFlags.SUPPRESS_NOTIFICATIONS — messages are delivered without
* triggering desktop or mobile push notifications.
*/
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<boolean> => {
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.
* @param discordId - The Discord user ID to grant the role to.
* @returns A promise that resolves when the role grant attempt completes.
*/
const grantApotheosisRole = async(discordId: string): Promise<void> => {
const botToken = process.env.DISCORD_BOT_TOKEN;
if (botToken === undefined || botToken === "") {
return;
}
try {
await fetch(
`${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`,
{
headers: {
"Authorization": `Bot ${botToken}`,
"User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)",
},
method: "PUT",
},
);
} catch (error) {
void logger.error(
"webhook_apotheosis_role",
error instanceof Error
? error
: new Error(String(error)),
);
// Graceful degradation — role grant failure must not affect the apotheosis
}
};
type MilestoneType = "prestige" | "transcendence" | "apotheosis";
interface MilestoneCounts {
prestige: number;
transcendence: number;
apotheosis: number;
}
const milestoneVerbs: Record<MilestoneType, string> = {
apotheosis: "reached apotheosis",
prestige: "prestiged",
transcendence: "transcended",
};
/**
* Posts a milestone announcement to the configured Discord webhook.
* Fails silently so webhook errors do not affect the game action.
* @param discordId - The Discord user ID of the player.
* @param milestone - The type of milestone reached.
* @param counts - The current prestige, transcendence, and apotheosis counts.
* @returns A promise that resolves when the webhook post attempt completes.
*/
const postMilestoneWebhook = async(
discordId: string,
milestone: MilestoneType,
counts: MilestoneCounts,
): Promise<void> => {
const webhookUrl = process.env.DISCORD_MILESTONE_WEBHOOK;
if (webhookUrl === undefined || webhookUrl === "") {
return;
}
const verb = milestoneVerbs[milestone];
/* eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- counts fields are numbers, intentionally stringified */
const content = `<@${discordId}> has ${verb}~! They are now on Prestige ${counts.prestige}, Transcendence ${counts.transcendence}, Apotheosis ${counts.apotheosis}!`;
try {
await fetch(webhookUrl, {
body: JSON.stringify({
content: content,
flags: suppressNotifications,
}),
headers: { "Content-Type": "application/json" },
method: "POST",
});
} catch (error) {
void logger.error(
"webhook_milestone",
error instanceof Error
? error
: new Error(String(error)),
);
// Graceful degradation — webhook failure must not affect the game action
}
};
export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook };