diff --git a/.env.example b/.env.example index e9220be..fd7e694 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ YOUTUBE_API_KEY="your_youtube_api_key" MAIN_GUILD_ID="123456789012345678" WORKSHOP_GUILD_ID="123456789012345678" +# Role Configuration +GAMBLER_ROLE_ID="role_id" + # Team Configuration (for privileged commands) CHEAT_INVESTIGATION_CATEGORY_ID="1234567890123456789" diff --git a/src/config/constants.ts b/src/config/constants.ts index a6db1a5..c36554d 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -13,6 +13,9 @@ export const CHEAT_INVESTIGATION_CATEGORY_ID = process.env.CHEAT_INVESTIGATION_C export const MAIN_GUILD_ID = process.env.MAIN_GUILD_ID || ""; export const WORKSHOP_GUILD_ID = process.env.WORKSHOP_GUILD_ID || ""; +// Role configuration. +export const GAMBLER_ROLE_ID = process.env.GAMBLER_ROLE_ID || ""; + // UWC Poll configuration. export const UWC_VOTING_TAG_ID = process.env.UWC_VOTING_TAG_ID || ""; export const UWC_VOTE_CONCLUDED_TAG_ID = process.env.UWC_VOTE_CONCLUDED_TAG_ID || ""; diff --git a/src/index.ts b/src/index.ts index 5920a52..34ff5db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMembers, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessagePolls, ], diff --git a/src/services/achievement-unlocks.service.test.ts b/src/services/achievement-unlocks.service.test.ts new file mode 100644 index 0000000..e41fb2f --- /dev/null +++ b/src/services/achievement-unlocks.service.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, mock, test } from "bun:test"; + +import { createMockAchievementUnlocks } from "../test/mocks/achievement-unlocks.mock"; +import { AchievementUnlocksService, PAGE_SIZE } from "./achievement-unlocks.service"; + +// ... mock the @retroachievements/api module ... +const mockBuildAuthorization = mock(() => ({ username: "RABot", webApiKey: "test-key" })); +const mockGetAchievementUnlocks = mock(async (_auth, { achievementId, offset, count }) => { + if (achievementId === 99999) { + throw new Error("API Error: 404"); + } else { + const data = createMockAchievementUnlocks(achievementId); + data.unlocks = data.unlocks.slice(offset, offset + count); + + return data; + } +}); + +mock.module("@retroachievements/api", () => ({ + buildAuthorization: mockBuildAuthorization, + getAchievementUnlocks: mockGetAchievementUnlocks, +})); + +describe("Service: AchievementUnlocksService", () => { + beforeEach(() => { + // ... reset mocks ... + mockBuildAuthorization.mockClear(); + mockGetAchievementUnlocks.mockClear(); + }); + + describe("getAllAchievementUnlocks", () => { + it("is defined", () => { + // ASSERT + expect(AchievementUnlocksService.getAllAchievementUnlocks).toBeDefined(); + }); + + test.each([PAGE_SIZE - 200, PAGE_SIZE, PAGE_SIZE * 2 - 200, PAGE_SIZE * 2, PAGE_SIZE * 20])( + "fetches all achievement unlocks successfully, %p unlocks", + async (n) => { + // ACT + const result = await AchievementUnlocksService.getAllAchievementUnlocks(n); + + // ASSERT + expect(result).toBeArrayOfSize(n); + expect(result!.at(0)).toBe("User0"); + expect(result!.at(-1)).toBe(`User${n - 1}`); + expect(mockGetAchievementUnlocks).toHaveBeenCalledTimes(Math.ceil(n / PAGE_SIZE)); + if (n < PAGE_SIZE) { + expect(mockGetAchievementUnlocks).toHaveBeenCalledWith( + { username: "RABot", webApiKey: "test-key" }, + { achievementId: n, count: PAGE_SIZE, offset: 0 }, + ); + } + }, + ); + + it("returns an empty array if there are no unlocks", async () => { + // ACT + const result = await AchievementUnlocksService.getAllAchievementUnlocks(0); + + // ASSERT + expect(result).toBeArrayOfSize(0); + }); + + it("returns null if the achievement is not found", async () => { + // ACT + const result = await AchievementUnlocksService.getAllAchievementUnlocks(99999); + + // ASSERT + expect(result).toBeNull(); + }); + + it('handles "429 too many requests" responses gracefully', async () => { + // ARRANGE + mockGetAchievementUnlocks.mockRejectedValueOnce(new Error("429")); + + // ACT + const result = await AchievementUnlocksService.getAllAchievementUnlocks(1); + + // ASSERT + expect(result).toBeArrayOfSize(1); + expect(mockGetAchievementUnlocks).toHaveBeenCalledTimes(2); + }); + + it("returns null on any thrown error", async () => { + // ARRANGE + mockGetAchievementUnlocks.mockRejectedValueOnce(new Error("500")); + + // ACT + const result = await AchievementUnlocksService.getAllAchievementUnlocks(1); + + // ASSERT + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/services/achievement-unlocks.service.ts b/src/services/achievement-unlocks.service.ts new file mode 100644 index 0000000..75d09ff --- /dev/null +++ b/src/services/achievement-unlocks.service.ts @@ -0,0 +1,48 @@ +import { buildAuthorization, getAchievementUnlocks } from "@retroachievements/api"; + +import { RA_WEB_API_KEY } from "../config/constants"; + +export const PAGE_SIZE = 500; + +export class AchievementUnlocksService { + static async getAllAchievementUnlocks(achievementId: number): Promise { + const auth = buildAuthorization({ username: "RABot", webApiKey: RA_WEB_API_KEY }); + + // retry after waiting up to 5 times upon receiving a 429 response, fail on any other error response + const fetchUnlocks = async (offset: number) => { + for (let tries = 0; tries < 5; tries++) { + try { + return await getAchievementUnlocks(auth, { + achievementId, + offset, + count: PAGE_SIZE, + }); + } catch (error) { + if (error instanceof Error && error.message.includes("429")) { + await new Promise((resolve) => setTimeout(() => resolve(), Math.pow(2, tries) * 200)); + continue; + } else { + return null; + } + } + } + }; + + const data = await fetchUnlocks(0); + if (!data) { + return null; + } + + let remaining = data.unlocksCount - PAGE_SIZE; + while (remaining > 0) { + const next = await fetchUnlocks(data.unlocks.length); + if (!next) { + return null; + } + data.unlocks.push(...next.unlocks); + remaining -= PAGE_SIZE; + } + + return data.unlocks.map((entity) => entity.user); + } +} diff --git a/src/slash-commands/events.command.ts b/src/slash-commands/events.command.ts new file mode 100644 index 0000000..1d177ee --- /dev/null +++ b/src/slash-commands/events.command.ts @@ -0,0 +1,221 @@ +import type { ChatInputCommandInteraction, Guild, Role } from "discord.js"; +import { AttachmentBuilder, SlashCommandBuilder } from "discord.js"; + +import { GAMBLER_ROLE_ID } from "../config/constants"; +import type { SlashCommand } from "../models"; +import { AchievementUnlocksService } from "../services/achievement-unlocks.service"; + +const eventsSlashCommand: SlashCommand = { + data: new SlashCommandBuilder() + .setName("events") + .setDescription("Various commands for Events Team") + .addSubcommandGroup((group) => + group + .setName("gambler") + .setDescription("Commands to manage the Gambler role") + .addSubcommand((sub) => + sub.setName("reset").setDescription("Remove Gambler role from all users"), + ) + .addSubcommand((sub) => + sub + .setName("award") + .setDescription("Manually award the Gambler role to the given user") + .addUserOption((option) => + option + .setName("user") + .setDescription("The user to add the Gambler role to") + .setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName("award-all") + .setDescription( + "Award Gambler role to all users that have earned at least 3 of the given achievements", + ) + .addNumberOption((option) => + option.setName("ach1").setDescription("Achievement #1").setRequired(true), + ) + .addNumberOption((option) => + option.setName("ach2").setDescription("Achievement #2").setRequired(true), + ) + .addNumberOption((option) => + option.setName("ach3").setDescription("Achievement #3").setRequired(true), + ) + .addNumberOption((option) => + option.setName("ach4").setDescription("Achievement #4").setRequired(false), + ), + ), + ), + + cooldown: 3, // 3 seconds cooldown. + + async execute(interaction, _client) { + await interaction.deferReply(); + + if (!interaction.guild) { + await interaction.editReply("This command is only supported in a server context."); + + return; + } + + switch (interaction.options.getSubcommandGroup(true)) { + case "gambler": + await new GamblerCommand(interaction).run(interaction.options.getSubcommand(true)); + + return; + default: + await interaction.editReply("Unknown subcommmand group."); + + return; + } + }, +}; + +async function replyWithLog( + interaction: ChatInputCommandInteraction, + message: string, + log: string, +) { + if (log.length === 0) { + return interaction.editReply(message); + } + const attachment = new AttachmentBuilder(Buffer.from(log, "utf8"), { name: "log.txt" }); + + return interaction.editReply({ + content: message, + files: [attachment], + }); +} + +class GamblerCommand { + interaction: ChatInputCommandInteraction; + + constructor(interaction: ChatInputCommandInteraction) { + this.interaction = interaction; + } + + async run(subcommand: string) { + const guild = this.interaction.guild!; + const role = await guild.roles.fetch(GAMBLER_ROLE_ID); + if (!role) { + await this.interaction.editReply( + "Sorry, I couldn't fetch the Gambler role. Please contact an admin.", + ); + + return; + } + + switch (subcommand) { + case "reset": + await this.resetGamblers(role); + + return; + case "award": + await this.awardGambler(guild, role); + + return; + case "award-all": + await this.awardAllGamblers(guild, role); + + return; + default: + await this.interaction.editReply(`Unknown subcommmand \`${subcommand}\`.`); + + return; + } + } + + async resetGamblers(role: Role) { + const members = role.members.values().toArray(); + const removed = []; + for (const member of members) { + await member.roles.remove(role); + removed.push(member.nickname ?? member.displayName); + } + + await replyWithLog( + this.interaction, + `Removed Gambler role from ${removed.length} user(s).`, + removed.join("\n"), + ); + } + + async awardGambler(guild: Guild, role: Role) { + const user = this.interaction.options.getUser("user", true); + const member = await guild.members.fetch(user); + await member.roles.add(role); + await this.interaction.editReply({ + content: `Successfully awarded the Gambler role to <@${member.id}>`, + allowedMentions: { parse: [] }, + }); + } + + async awardAllGamblers(guild: Guild, role: Role) { + const achievements = [ + this.interaction.options.getNumber("ach1", true), + this.interaction.options.getNumber("ach2", true), + this.interaction.options.getNumber("ach3", true), + ]; + + const ach4 = this.interaction.options.getNumber("ach4", false); + if (ach4) { + achievements.push(ach4); + } + + const scores = new Map(); + let statusMessage = ""; + + for (const id of achievements) { + const unlocks = await AchievementUnlocksService.getAllAchievementUnlocks(id); + if (!unlocks) { + await this.interaction.editReply( + "Sorry, I couldn't fetch the achievement unlocks right now. Please check achievement IDs and try again in a minute.", + ); + + return; + } + + statusMessage += `Fetched ${unlocks.length} unlocks from achievement ID ${id}...\n`; + await this.interaction.editReply(statusMessage); + + for (const user of unlocks) { + scores.set(user, (scores.get(user) ?? 0) + 1); + } + } + + const gamblers = scores + .entries() + .filter((pair) => pair[1] >= 3) + .map((pair) => pair[0]); + + const members = new Map( + (await guild.members.fetch()) + .values() + .map((member) => [member.nickname ?? member.displayName, member]), + ); + + const added = []; + const skipped = []; + + for (const user of gamblers) { + if (members.has(user)) { + await members.get(user)!.roles.add(role); + added.push(user); + } else { + skipped.push(user); + } + } + + added.sort(); + skipped.sort(); + + await replyWithLog( + this.interaction, + `${statusMessage}\nAdded the Gambler role to ${added.length} members, skipped ${skipped.length} users not found on the server.`, + `Added:\n${added.map((s) => ` ${s}`).join("\n")}\nSkipped:\n${skipped.map((s) => ` ${s}`).join("\n")}`, + ); + } +} + +export default eventsSlashCommand; diff --git a/src/test/mocks/achievement-unlocks.mock.ts b/src/test/mocks/achievement-unlocks.mock.ts new file mode 100644 index 0000000..0c6c1db --- /dev/null +++ b/src/test/mocks/achievement-unlocks.mock.ts @@ -0,0 +1,37 @@ +import type { AchievementUnlocksMetadata } from "@retroachievements/api"; + +export function createMockAchievementUnlocks( + unlocksCount: number, + overrides?: Partial, +): AchievementUnlocksMetadata { + return { + achievement: { + id: 12345, + title: "Test Achievement 1", + description: "Complete a level", + points: 5, + trueRatio: 1, + author: "TestAuthor", + dateCreated: "2025-12-01 00:00:00", + dateModified: "2025-12-02 00:00:00", + }, + console: { + id: 1, + title: "Test Console", + }, + game: { + id: 2, + title: "Test Game", + }, + totalPlayers: 9999, + unlocks: [...Array(unlocksCount)].map((_, i) => ({ + user: `User${i}`, + raPoints: 5, + raSoftcorePoints: 0, + dateAwarded: "2026-01-01 02:00:00", + hardcoreMode: true, + })), + unlocksCount, + ...overrides, + }; +}