From acd4648d4914fc378608e1c8a1b7d6c5eb81d647 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 16 May 2026 00:06:13 +0700 Subject: [PATCH] feat: update booster role eligibility logic and refactor related services --- README.md | 12 +- bun.lock | 2 - package.json | 2 - src/discord/bot.ts | 42 +++++++ src/discord/events/guildMemberUpdate.ts | 9 +- src/discord/interactionHandler.test.ts | 88 ++++++++++++++ src/discord/interactionHandler.ts | 109 ++++++++++++++++++ src/domain/boostEligibility.test.ts | 14 +-- src/domain/boostEligibility.ts | 10 +- src/index.ts | 2 + src/services/boosterRoleService.test.ts | 16 +-- src/services/boosterRoleService.ts | 14 ++- .../bunSqliteBoosterRoleStore.test.ts | 26 +++++ src/services/bunSqliteBoosterRoleStore.ts | 71 ++++++++++++ src/services/discordRoleRepository.ts | 53 +++++++++ src/services/sqliteBoosterRoleStore.ts | 48 ++++++++ 16 files changed, 478 insertions(+), 40 deletions(-) create mode 100644 src/discord/bot.ts create mode 100644 src/discord/interactionHandler.test.ts create mode 100644 src/discord/interactionHandler.ts create mode 100644 src/services/bunSqliteBoosterRoleStore.test.ts create mode 100644 src/services/bunSqliteBoosterRoleStore.ts create mode 100644 src/services/discordRoleRepository.ts create mode 100644 src/services/sqliteBoosterRoleStore.ts diff --git a/README.md b/README.md index 43c13ae..9c6f0cb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Booster Role Bot -Discord bot untuk memberi role custom kosmetik ke user yang memenuhi syarat boost server 2x. Role dibuat oleh bot, tidak boleh mengambil role yang sudah ada, dan otomatis dihapus saat user tidak lagi eligible. +Discord bot untuk memberi role custom kosmetik ke user yang sedang boost server. Role dibuat oleh bot, tidak boleh mengambil role yang sudah ada, dan otomatis dihapus saat user tidak lagi eligible. ## Tech stack - Bun + TypeScript - discord.js -- SQLite + Drizzle ORM +- SQLite via `bun:sqlite` + Drizzle schema - Bun test runner ## Prasyarat @@ -53,7 +53,7 @@ SQLite default tersimpan di `./data/booster-role.sqlite`. bun run dev ``` -Saat startup, bot otomatis register slash command ke guild dari `DISCORD_GUILD_ID`. Pastikan bot di-invite dengan scope `applications.commands`. +Saat startup, bot otomatis register slash command ke guild dari `DISCORD_GUILD_ID`, lalu menangani interaction command. Pastikan bot di-invite dengan scope `applications.commands`. ## Testing @@ -75,7 +75,7 @@ Bot ini dirancang supaya aman dari abuse: - Icon/logo role opsional hanya bisa dipasang ke role bot-managed milik user tersebut. - Attachment icon harus berupa image dan dibatasi ukuran agar tidak disalahgunakan. - Permission berbahaya seperti `Administrator`, `ManageRoles`, `ManageChannels`, `BanMembers`, `KickMembers`, `MentionEveryone`, `ManageGuild`, dan `ManageWebhooks` ditolak. -- Jika eligibility boost tidak bisa diverifikasi, claim ditolak. +- Jika user tidak sedang boost server, claim ditolak. ## Slash command target @@ -87,6 +87,6 @@ Command utama yang disiapkan: - `/booster-role icon image` - pasang atau ganti logo/icon role milik sendiri. - `/booster-role delete` - hapus role milik sendiri. -## Catatan eligibility boost 2x +## Catatan eligibility boost -Discord tidak selalu menyediakan data jumlah boost per user secara langsung ke bot. Implementasi saat ini memakai batas `verifiedBoostCount` dan fail-closed jika jumlah boost tidak bisa diverifikasi. Untuk production, hubungkan nilai ini ke sumber data yang benar-benar bisa memverifikasi user punya minimal 2 boost aktif. +Untuk sekarang, user eligible jika sedang boost server (`premiumSince` aktif). Jika user berhenti boost, role bot-managed miliknya akan dihapus lewat event `guildMemberUpdate`. diff --git a/bun.lock b/bun.lock index 4bbd1f0..0afe8da 100644 --- a/bun.lock +++ b/bun.lock @@ -6,12 +6,10 @@ "name": "booster-role-bot", "dependencies": { "@discordjs/builders": "latest", - "better-sqlite3": "latest", "discord.js": "latest", "drizzle-orm": "latest", }, "devDependencies": { - "@types/better-sqlite3": "latest", "@types/bun": "^1.3.14", "drizzle-kit": "latest", "typescript": "latest", diff --git a/package.json b/package.json index 53faf02..c50a47d 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,10 @@ }, "dependencies": { "@discordjs/builders": "latest", - "better-sqlite3": "latest", "discord.js": "latest", "drizzle-orm": "latest" }, "devDependencies": { - "@types/better-sqlite3": "latest", "@types/bun": "^1.3.14", "drizzle-kit": "latest", "typescript": "latest" diff --git a/src/discord/bot.ts b/src/discord/bot.ts new file mode 100644 index 0000000..c690100 --- /dev/null +++ b/src/discord/bot.ts @@ -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; +} diff --git a/src/discord/events/guildMemberUpdate.ts b/src/discord/events/guildMemberUpdate.ts index 7a356f8..2eaa39a 100644 --- a/src/discord/events/guildMemberUpdate.ts +++ b/src/discord/events/guildMemberUpdate.ts @@ -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 { +type GuildMemberLike = { + id: string; + premiumSince: Date | null; + guild: { id: string }; +}; + +export async function handleGuildMemberUpdate(oldMember: GuildMemberLike, newMember: GuildMemberLike, service: BoosterRoleService): Promise { const hadBoost = Boolean(oldMember.premiumSince); const hasBoost = Boolean(newMember.premiumSince); diff --git a/src/discord/interactionHandler.test.ts b/src/discord/interactionHandler.test.ts new file mode 100644 index 0000000..211f942 --- /dev/null +++ b/src/discord/interactionHandler.test.ts @@ -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 = {}) {} + + isChatInputCommand(): boolean { + return true; + } + + async reply(reply: Reply): Promise { + 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 { + this.calls.push("rename"); + } + + async recolorRole(): Promise { + this.calls.push("recolor"); + } + + async setRoleIcon(): Promise { + this.calls.push("icon"); + } + + async deleteRole(): Promise { + 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 }); + }); +}); diff --git a/src/discord/interactionHandler.ts b/src/discord/interactionHandler.ts new file mode 100644 index 0000000..57cf47a --- /dev/null +++ b/src/discord/interactionHandler.ts @@ -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; + 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; + renameRole(input: { guildId: string; userId: string; name: string }): Promise; + recolorRole(input: { guildId: string; userId: string; color: string }): Promise; + setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise; + deleteRole(input: { guildId: string; userId: string }): Promise; +}; + +export type InteractionHandlerDeps = { + isBoosting(guildId: string, userId: string): Promise; +}; + +export async function handleInteraction( + interaction: ChatInputInteractionLike, + service: BoosterRoleCommandService, + deps: InteractionHandlerDeps +): Promise { + 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 + }; +} diff --git a/src/domain/boostEligibility.test.ts b/src/domain/boostEligibility.test.ts index 6437214..3cc30ba 100644 --- a/src/domain/boostEligibility.test.ts +++ b/src/domain/boostEligibility.test.ts @@ -2,17 +2,11 @@ import { describe, expect, test } from "bun:test"; import { assertBoostEligibility } from "./boostEligibility"; describe("boost eligibility", () => { - test("allows users with at least two verified boosts", () => { - expect(() => assertBoostEligibility({ verifiedBoostCount: 2 })).not.toThrow(); - expect(() => assertBoostEligibility({ verifiedBoostCount: 3 })).not.toThrow(); + test("allows users who are currently boosting", () => { + expect(() => assertBoostEligibility({ isBoosting: true })).not.toThrow(); }); - test("rejects users below two verified boosts", () => { - expect(() => assertBoostEligibility({ verifiedBoostCount: 1 })).toThrow("two server boosts"); - expect(() => assertBoostEligibility({ verifiedBoostCount: 0 })).toThrow("two server boosts"); - }); - - test("fails closed when boost count is not verifiable", () => { - expect(() => assertBoostEligibility({ verifiedBoostCount: null })).toThrow("Boost eligibility cannot be verified"); + test("rejects users who are not currently boosting", () => { + expect(() => assertBoostEligibility({ isBoosting: false })).toThrow("User must be currently boosting"); }); }); diff --git a/src/domain/boostEligibility.ts b/src/domain/boostEligibility.ts index 594b62d..b9c5182 100644 --- a/src/domain/boostEligibility.ts +++ b/src/domain/boostEligibility.ts @@ -1,13 +1,9 @@ export type BoostEligibilityInput = { - verifiedBoostCount: number | null; + isBoosting: boolean; }; export function assertBoostEligibility(input: BoostEligibilityInput): void { - if (input.verifiedBoostCount === null) { - throw new Error("Boost eligibility cannot be verified"); - } - - if (input.verifiedBoostCount < 2) { - throw new Error("User must have at least two server boosts to claim a custom role"); + if (!input.isBoosting) { + throw new Error("User must be currently boosting to claim a custom role"); } } diff --git a/src/index.ts b/src/index.ts index a15298f..d0cb6ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { loadConfig } from "./config"; +import { attachBotHandlers } from "./discord/bot"; import { createDiscordClient } from "./discord/client"; import { registerGuildCommandsWithToken } from "./discord/registerCommands"; @@ -9,6 +10,7 @@ await registerGuildCommandsWithToken(config.discordToken, { }); const client = createDiscordClient(); +attachBotHandlers(client, config); client.once("clientReady", () => { console.log(`Logged in as ${client.user?.tag ?? "unknown bot"}`); diff --git a/src/services/boosterRoleService.test.ts b/src/services/boosterRoleService.test.ts index 43c65a5..712c25a 100644 --- a/src/services/boosterRoleService.test.ts +++ b/src/services/boosterRoleService.test.ts @@ -55,7 +55,7 @@ describe("BoosterRoleService", () => { const roles = new FakeRoleRepository(); const service = new BoosterRoleService(store, roles, { anchorPosition: 10 }); - const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "My Role", color: "#aabbcc", verifiedBoostCount: 2 }); + const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "My Role", color: "#aabbcc", isBoosting: true }); expect(claimed.roleId).toBe("created-2"); expect(roles.roles.get(claimed.roleId)?.permissions).toEqual([]); @@ -66,7 +66,7 @@ describe("BoosterRoleService", () => { test("rejects claiming an existing unmanaged role name", async () => { const service = new BoosterRoleService(new MemoryRoleStore(), new FakeRoleRepository(), { anchorPosition: 10 }); - await expect(service.claimRole({ guildId: "guild", userId: "user", name: "vip", color: null, verifiedBoostCount: 2 })).rejects.toThrow("already used"); + await expect(service.claimRole({ guildId: "guild", userId: "user", name: "vip", color: null, isBoosting: true })).rejects.toThrow("already used"); }); test("rejects duplicate claims instead of creating another role", async () => { @@ -74,16 +74,16 @@ describe("BoosterRoleService", () => { const roles = new FakeRoleRepository(); const service = new BoosterRoleService(store, roles, { anchorPosition: 10 }); - await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, verifiedBoostCount: 2 }); + await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true }); - await expect(service.claimRole({ guildId: "guild", userId: "user", name: "Second Role", color: null, verifiedBoostCount: 2 })).rejects.toThrow("already has a booster role"); + await expect(service.claimRole({ guildId: "guild", userId: "user", name: "Second Role", color: null, isBoosting: true })).rejects.toThrow("already has a booster role"); }); test("renames only the stored role owned by the user", async () => { const store = new MemoryRoleStore(); const roles = new FakeRoleRepository([]); const service = new BoosterRoleService(store, roles, { anchorPosition: 10 }); - const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, verifiedBoostCount: 2 }); + const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true }); await service.renameRole({ guildId: "guild", userId: "user", name: "Renamed" }); @@ -95,7 +95,7 @@ describe("BoosterRoleService", () => { const store = new MemoryRoleStore(); const roles = new FakeRoleRepository([]); const service = new BoosterRoleService(store, roles, { anchorPosition: 10 }); - const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, verifiedBoostCount: 2 }); + const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true }); await service.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "image/png", size: 128_000, dataUri: "data:image/png;base64,abc" } }); @@ -107,7 +107,7 @@ describe("BoosterRoleService", () => { const store = new MemoryRoleStore(); const roles = new FakeRoleRepository([]); const service = new BoosterRoleService(store, roles, { anchorPosition: 10, maxIconBytes: 256_000 }); - await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, verifiedBoostCount: 2 }); + await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true }); await expect(service.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "text/html", size: 100, dataUri: "data:text/html;base64,abc" } })).rejects.toThrow("Role icon must be an image"); await expect(service.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "image/png", size: 256_001, dataUri: "data:image/png;base64,abc" } })).rejects.toThrow("Role icon is too large"); @@ -117,7 +117,7 @@ describe("BoosterRoleService", () => { const store = new MemoryRoleStore(); const roles = new FakeRoleRepository([]); const service = new BoosterRoleService(store, roles, { anchorPosition: 10 }); - const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, verifiedBoostCount: 2 }); + const claimed = await service.claimRole({ guildId: "guild", userId: "user", name: "First Role", color: null, isBoosting: true }); await service.removeRoleForLostBoost({ guildId: "guild", userId: "user" }); diff --git a/src/services/boosterRoleService.ts b/src/services/boosterRoleService.ts index c01cf4b..97a0fa6 100644 --- a/src/services/boosterRoleService.ts +++ b/src/services/boosterRoleService.ts @@ -13,6 +13,7 @@ export type BoosterRoleRecord = { roleId: string; name: string; color: string | null; + icon: string | null; createdAt: number; updatedAt: number; }; @@ -58,10 +59,11 @@ export class BoosterRoleService { userId: string; name: string; color: string | null; - verifiedBoostCount: number | null; + icon?: RoleIcon | null; + isBoosting: boolean; }): Promise { - const { guildId, userId, verifiedBoostCount } = input; - assertBoostEligibility({ verifiedBoostCount }); + const { guildId, userId, isBoosting } = input; + assertBoostEligibility({ isBoosting }); const existingRecord = await this.store.findByUser(guildId, userId); if (existingRecord) { @@ -76,6 +78,11 @@ export class BoosterRoleService { assertRolePositionIsSafe(position, this.options.anchorPosition); const role = await this.roles.createRole({ name, color, permissions: [], position }); + if (input.icon) { + this.validateRoleIcon(input.icon); + await this.roles.updateRole(role.id, { icon: input.icon.dataUri }); + } + const timestamp = this.now(); const record = { guildId, @@ -83,6 +90,7 @@ export class BoosterRoleService { roleId: role.id, name, color, + icon: input.icon?.dataUri ?? null, createdAt: timestamp, updatedAt: timestamp }; diff --git a/src/services/bunSqliteBoosterRoleStore.test.ts b/src/services/bunSqliteBoosterRoleStore.test.ts new file mode 100644 index 0000000..de8f253 --- /dev/null +++ b/src/services/bunSqliteBoosterRoleStore.test.ts @@ -0,0 +1,26 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; +import { BunSqliteBoosterRoleStore } from "./bunSqliteBoosterRoleStore"; + +describe("BunSqliteBoosterRoleStore", () => { + test("creates schema and stores booster role records", async () => { + const db = new Database(":memory:"); + const store = new BunSqliteBoosterRoleStore(db); + const record = { + guildId: "guild", + userId: "user", + roleId: "role", + name: "Test Role", + color: "#AABBCC", + icon: null, + createdAt: 1, + updatedAt: 1 + }; + + await store.create(record); + + expect(await store.findByUser("guild", "user")).toEqual(record); + await store.delete("guild", "user"); + expect(await store.findByUser("guild", "user")).toBeNull(); + }); +}); diff --git a/src/services/bunSqliteBoosterRoleStore.ts b/src/services/bunSqliteBoosterRoleStore.ts new file mode 100644 index 0000000..6719c40 --- /dev/null +++ b/src/services/bunSqliteBoosterRoleStore.ts @@ -0,0 +1,71 @@ +import type { Database } from "bun:sqlite"; +import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService"; + +export class BunSqliteBoosterRoleStore implements BoosterRoleStore { + constructor(private readonly db: Database) { + this.db.run(` + CREATE TABLE IF NOT EXISTS booster_roles ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(guild_id, user_id), + UNIQUE(guild_id, role_id) + ) + `); + } + + async findByUser(guildId: string, userId: string): Promise { + const row = this.db + .query(` + SELECT guild_id, user_id, role_id, name, color, icon, created_at, updated_at + FROM booster_roles + WHERE guild_id = ? AND user_id = ? + LIMIT 1 + `) + .get(guildId, userId); + + return row ? toRecord(row) : null; + } + + async create(record: BoosterRoleRecord): Promise { + this.db + .query(` + INSERT INTO booster_roles (guild_id, user_id, role_id, name, color, icon, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + .run(record.guildId, record.userId, record.roleId, record.name, record.color, record.icon, record.createdAt, record.updatedAt); + } + + async delete(guildId: string, userId: string): Promise { + this.db.query("DELETE FROM booster_roles WHERE guild_id = ? AND user_id = ?").run(guildId, userId); + } +} + +type BoosterRoleRow = { + guild_id: string; + user_id: string; + role_id: string; + name: string; + color: string | null; + icon: string | null; + created_at: number; + updated_at: number; +}; + +function toRecord(row: BoosterRoleRow): BoosterRoleRecord { + return { + guildId: row.guild_id, + userId: row.user_id, + roleId: row.role_id, + name: row.name, + color: row.color, + icon: row.icon, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} diff --git a/src/services/discordRoleRepository.ts b/src/services/discordRoleRepository.ts new file mode 100644 index 0000000..1a23d6e --- /dev/null +++ b/src/services/discordRoleRepository.ts @@ -0,0 +1,53 @@ +import type { ColorResolvable, Guild, Role } from "discord.js"; +import type { RoleRepository } from "./boosterRoleService"; + +export class DiscordRoleRepository implements RoleRepository { + constructor(private readonly guild: Guild, private readonly anchorRoleId: string | null) {} + + async listRoles() { + await this.guild.roles.fetch(); + return this.guild.roles.cache.map((role) => ({ id: role.id, name: role.name })); + } + + 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), + permissions: 0n + }); + + await role.setPosition(await this.resolvePosition(input.position)); + return { id: role.id }; + } + + async updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise { + const role = await this.fetchRole(roleId); + await role.edit({ + name: input.name, + color: input.color === undefined ? undefined : toDiscordColor(input.color), + icon: input.icon === undefined ? undefined : input.icon + }); + } + + async deleteRole(roleId: string): Promise { + const role = await this.fetchRole(roleId); + await role.delete(); + } + + private async resolvePosition(fallbackPosition: number): Promise { + if (!this.anchorRoleId) return fallbackPosition; + + const anchor = await this.fetchRole(this.anchorRoleId); + return Math.max(anchor.position - 1, 1); + } + + private async fetchRole(roleId: string): Promise { + const role = await this.guild.roles.fetch(roleId); + if (!role) throw new Error("Discord role not found"); + return role; + } +} + +function toDiscordColor(color: string | null): ColorResolvable | undefined { + return color === null ? undefined : (color as ColorResolvable); +} diff --git a/src/services/sqliteBoosterRoleStore.ts b/src/services/sqliteBoosterRoleStore.ts new file mode 100644 index 0000000..28f16f1 --- /dev/null +++ b/src/services/sqliteBoosterRoleStore.ts @@ -0,0 +1,48 @@ +import { and, eq } from "drizzle-orm"; +import { boosterRoles } from "../db/schema"; +import type { BoosterRoleRecord, BoosterRoleStore } from "./boosterRoleService"; + +type DatabaseLike = { + select(): { + from(table: typeof boosterRoles): { + where(condition: unknown): { + limit(count: number): Promise | BoosterRoleRecord[]; + }; + }; + }; + insert(table: typeof boosterRoles): { + values(record: BoosterRoleRecord): { + run(): unknown; + }; + }; + delete(table: typeof boosterRoles): { + where(condition: unknown): { + run(): unknown; + }; + }; +}; + +export class SqliteBoosterRoleStore implements BoosterRoleStore { + constructor(private readonly db: DatabaseLike) {} + + async findByUser(guildId: string, userId: string): Promise { + const rows = await this.db + .select() + .from(boosterRoles) + .where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId))) + .limit(1); + + return rows[0] ?? null; + } + + async create(record: BoosterRoleRecord): Promise { + await this.db.insert(boosterRoles).values(record).run(); + } + + async delete(guildId: string, userId: string): Promise { + await this.db + .delete(boosterRoles) + .where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId))) + .run(); + } +}