feat: update booster role eligibility logic and refactor related services

This commit is contained in:
MythEclipse
2026-05-16 00:06:13 +07:00
parent 972400e4e4
commit acd4648d49
16 changed files with 478 additions and 40 deletions
+42
View File
@@ -0,0 +1,42 @@
import { Database } from "bun:sqlite";
import type { Client } from "discord.js";
import type { AppConfig } from "../config";
import { BoosterRoleService } from "../services/boosterRoleService";
import { BunSqliteBoosterRoleStore } from "../services/bunSqliteBoosterRoleStore";
import { DiscordRoleRepository } from "../services/discordRoleRepository";
import { handleGuildMemberUpdate } from "./events/guildMemberUpdate";
import { handleInteraction } from "./interactionHandler";
export function attachBotHandlers(client: Client, config: AppConfig): void {
const db = new Database(config.databaseUrl.replace(/^file:/, ""));
const store = new BunSqliteBoosterRoleStore(db);
client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand() || !interaction.guild) return;
const service = new BoosterRoleService(
store,
new DiscordRoleRepository(interaction.guild, config.boosterRoleAnchorRoleId),
{ anchorPosition: resolveAnchorPosition(interaction.guild, config.boosterRoleAnchorRoleId) }
);
await handleInteraction(interaction, service, {
isBoosting: async () => Boolean(interaction.member && "premiumSince" in interaction.member && interaction.member.premiumSince)
});
});
client.on("guildMemberUpdate", async (oldMember, newMember) => {
const service = new BoosterRoleService(
store,
new DiscordRoleRepository(newMember.guild, config.boosterRoleAnchorRoleId),
{ anchorPosition: resolveAnchorPosition(newMember.guild, config.boosterRoleAnchorRoleId) }
);
await handleGuildMemberUpdate(oldMember, newMember, service);
});
}
function resolveAnchorPosition(guild: { roles: { cache: { get(id: string): { position: number } | undefined } } }, anchorRoleId: string | null): number {
if (!anchorRoleId) return 1;
return guild.roles.cache.get(anchorRoleId)?.position ?? 1;
}
+7 -2
View File
@@ -1,7 +1,12 @@
import type { GuildMember } from "discord.js";
import type { BoosterRoleService } from "../../services/boosterRoleService";
export async function handleGuildMemberUpdate(oldMember: GuildMember, newMember: GuildMember, service: BoosterRoleService): Promise<void> {
type GuildMemberLike = {
id: string;
premiumSince: Date | null;
guild: { id: string };
};
export async function handleGuildMemberUpdate(oldMember: GuildMemberLike, newMember: GuildMemberLike, service: BoosterRoleService): Promise<void> {
const hadBoost = Boolean(oldMember.premiumSince);
const hasBoost = Boolean(newMember.premiumSince);
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import { handleInteraction, type BoosterRoleCommandService, type ChatInputInteractionLike } from "./interactionHandler";
type Reply = { content: string; ephemeral: boolean };
class FakeInteraction implements ChatInputInteractionLike {
commandName = "booster-role";
user = { id: "user" };
guildId: string | null = "guild";
replies: Reply[] = [];
constructor(private readonly subcommand: string, private readonly values: Record<string, unknown> = {}) {}
isChatInputCommand(): boolean {
return true;
}
async reply(reply: Reply): Promise<void> {
this.replies.push(reply);
}
options = {
getSubcommand: () => this.subcommand,
getString: (name: string) => (this.values[name] as string | null) ?? null,
getAttachment: (name: string) => (this.values[name] as { contentType: string | null; size: number; url: string } | null) ?? null
};
}
class FakeService implements BoosterRoleCommandService {
calls: string[] = [];
async claimRole(): Promise<{ roleId: string }> {
this.calls.push("claim");
return { roleId: "role-1" };
}
async renameRole(): Promise<void> {
this.calls.push("rename");
}
async recolorRole(): Promise<void> {
this.calls.push("recolor");
}
async setRoleIcon(): Promise<void> {
this.calls.push("icon");
}
async deleteRole(): Promise<void> {
this.calls.push("delete");
}
}
describe("handleInteraction", () => {
test("replies to claim command", async () => {
const interaction = new FakeInteraction("claim", { name: "Test", color: "#AABBCC" });
const service = new FakeService();
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(service.calls).toEqual(["claim"]);
expect(interaction.replies[0]).toEqual({ content: "Booster role created: <@&role-1>", ephemeral: true });
});
test("routes update subcommands and replies", async () => {
for (const subcommand of ["rename", "recolor", "icon", "delete"]) {
const interaction = new FakeInteraction(subcommand, { name: "Test", color: "#AABBCC", image: { contentType: "image/png", size: 100, url: "https://cdn.discordapp.com/icon.png" } });
const service = new FakeService();
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(service.calls).toEqual([subcommand]);
expect(interaction.replies[0]?.ephemeral).toBe(true);
}
});
test("turns service errors into private replies", async () => {
const interaction = new FakeInteraction("claim", { name: "VIP" });
const service = new FakeService();
service.claimRole = async () => {
throw new Error("Role name is already used");
};
await handleInteraction(interaction, service, { isBoosting: async () => true });
expect(interaction.replies[0]).toEqual({ content: "Role name is already used", ephemeral: true });
});
});
+109
View File
@@ -0,0 +1,109 @@
import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService";
export type ChatInputInteractionLike = {
commandName: string;
guildId: string | null;
user: { id: string };
isChatInputCommand(): boolean;
reply(input: { content: string; ephemeral: boolean }): Promise<unknown>;
options: {
getSubcommand(): string;
getString(name: string): string | null;
getAttachment(name: string): { contentType: string | null; size: number; url: string } | null;
};
};
export type BoosterRoleCommandService = {
claimRole(input: { guildId: string; userId: string; name: string; color: string | null; icon?: RoleIcon | null; isBoosting: boolean }): Promise<BoosterRoleRecord | { roleId: string }>;
renameRole(input: { guildId: string; userId: string; name: string }): Promise<void>;
recolorRole(input: { guildId: string; userId: string; color: string }): Promise<void>;
setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise<void>;
deleteRole(input: { guildId: string; userId: string }): Promise<void>;
};
export type InteractionHandlerDeps = {
isBoosting(guildId: string, userId: string): Promise<boolean>;
};
export async function handleInteraction(
interaction: ChatInputInteractionLike,
service: BoosterRoleCommandService,
deps: InteractionHandlerDeps
): Promise<void> {
if (!interaction.isChatInputCommand() || interaction.commandName !== "booster-role") return;
try {
const guildId = requireGuildId(interaction.guildId);
const userId = interaction.user.id;
const subcommand = interaction.options.getSubcommand();
if (subcommand === "claim") {
const role = await service.claimRole({
guildId,
userId,
name: requireString(interaction, "name"),
color: interaction.options.getString("color"),
icon: optionalIcon(interaction, "icon"),
isBoosting: await deps.isBoosting(guildId, userId)
});
await interaction.reply({ content: `Booster role created: <@&${role.roleId}>`, ephemeral: true });
return;
}
if (subcommand === "rename") {
await service.renameRole({ guildId, userId, name: requireString(interaction, "name") });
await interaction.reply({ content: "Booster role renamed.", ephemeral: true });
return;
}
if (subcommand === "recolor") {
await service.recolorRole({ guildId, userId, color: requireString(interaction, "color") });
await interaction.reply({ content: "Booster role color updated.", ephemeral: true });
return;
}
if (subcommand === "icon") {
await service.setRoleIcon({ guildId, userId, icon: requireIcon(interaction, "image") });
await interaction.reply({ content: "Booster role icon updated.", ephemeral: true });
return;
}
if (subcommand === "delete") {
await service.deleteRole({ guildId, userId });
await interaction.reply({ content: "Booster role deleted.", ephemeral: true });
return;
}
throw new Error("Unknown booster-role subcommand");
} catch (error) {
await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", ephemeral: true });
}
}
function requireGuildId(guildId: string | null): string {
if (!guildId) throw new Error("This command can only be used in a server");
return guildId;
}
function requireString(interaction: ChatInputInteractionLike, name: string): string {
const value = interaction.options.getString(name);
if (!value) throw new Error(`${name} is required`);
return value;
}
function requireIcon(interaction: ChatInputInteractionLike, name: string): RoleIcon {
const icon = optionalIcon(interaction, name);
if (!icon) throw new Error("Role icon image is required");
return icon;
}
function optionalIcon(interaction: ChatInputInteractionLike, name: string): RoleIcon | null {
const attachment = interaction.options.getAttachment(name);
if (!attachment) return null;
return {
contentType: attachment.contentType ?? "application/octet-stream",
size: attachment.size,
dataUri: attachment.url
};
}