Skip to content

Commit 68c0032

Browse files
authored
feat: add /events command for event role management (#42)
* feat: add /events command and gambler subcommand * chore: linting/style * fix: add missing awaits * refactor(AchievementUnlocks): add retry limit and inline waiting logic * refactor: remove unneeded constants, add missing typing
1 parent 1b9735b commit 68c0032

7 files changed

Lines changed: 409 additions & 0 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ YOUTUBE_API_KEY="your_youtube_api_key"
1515
MAIN_GUILD_ID="123456789012345678"
1616
WORKSHOP_GUILD_ID="123456789012345678"
1717

18+
# Role Configuration
19+
GAMBLER_ROLE_ID="role_id"
20+
1821
# Team Configuration (for privileged commands)
1922
CHEAT_INVESTIGATION_CATEGORY_ID="1234567890123456789"
2023

src/config/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export const CHEAT_INVESTIGATION_CATEGORY_ID = process.env.CHEAT_INVESTIGATION_C
1313
export const MAIN_GUILD_ID = process.env.MAIN_GUILD_ID || "";
1414
export const WORKSHOP_GUILD_ID = process.env.WORKSHOP_GUILD_ID || "";
1515

16+
// Role configuration.
17+
export const GAMBLER_ROLE_ID = process.env.GAMBLER_ROLE_ID || "";
18+
1619
// UWC Poll configuration.
1720
export const UWC_VOTING_TAG_ID = process.env.UWC_VOTING_TAG_ID || "";
1821
export const UWC_VOTE_CONCLUDED_TAG_ID = process.env.UWC_VOTE_CONCLUDED_TAG_ID || "";

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const client = new Client({
5151
intents: [
5252
GatewayIntentBits.Guilds,
5353
GatewayIntentBits.GuildMessages,
54+
GatewayIntentBits.GuildMembers,
5455
GatewayIntentBits.MessageContent,
5556
GatewayIntentBits.GuildMessagePolls,
5657
],
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { beforeEach, describe, expect, it, mock, test } from "bun:test";
2+
3+
import { createMockAchievementUnlocks } from "../test/mocks/achievement-unlocks.mock";
4+
import { AchievementUnlocksService, PAGE_SIZE } from "./achievement-unlocks.service";
5+
6+
// ... mock the @retroachievements/api module ...
7+
const mockBuildAuthorization = mock(() => ({ username: "RABot", webApiKey: "test-key" }));
8+
const mockGetAchievementUnlocks = mock(async (_auth, { achievementId, offset, count }) => {
9+
if (achievementId === 99999) {
10+
throw new Error("API Error: 404");
11+
} else {
12+
const data = createMockAchievementUnlocks(achievementId);
13+
data.unlocks = data.unlocks.slice(offset, offset + count);
14+
15+
return data;
16+
}
17+
});
18+
19+
mock.module("@retroachievements/api", () => ({
20+
buildAuthorization: mockBuildAuthorization,
21+
getAchievementUnlocks: mockGetAchievementUnlocks,
22+
}));
23+
24+
describe("Service: AchievementUnlocksService", () => {
25+
beforeEach(() => {
26+
// ... reset mocks ...
27+
mockBuildAuthorization.mockClear();
28+
mockGetAchievementUnlocks.mockClear();
29+
});
30+
31+
describe("getAllAchievementUnlocks", () => {
32+
it("is defined", () => {
33+
// ASSERT
34+
expect(AchievementUnlocksService.getAllAchievementUnlocks).toBeDefined();
35+
});
36+
37+
test.each([PAGE_SIZE - 200, PAGE_SIZE, PAGE_SIZE * 2 - 200, PAGE_SIZE * 2, PAGE_SIZE * 20])(
38+
"fetches all achievement unlocks successfully, %p unlocks",
39+
async (n) => {
40+
// ACT
41+
const result = await AchievementUnlocksService.getAllAchievementUnlocks(n);
42+
43+
// ASSERT
44+
expect(result).toBeArrayOfSize(n);
45+
expect(result!.at(0)).toBe("User0");
46+
expect(result!.at(-1)).toBe(`User${n - 1}`);
47+
expect(mockGetAchievementUnlocks).toHaveBeenCalledTimes(Math.ceil(n / PAGE_SIZE));
48+
if (n < PAGE_SIZE) {
49+
expect(mockGetAchievementUnlocks).toHaveBeenCalledWith(
50+
{ username: "RABot", webApiKey: "test-key" },
51+
{ achievementId: n, count: PAGE_SIZE, offset: 0 },
52+
);
53+
}
54+
},
55+
);
56+
57+
it("returns an empty array if there are no unlocks", async () => {
58+
// ACT
59+
const result = await AchievementUnlocksService.getAllAchievementUnlocks(0);
60+
61+
// ASSERT
62+
expect(result).toBeArrayOfSize(0);
63+
});
64+
65+
it("returns null if the achievement is not found", async () => {
66+
// ACT
67+
const result = await AchievementUnlocksService.getAllAchievementUnlocks(99999);
68+
69+
// ASSERT
70+
expect(result).toBeNull();
71+
});
72+
73+
it('handles "429 too many requests" responses gracefully', async () => {
74+
// ARRANGE
75+
mockGetAchievementUnlocks.mockRejectedValueOnce(new Error("429"));
76+
77+
// ACT
78+
const result = await AchievementUnlocksService.getAllAchievementUnlocks(1);
79+
80+
// ASSERT
81+
expect(result).toBeArrayOfSize(1);
82+
expect(mockGetAchievementUnlocks).toHaveBeenCalledTimes(2);
83+
});
84+
85+
it("returns null on any thrown error", async () => {
86+
// ARRANGE
87+
mockGetAchievementUnlocks.mockRejectedValueOnce(new Error("500"));
88+
89+
// ACT
90+
const result = await AchievementUnlocksService.getAllAchievementUnlocks(1);
91+
92+
// ASSERT
93+
expect(result).toBeNull();
94+
});
95+
});
96+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { buildAuthorization, getAchievementUnlocks } from "@retroachievements/api";
2+
3+
import { RA_WEB_API_KEY } from "../config/constants";
4+
5+
export const PAGE_SIZE = 500;
6+
7+
export class AchievementUnlocksService {
8+
static async getAllAchievementUnlocks(achievementId: number): Promise<string[] | null> {
9+
const auth = buildAuthorization({ username: "RABot", webApiKey: RA_WEB_API_KEY });
10+
11+
// retry after waiting up to 5 times upon receiving a 429 response, fail on any other error response
12+
const fetchUnlocks = async (offset: number) => {
13+
for (let tries = 0; tries < 5; tries++) {
14+
try {
15+
return await getAchievementUnlocks(auth, {
16+
achievementId,
17+
offset,
18+
count: PAGE_SIZE,
19+
});
20+
} catch (error) {
21+
if (error instanceof Error && error.message.includes("429")) {
22+
await new Promise<void>((resolve) => setTimeout(() => resolve(), Math.pow(2, tries) * 200));
23+
continue;
24+
} else {
25+
return null;
26+
}
27+
}
28+
}
29+
};
30+
31+
const data = await fetchUnlocks(0);
32+
if (!data) {
33+
return null;
34+
}
35+
36+
let remaining = data.unlocksCount - PAGE_SIZE;
37+
while (remaining > 0) {
38+
const next = await fetchUnlocks(data.unlocks.length);
39+
if (!next) {
40+
return null;
41+
}
42+
data.unlocks.push(...next.unlocks);
43+
remaining -= PAGE_SIZE;
44+
}
45+
46+
return data.unlocks.map((entity) => entity.user);
47+
}
48+
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import type { ChatInputCommandInteraction, Guild, Role } from "discord.js";
2+
import { AttachmentBuilder, SlashCommandBuilder } from "discord.js";
3+
4+
import { GAMBLER_ROLE_ID } from "../config/constants";
5+
import type { SlashCommand } from "../models";
6+
import { AchievementUnlocksService } from "../services/achievement-unlocks.service";
7+
8+
const eventsSlashCommand: SlashCommand = {
9+
data: new SlashCommandBuilder()
10+
.setName("events")
11+
.setDescription("Various commands for Events Team")
12+
.addSubcommandGroup((group) =>
13+
group
14+
.setName("gambler")
15+
.setDescription("Commands to manage the Gambler role")
16+
.addSubcommand((sub) =>
17+
sub.setName("reset").setDescription("Remove Gambler role from all users"),
18+
)
19+
.addSubcommand((sub) =>
20+
sub
21+
.setName("award")
22+
.setDescription("Manually award the Gambler role to the given user")
23+
.addUserOption((option) =>
24+
option
25+
.setName("user")
26+
.setDescription("The user to add the Gambler role to")
27+
.setRequired(true),
28+
),
29+
)
30+
.addSubcommand((sub) =>
31+
sub
32+
.setName("award-all")
33+
.setDescription(
34+
"Award Gambler role to all users that have earned at least 3 of the given achievements",
35+
)
36+
.addNumberOption((option) =>
37+
option.setName("ach1").setDescription("Achievement #1").setRequired(true),
38+
)
39+
.addNumberOption((option) =>
40+
option.setName("ach2").setDescription("Achievement #2").setRequired(true),
41+
)
42+
.addNumberOption((option) =>
43+
option.setName("ach3").setDescription("Achievement #3").setRequired(true),
44+
)
45+
.addNumberOption((option) =>
46+
option.setName("ach4").setDescription("Achievement #4").setRequired(false),
47+
),
48+
),
49+
),
50+
51+
cooldown: 3, // 3 seconds cooldown.
52+
53+
async execute(interaction, _client) {
54+
await interaction.deferReply();
55+
56+
if (!interaction.guild) {
57+
await interaction.editReply("This command is only supported in a server context.");
58+
59+
return;
60+
}
61+
62+
switch (interaction.options.getSubcommandGroup(true)) {
63+
case "gambler":
64+
await new GamblerCommand(interaction).run(interaction.options.getSubcommand(true));
65+
66+
return;
67+
default:
68+
await interaction.editReply("Unknown subcommmand group.");
69+
70+
return;
71+
}
72+
},
73+
};
74+
75+
async function replyWithLog(
76+
interaction: ChatInputCommandInteraction,
77+
message: string,
78+
log: string,
79+
) {
80+
if (log.length === 0) {
81+
return interaction.editReply(message);
82+
}
83+
const attachment = new AttachmentBuilder(Buffer.from(log, "utf8"), { name: "log.txt" });
84+
85+
return interaction.editReply({
86+
content: message,
87+
files: [attachment],
88+
});
89+
}
90+
91+
class GamblerCommand {
92+
interaction: ChatInputCommandInteraction;
93+
94+
constructor(interaction: ChatInputCommandInteraction) {
95+
this.interaction = interaction;
96+
}
97+
98+
async run(subcommand: string) {
99+
const guild = this.interaction.guild!;
100+
const role = await guild.roles.fetch(GAMBLER_ROLE_ID);
101+
if (!role) {
102+
await this.interaction.editReply(
103+
"Sorry, I couldn't fetch the Gambler role. Please contact an admin.",
104+
);
105+
106+
return;
107+
}
108+
109+
switch (subcommand) {
110+
case "reset":
111+
await this.resetGamblers(role);
112+
113+
return;
114+
case "award":
115+
await this.awardGambler(guild, role);
116+
117+
return;
118+
case "award-all":
119+
await this.awardAllGamblers(guild, role);
120+
121+
return;
122+
default:
123+
await this.interaction.editReply(`Unknown subcommmand \`${subcommand}\`.`);
124+
125+
return;
126+
}
127+
}
128+
129+
async resetGamblers(role: Role) {
130+
const members = role.members.values().toArray();
131+
const removed = [];
132+
for (const member of members) {
133+
await member.roles.remove(role);
134+
removed.push(member.nickname ?? member.displayName);
135+
}
136+
137+
await replyWithLog(
138+
this.interaction,
139+
`Removed Gambler role from ${removed.length} user(s).`,
140+
removed.join("\n"),
141+
);
142+
}
143+
144+
async awardGambler(guild: Guild, role: Role) {
145+
const user = this.interaction.options.getUser("user", true);
146+
const member = await guild.members.fetch(user);
147+
await member.roles.add(role);
148+
await this.interaction.editReply({
149+
content: `Successfully awarded the Gambler role to <@${member.id}>`,
150+
allowedMentions: { parse: [] },
151+
});
152+
}
153+
154+
async awardAllGamblers(guild: Guild, role: Role) {
155+
const achievements = [
156+
this.interaction.options.getNumber("ach1", true),
157+
this.interaction.options.getNumber("ach2", true),
158+
this.interaction.options.getNumber("ach3", true),
159+
];
160+
161+
const ach4 = this.interaction.options.getNumber("ach4", false);
162+
if (ach4) {
163+
achievements.push(ach4);
164+
}
165+
166+
const scores = new Map<string, number>();
167+
let statusMessage = "";
168+
169+
for (const id of achievements) {
170+
const unlocks = await AchievementUnlocksService.getAllAchievementUnlocks(id);
171+
if (!unlocks) {
172+
await this.interaction.editReply(
173+
"Sorry, I couldn't fetch the achievement unlocks right now. Please check achievement IDs and try again in a minute.",
174+
);
175+
176+
return;
177+
}
178+
179+
statusMessage += `Fetched ${unlocks.length} unlocks from achievement ID ${id}...\n`;
180+
await this.interaction.editReply(statusMessage);
181+
182+
for (const user of unlocks) {
183+
scores.set(user, (scores.get(user) ?? 0) + 1);
184+
}
185+
}
186+
187+
const gamblers = scores
188+
.entries()
189+
.filter((pair) => pair[1] >= 3)
190+
.map((pair) => pair[0]);
191+
192+
const members = new Map(
193+
(await guild.members.fetch())
194+
.values()
195+
.map((member) => [member.nickname ?? member.displayName, member]),
196+
);
197+
198+
const added = [];
199+
const skipped = [];
200+
201+
for (const user of gamblers) {
202+
if (members.has(user)) {
203+
await members.get(user)!.roles.add(role);
204+
added.push(user);
205+
} else {
206+
skipped.push(user);
207+
}
208+
}
209+
210+
added.sort();
211+
skipped.sort();
212+
213+
await replyWithLog(
214+
this.interaction,
215+
`${statusMessage}\nAdded the Gambler role to ${added.length} members, skipped ${skipped.length} users not found on the server.`,
216+
`Added:\n${added.map((s) => ` ${s}`).join("\n")}\nSkipped:\n${skipped.map((s) => ` ${s}`).join("\n")}`,
217+
);
218+
}
219+
}
220+
221+
export default eventsSlashCommand;

0 commit comments

Comments
 (0)