-
Notifications
You must be signed in to change notification settings - Fork 5
Add /events command for event role management #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
wescopeland
merged 6 commits into
RetroAchievements:main
from
monkey-bug:feature/events-command
Feb 28, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
57c92ce
feat: add /events command and gambler subcommand
monkey-bug 5aa9366
chore: linting/style
monkey-bug 1d77ce4
fix: add missing awaits
monkey-bug e2b4ef6
refactor(AchievementUnlocks): add retry limit and inline waiting logic
monkey-bug ad0c85a
refactor: remove unneeded constants, add missing typing
monkey-bug 5c357ae
Merge branch 'main' into feature/events-command
wescopeland File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string[] | null> { | ||
| 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<void>((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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, number>(); | ||
| 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; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.