diff --git a/src/discord/bot.ts b/src/discord/bot.ts index cd4f7f2..9c90dd5 100644 --- a/src/discord/bot.ts +++ b/src/discord/bot.ts @@ -37,7 +37,11 @@ export function attachBotHandlers(client: Client, config: AppConfig): void { }); } -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; +function resolveAnchorPosition(guild: { members: { me: { roles: { highest: { position: number } } } | null }; roles: { cache: { get(id: string): { position: number } | undefined } } }, anchorRoleId: string | null): number { + if (!anchorRoleId) return botRolePosition(guild); + return guild.roles.cache.get(anchorRoleId)?.position ?? botRolePosition(guild); +} + +function botRolePosition(guild: { members: { me: { roles: { highest: { position: number } } } | null } }): number { + return guild.members.me?.roles.highest.position ?? 1; } diff --git a/src/discord/interactionHandler.test.ts b/src/discord/interactionHandler.test.ts index 211f942..a01baad 100644 --- a/src/discord/interactionHandler.test.ts +++ b/src/discord/interactionHandler.test.ts @@ -1,7 +1,8 @@ +import { MessageFlags } from "discord.js"; import { describe, expect, test } from "bun:test"; import { handleInteraction, type BoosterRoleCommandService, type ChatInputInteractionLike } from "./interactionHandler"; -type Reply = { content: string; ephemeral: boolean }; +type Reply = { content: string; flags: MessageFlags.Ephemeral }; class FakeInteraction implements ChatInputInteractionLike { commandName = "booster-role"; @@ -59,7 +60,7 @@ describe("handleInteraction", () => { 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 }); + expect(interaction.replies[0]).toEqual({ content: "Booster role created: <@&role-1>", flags: MessageFlags.Ephemeral }); }); test("routes update subcommands and replies", async () => { @@ -70,7 +71,7 @@ describe("handleInteraction", () => { await handleInteraction(interaction, service, { isBoosting: async () => true }); expect(service.calls).toEqual([subcommand]); - expect(interaction.replies[0]?.ephemeral).toBe(true); + expect(interaction.replies[0]?.flags).toBe(MessageFlags.Ephemeral); } }); @@ -83,6 +84,6 @@ describe("handleInteraction", () => { await handleInteraction(interaction, service, { isBoosting: async () => true }); - expect(interaction.replies[0]).toEqual({ content: "Role name is already used", ephemeral: true }); + expect(interaction.replies[0]).toEqual({ content: "Role name is already used", flags: MessageFlags.Ephemeral }); }); }); diff --git a/src/discord/interactionHandler.ts b/src/discord/interactionHandler.ts index 57cf47a..5cffcd2 100644 --- a/src/discord/interactionHandler.ts +++ b/src/discord/interactionHandler.ts @@ -1,3 +1,4 @@ +import { MessageFlags } from "discord.js"; import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService"; export type ChatInputInteractionLike = { @@ -5,7 +6,7 @@ export type ChatInputInteractionLike = { guildId: string | null; user: { id: string }; isChatInputCommand(): boolean; - reply(input: { content: string; ephemeral: boolean }): Promise; + reply(input: { content: string; flags: MessageFlags.Ephemeral }): Promise; options: { getSubcommand(): string; getString(name: string): string | null; @@ -46,37 +47,37 @@ export async function handleInteraction( icon: optionalIcon(interaction, "icon"), isBoosting: await deps.isBoosting(guildId, userId) }); - await interaction.reply({ content: `Booster role created: <@&${role.roleId}>`, ephemeral: true }); + await interaction.reply({ content: `Booster role created: <@&${role.roleId}>`, flags: MessageFlags.Ephemeral }); return; } if (subcommand === "rename") { await service.renameRole({ guildId, userId, name: requireString(interaction, "name") }); - await interaction.reply({ content: "Booster role renamed.", ephemeral: true }); + await interaction.reply({ content: "Booster role renamed.", flags: MessageFlags.Ephemeral }); return; } if (subcommand === "recolor") { await service.recolorRole({ guildId, userId, color: requireString(interaction, "color") }); - await interaction.reply({ content: "Booster role color updated.", ephemeral: true }); + await interaction.reply({ content: "Booster role color updated.", flags: MessageFlags.Ephemeral }); return; } if (subcommand === "icon") { await service.setRoleIcon({ guildId, userId, icon: requireIcon(interaction, "image") }); - await interaction.reply({ content: "Booster role icon updated.", ephemeral: true }); + await interaction.reply({ content: "Booster role icon updated.", flags: MessageFlags.Ephemeral }); return; } if (subcommand === "delete") { await service.deleteRole({ guildId, userId }); - await interaction.reply({ content: "Booster role deleted.", ephemeral: true }); + await interaction.reply({ content: "Booster role deleted.", flags: MessageFlags.Ephemeral }); return; } throw new Error("Unknown booster-role subcommand"); } catch (error) { - await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", ephemeral: true }); + await interaction.reply({ content: error instanceof Error ? error.message : "Command failed", flags: MessageFlags.Ephemeral }); } } diff --git a/src/services/boosterRoleService.test.ts b/src/services/boosterRoleService.test.ts index 712c25a..5b741b4 100644 --- a/src/services/boosterRoleService.test.ts +++ b/src/services/boosterRoleService.test.ts @@ -20,6 +20,7 @@ class MemoryRoleStore { class FakeRoleRepository implements RoleRepository { roles = new Map(); deletedRoleIds: string[] = []; + assignedRoles: Array<{ userId: string; roleId: string }> = []; constructor(initialRoles = [{ id: "existing-vip", name: "VIP", permissions: [], position: 1, color: null }]) { for (const role of initialRoles) { @@ -43,6 +44,10 @@ class FakeRoleRepository implements RoleRepository { this.roles.set(roleId, { ...role, ...input }); } + async assignRole(userId: string, roleId: string) { + this.assignedRoles.push({ userId, roleId }); + } + async deleteRole(roleId: string) { this.deletedRoleIds.push(roleId); this.roles.delete(roleId); @@ -60,6 +65,7 @@ describe("BoosterRoleService", () => { expect(claimed.roleId).toBe("created-2"); expect(roles.roles.get(claimed.roleId)?.permissions).toEqual([]); expect(roles.roles.get(claimed.roleId)?.position).toBe(9); + expect(roles.assignedRoles).toEqual([{ userId: "user", roleId: claimed.roleId }]); expect(await store.findByUser("guild", "user")).toEqual(claimed); }); diff --git a/src/services/boosterRoleService.ts b/src/services/boosterRoleService.ts index 97a0fa6..50e6e06 100644 --- a/src/services/boosterRoleService.ts +++ b/src/services/boosterRoleService.ts @@ -28,6 +28,7 @@ export type RoleRepository = { listRoles(): Promise; createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }>; updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise; + assignRole(userId: string, roleId: string): Promise; deleteRole(roleId: string): Promise; }; @@ -83,6 +84,8 @@ export class BoosterRoleService { await this.roles.updateRole(role.id, { icon: input.icon.dataUri }); } + await this.roles.assignRole(userId, role.id); + const timestamp = this.now(); const record = { guildId, diff --git a/src/services/discordRoleRepository.ts b/src/services/discordRoleRepository.ts index 1a23d6e..5ddaf07 100644 --- a/src/services/discordRoleRepository.ts +++ b/src/services/discordRoleRepository.ts @@ -12,7 +12,7 @@ export class DiscordRoleRepository implements RoleRepository { async createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }> { const role = await this.guild.roles.create({ name: input.name, - color: toDiscordColor(input.color), + colors: toDiscordColors(input.color), permissions: 0n }); @@ -24,11 +24,16 @@ export class DiscordRoleRepository implements RoleRepository { const role = await this.fetchRole(roleId); await role.edit({ name: input.name, - color: input.color === undefined ? undefined : toDiscordColor(input.color), + colors: input.color === undefined ? undefined : toDiscordColors(input.color), icon: input.icon === undefined ? undefined : input.icon }); } + async assignRole(userId: string, roleId: string): Promise { + const member = await this.guild.members.fetch(userId); + await member.roles.add(roleId); + } + async deleteRole(roleId: string): Promise { const role = await this.fetchRole(roleId); await role.delete(); @@ -48,6 +53,6 @@ export class DiscordRoleRepository implements RoleRepository { } } -function toDiscordColor(color: string | null): ColorResolvable | undefined { - return color === null ? undefined : (color as ColorResolvable); +function toDiscordColors(color: string | null): { primaryColor: ColorResolvable } | undefined { + return color === null ? undefined : { primaryColor: color as ColorResolvable }; }