feat: update booster role eligibility logic and refactor related services
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}`);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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<BoosterRoleRecord> {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<BoosterRoleRecord | null> {
|
||||
const row = this.db
|
||||
.query<BoosterRoleRow, [string, string]>(`
|
||||
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<void> {
|
||||
this.db
|
||||
.query<unknown, [string, string, string, string, string | null, string | null, number, number]>(`
|
||||
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<void> {
|
||||
this.db.query<unknown, [string, string]>("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
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const role = await this.fetchRole(roleId);
|
||||
await role.delete();
|
||||
}
|
||||
|
||||
private async resolvePosition(fallbackPosition: number): Promise<number> {
|
||||
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<Role> {
|
||||
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);
|
||||
}
|
||||
@@ -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[]> | 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<BoosterRoleRecord | null> {
|
||||
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<void> {
|
||||
await this.db.insert(boosterRoles).values(record).run();
|
||||
}
|
||||
|
||||
async delete(guildId: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(boosterRoles)
|
||||
.where(and(eq(boosterRoles.guildId, guildId), eq(boosterRoles.userId, userId)))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user