Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessagePolls,
],
Expand Down
96 changes: 96 additions & 0 deletions src/services/achievement-unlocks.service.test.ts
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();
});
});
});
48 changes: 48 additions & 0 deletions src/services/achievement-unlocks.service.ts
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);
}
}
221 changes: 221 additions & 0 deletions src/slash-commands/events.command.ts
Comment thread
wescopeland marked this conversation as resolved.
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;
Loading