From 57c92ce2877f3fd30b115a293cc3fdfb41717337 Mon Sep 17 00:00:00 2001 From: monkey-bug <136386334+monkey-bug@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:07:46 -0600 Subject: [PATCH 1/5] feat: add /events command and gambler subcommand --- .env.example | 5 + src/config/constants.ts | 5 + src/index.ts | 1 + .../achievement-unlocks.service.test.ts | 98 ++++++++++ src/services/achievement-unlocks.service.ts | 63 +++++++ src/slash-commands/events.command.ts | 173 ++++++++++++++++++ src/test/mocks/achievement-unlocks.mock.ts | 34 ++++ 7 files changed, 379 insertions(+) create mode 100644 src/services/achievement-unlocks.service.test.ts create mode 100644 src/services/achievement-unlocks.service.ts create mode 100644 src/slash-commands/events.command.ts create mode 100644 src/test/mocks/achievement-unlocks.mock.ts diff --git a/.env.example b/.env.example index e9220be..492d10c 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ YOUTUBE_API_KEY="your_youtube_api_key" MAIN_GUILD_ID="123456789012345678" WORKSHOP_GUILD_ID="123456789012345678" +# Role Configuration +GAMBLER_ROLE_ID="role_id" +AOTW_ROLE_ID="role_id" +PEAK_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..1c443e8 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -13,6 +13,11 @@ 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 || ""; +export const AOTW_ROLE_ID = process.env.AOTW_ROLE_ID || ""; +export const PEAK_ROLE_ID = process.env.PEAK_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..13848d0 --- /dev/null +++ b/src/services/achievement-unlocks.service.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, jest, mock, setSystemTime, 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..111ff46 --- /dev/null +++ b/src/services/achievement-unlocks.service.ts @@ -0,0 +1,63 @@ +import { buildAuthorization, getAchievementUnlocks } from "@retroachievements/api"; +import { RA_WEB_API_KEY } from "../config/constants"; + +const api = new class { + #sleepBase = 200; + #failures = 0; + + async wait() { + return new Promise( + resolve => setTimeout(() => resolve(), this.#sleepBase * Math.pow(2, this.#failures++)) + ); + } + + reset() { + this.#failures = 0; + } +} + +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 upon recieving a 429 response, fail on any other error response + const fetchUnlocks = async (offset: number) => { + api.reset(); + while (true) { + try { + return await getAchievementUnlocks(auth, { + achievementId, + offset, + count: PAGE_SIZE, + }); + } catch (error) { + if (error instanceof Error && error.message.search("429") != -1) { + await api.wait(); + 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..8ae11c5 --- /dev/null +++ b/src/slash-commands/events.command.ts @@ -0,0 +1,173 @@ +import { AttachmentBuilder, ChatInputCommandInteraction, Guild, Role, SlashCommandBuilder } from "discord.js"; + +import type { SlashCommand } from "../models"; +import { AchievementUnlocksService } from "../services/achievement-unlocks.service"; +import { GAMBLER_ROLE_ID } from "../config/constants"; +import logger from "../utils/logger"; + +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": + return new GamblerCommand(interaction).run(interaction.options.getSubcommand(true)); + 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); + } else { + 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": + return this.resetGamblers(role); + case "award": + return this.awardGambler(guild, role); + case "award-all": + return this.awardAllGamblers(guild, role); + 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) { + member.roles.remove(role); + removed.push(member.nickname ?? member.displayName); + } + + 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); + member.roles.add(role); + 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 earners right now. Try again later!"); + 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]) + ); + + let added = []; + let skipped = []; + + for (const user of gamblers) { + if (members.has(user)) { + members.get(user)!.roles.add(role); + added.push(user); + } else { + skipped.push(user); + } + } + + added.sort(); + skipped.sort(); + + 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..fe1d2d0 --- /dev/null +++ b/src/test/mocks/achievement-unlocks.mock.ts @@ -0,0 +1,34 @@ +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, + }; +} From 5aa9366e216f6a31d42986d3604ab536a86dbb20 Mon Sep 17 00:00:00 2001 From: monkey-bug <136386334+monkey-bug@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:15:08 -0600 Subject: [PATCH 2/5] chore: linting/style --- .../achievement-unlocks.service.test.ts | 52 ++++---- src/services/achievement-unlocks.service.ts | 17 +-- src/slash-commands/events.command.ts | 120 ++++++++++++------ src/test/mocks/achievement-unlocks.mock.ts | 29 +++-- 4 files changed, 130 insertions(+), 88 deletions(-) diff --git a/src/services/achievement-unlocks.service.test.ts b/src/services/achievement-unlocks.service.test.ts index 13848d0..e41fb2f 100644 --- a/src/services/achievement-unlocks.service.test.ts +++ b/src/services/achievement-unlocks.service.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, jest, mock, setSystemTime, test } from "bun:test"; +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"; @@ -6,11 +6,12 @@ import { AchievementUnlocksService, PAGE_SIZE } from "./achievement-unlocks.serv // ... mock the @retroachievements/api module ... const mockBuildAuthorization = mock(() => ({ username: "RABot", webApiKey: "test-key" })); const mockGetAchievementUnlocks = mock(async (_auth, { achievementId, offset, count }) => { - if (achievementId == 99999) { + if (achievementId === 99999) { throw new Error("API Error: 404"); } else { const data = createMockAchievementUnlocks(achievementId); - data.unlocks = data.unlocks.slice(offset, offset + count) + data.unlocks = data.unlocks.slice(offset, offset + count); + return data; } }); @@ -31,31 +32,28 @@ describe("Service: AchievementUnlocksService", () => { 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 } - ); - } }); + 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); @@ -72,7 +70,7 @@ describe("Service: AchievementUnlocksService", () => { expect(result).toBeNull(); }); - it("handles \"429 too many requests\" responses gracefully", async () => { + it('handles "429 too many requests" responses gracefully', async () => { // ARRANGE mockGetAchievementUnlocks.mockRejectedValueOnce(new Error("429")); diff --git a/src/services/achievement-unlocks.service.ts b/src/services/achievement-unlocks.service.ts index 111ff46..e3219e9 100644 --- a/src/services/achievement-unlocks.service.ts +++ b/src/services/achievement-unlocks.service.ts @@ -1,20 +1,21 @@ import { buildAuthorization, getAchievementUnlocks } from "@retroachievements/api"; + import { RA_WEB_API_KEY } from "../config/constants"; -const api = new class { +const api = new (class { #sleepBase = 200; #failures = 0; async wait() { - return new Promise( - resolve => setTimeout(() => resolve(), this.#sleepBase * Math.pow(2, this.#failures++)) + return new Promise((resolve) => + setTimeout(() => resolve(), this.#sleepBase * Math.pow(2, this.#failures++)), ); } reset() { this.#failures = 0; } -} +})(); export const PAGE_SIZE = 500; @@ -25,7 +26,7 @@ export class AchievementUnlocksService { // retry after waiting upon recieving a 429 response, fail on any other error response const fetchUnlocks = async (offset: number) => { api.reset(); - while (true) { + for (;;) { try { return await getAchievementUnlocks(auth, { achievementId, @@ -33,7 +34,7 @@ export class AchievementUnlocksService { count: PAGE_SIZE, }); } catch (error) { - if (error instanceof Error && error.message.search("429") != -1) { + if (error instanceof Error && error.message.search("429") !== -1) { await api.wait(); continue; } else { @@ -41,7 +42,7 @@ export class AchievementUnlocksService { } } } - } + }; const data = await fetchUnlocks(0); if (!data) { @@ -58,6 +59,6 @@ export class AchievementUnlocksService { remaining -= PAGE_SIZE; } - return data.unlocks.map(entity => entity.user); + return data.unlocks.map((entity) => entity.user); } } diff --git a/src/slash-commands/events.command.ts b/src/slash-commands/events.command.ts index 8ae11c5..94f739e 100644 --- a/src/slash-commands/events.command.ts +++ b/src/slash-commands/events.command.ts @@ -1,34 +1,51 @@ -import { AttachmentBuilder, ChatInputCommandInteraction, Guild, Role, SlashCommandBuilder } from "discord.js"; +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"; -import { GAMBLER_ROLE_ID } from "../config/constants"; -import logger from "../utils/logger"; 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)) - ) + .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. @@ -38,6 +55,7 @@ const eventsSlashCommand: SlashCommand = { if (!interaction.guild) { await interaction.editReply("This command is only supported in a server context."); + return; } @@ -46,21 +64,26 @@ const eventsSlashCommand: SlashCommand = { return new GamblerCommand(interaction).run(interaction.options.getSubcommand(true)); default: await interaction.editReply("Unknown subcommmand group."); + return; } }, }; -async function replyWithLog(interaction: ChatInputCommandInteraction, message: string, log: string) { - if (log.length == 0) { +async function replyWithLog( + interaction: ChatInputCommandInteraction, + message: string, + log: string, +) { + if (log.length === 0) { return interaction.editReply(message); - } else { - const attachment = new AttachmentBuilder(Buffer.from(log, "utf8"), { name: "log.txt" }); - return interaction.editReply({ - content: message, - files: [ attachment ], - }); } + const attachment = new AttachmentBuilder(Buffer.from(log, "utf8"), { name: "log.txt" }); + + return interaction.editReply({ + content: message, + files: [attachment], + }); } class GamblerCommand { @@ -74,7 +97,10 @@ class GamblerCommand { 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."); + await this.interaction.editReply( + "Sorry, I couldn't fetch the Gambler role. Please contact an admin.", + ); + return; } @@ -87,6 +113,7 @@ class GamblerCommand { return this.awardAllGamblers(guild, role); default: await this.interaction.editReply(`Unknown subcommmand \`${subcommand}\`.`); + return; } } @@ -99,14 +126,21 @@ class GamblerCommand { removed.push(member.nickname ?? member.displayName); } - replyWithLog(this.interaction, `Removed Gambler role from ${removed.length} user(s).`, removed.join("\n")); + 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); member.roles.add(role); - this.interaction.editReply({ content: `Successfully awarded the Gambler role to <@${member.id}>`, allowedMentions: { parse: [] }}); + this.interaction.editReply({ + content: `Successfully awarded the Gambler role to <@${member.id}>`, + allowedMentions: { parse: [] }, + }); } async awardAllGamblers(guild: Guild, role: Role) { @@ -127,7 +161,10 @@ class GamblerCommand { for (const id of achievements) { const unlocks = await AchievementUnlocksService.getAllAchievementUnlocks(id); if (!unlocks) { - await this.interaction.editReply("Sorry, I couldn't fetch the achievement earners right now. Try again later!"); + 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; } @@ -139,16 +176,19 @@ class GamblerCommand { } } - const gamblers = scores.entries().filter(pair => pair[1] >= 3).map(pair => pair[0]); + 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]) + .map((member) => [member.nickname ?? member.displayName, member]), ); - let added = []; - let skipped = []; + const added = []; + const skipped = []; for (const user of gamblers) { if (members.has(user)) { @@ -165,7 +205,7 @@ class GamblerCommand { 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")}` + `Added:\n${added.map((s) => ` ${s}`).join("\n")}\nSkipped:\n${skipped.map((s) => ` ${s}`).join("\n")}`, ); } } diff --git a/src/test/mocks/achievement-unlocks.mock.ts b/src/test/mocks/achievement-unlocks.mock.ts index fe1d2d0..0c6c1db 100644 --- a/src/test/mocks/achievement-unlocks.mock.ts +++ b/src/test/mocks/achievement-unlocks.mock.ts @@ -1,24 +1,27 @@ import type { AchievementUnlocksMetadata } from "@retroachievements/api"; -export function createMockAchievementUnlocks(unlocksCount: number, overrides?: Partial): AchievementUnlocksMetadata { +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", + 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" + id: 1, + title: "Test Console", }, game: { - id: 2, - title: "Test Game" + id: 2, + title: "Test Game", }, totalPlayers: 9999, unlocks: [...Array(unlocksCount)].map((_, i) => ({ From 1d77ce46875571367ab64f5d86569b108d280ed7 Mon Sep 17 00:00:00 2001 From: monkey-bug <136386334+monkey-bug@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:47:52 -0600 Subject: [PATCH 3/5] fix: add missing awaits --- src/slash-commands/events.command.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/slash-commands/events.command.ts b/src/slash-commands/events.command.ts index 94f739e..ba40a25 100644 --- a/src/slash-commands/events.command.ts +++ b/src/slash-commands/events.command.ts @@ -61,7 +61,9 @@ const eventsSlashCommand: SlashCommand = { switch (interaction.options.getSubcommandGroup(true)) { case "gambler": - return new GamblerCommand(interaction).run(interaction.options.getSubcommand(true)); + await new GamblerCommand(interaction).run(interaction.options.getSubcommand(true)); + + return; default: await interaction.editReply("Unknown subcommmand group."); @@ -106,11 +108,17 @@ class GamblerCommand { switch (subcommand) { case "reset": - return this.resetGamblers(role); + await this.resetGamblers(role); + + return; case "award": - return this.awardGambler(guild, role); + await this.awardGambler(guild, role); + + return; case "award-all": - return this.awardAllGamblers(guild, role); + await this.awardAllGamblers(guild, role); + + return; default: await this.interaction.editReply(`Unknown subcommmand \`${subcommand}\`.`); @@ -122,11 +130,11 @@ class GamblerCommand { const members = role.members.values().toArray(); const removed = []; for (const member of members) { - member.roles.remove(role); + await member.roles.remove(role); removed.push(member.nickname ?? member.displayName); } - replyWithLog( + await replyWithLog( this.interaction, `Removed Gambler role from ${removed.length} user(s).`, removed.join("\n"), @@ -136,8 +144,8 @@ class GamblerCommand { async awardGambler(guild: Guild, role: Role) { const user = this.interaction.options.getUser("user", true); const member = await guild.members.fetch(user); - member.roles.add(role); - this.interaction.editReply({ + await member.roles.add(role); + await this.interaction.editReply({ content: `Successfully awarded the Gambler role to <@${member.id}>`, allowedMentions: { parse: [] }, }); @@ -192,7 +200,7 @@ class GamblerCommand { for (const user of gamblers) { if (members.has(user)) { - members.get(user)!.roles.add(role); + await members.get(user)!.roles.add(role); added.push(user); } else { skipped.push(user); @@ -202,7 +210,7 @@ class GamblerCommand { added.sort(); skipped.sort(); - replyWithLog( + 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")}`, From e2b4ef63c5504fb85d701d5659e0af286e706509 Mon Sep 17 00:00:00 2001 From: monkey-bug <136386334+monkey-bug@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:17:33 -0600 Subject: [PATCH 4/5] refactor(AchievementUnlocks): add retry limit and inline waiting logic --- src/services/achievement-unlocks.service.ts | 24 ++++----------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/src/services/achievement-unlocks.service.ts b/src/services/achievement-unlocks.service.ts index e3219e9..75d09ff 100644 --- a/src/services/achievement-unlocks.service.ts +++ b/src/services/achievement-unlocks.service.ts @@ -2,31 +2,15 @@ import { buildAuthorization, getAchievementUnlocks } from "@retroachievements/ap import { RA_WEB_API_KEY } from "../config/constants"; -const api = new (class { - #sleepBase = 200; - #failures = 0; - - async wait() { - return new Promise((resolve) => - setTimeout(() => resolve(), this.#sleepBase * Math.pow(2, this.#failures++)), - ); - } - - reset() { - this.#failures = 0; - } -})(); - 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 upon recieving a 429 response, fail on any other error response + // retry after waiting up to 5 times upon receiving a 429 response, fail on any other error response const fetchUnlocks = async (offset: number) => { - api.reset(); - for (;;) { + for (let tries = 0; tries < 5; tries++) { try { return await getAchievementUnlocks(auth, { achievementId, @@ -34,8 +18,8 @@ export class AchievementUnlocksService { count: PAGE_SIZE, }); } catch (error) { - if (error instanceof Error && error.message.search("429") !== -1) { - await api.wait(); + if (error instanceof Error && error.message.includes("429")) { + await new Promise((resolve) => setTimeout(() => resolve(), Math.pow(2, tries) * 200)); continue; } else { return null; From ad0c85aa7ca743efa56fddf33d0824fdb86cfa5c Mon Sep 17 00:00:00 2001 From: monkey-bug <136386334+monkey-bug@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:27:13 -0600 Subject: [PATCH 5/5] refactor: remove unneeded constants, add missing typing --- .env.example | 2 -- src/config/constants.ts | 2 -- src/slash-commands/events.command.ts | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 492d10c..fd7e694 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,6 @@ WORKSHOP_GUILD_ID="123456789012345678" # Role Configuration GAMBLER_ROLE_ID="role_id" -AOTW_ROLE_ID="role_id" -PEAK_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 1c443e8..c36554d 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -15,8 +15,6 @@ export const WORKSHOP_GUILD_ID = process.env.WORKSHOP_GUILD_ID || ""; // Role configuration. export const GAMBLER_ROLE_ID = process.env.GAMBLER_ROLE_ID || ""; -export const AOTW_ROLE_ID = process.env.AOTW_ROLE_ID || ""; -export const PEAK_ROLE_ID = process.env.PEAK_ROLE_ID || ""; // UWC Poll configuration. export const UWC_VOTING_TAG_ID = process.env.UWC_VOTING_TAG_ID || ""; diff --git a/src/slash-commands/events.command.ts b/src/slash-commands/events.command.ts index ba40a25..1d177ee 100644 --- a/src/slash-commands/events.command.ts +++ b/src/slash-commands/events.command.ts @@ -163,7 +163,7 @@ class GamblerCommand { achievements.push(ach4); } - const scores = new Map(); + const scores = new Map(); let statusMessage = ""; for (const id of achievements) {