feat: initialize booster role bot with database and Discord integration
- Add drizzle configuration for SQLite database - Create package.json with necessary dependencies and scripts - Implement configuration loading for Discord bot - Define permissions for roles - Set up database schema for booster roles - Create Discord client and commands for managing booster roles - Handle guild member updates to manage roles based on boost status - Implement boost eligibility checks and role management logic - Add tests for boost eligibility and role guards - Create service for managing booster roles with in-memory storage for testing - Configure TypeScript settings for the project
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
export type AppConfig = {
|
||||
discordToken: string;
|
||||
discordClientId: string;
|
||||
discordGuildId: string;
|
||||
databaseUrl: string;
|
||||
boosterRoleAnchorRoleId: string | null;
|
||||
};
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): AppConfig {
|
||||
return {
|
||||
discordToken: requireEnv(env, "DISCORD_TOKEN"),
|
||||
discordClientId: requireEnv(env, "DISCORD_CLIENT_ID"),
|
||||
discordGuildId: requireEnv(env, "DISCORD_GUILD_ID"),
|
||||
databaseUrl: env.DATABASE_URL ?? "file:./data/booster-role.sqlite",
|
||||
boosterRoleAnchorRoleId: env.BOOSTER_ROLE_ANCHOR_ROLE_ID ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function requireEnv(env: Record<string, string | undefined>, key: string): string {
|
||||
const value = env[key];
|
||||
if (!value) {
|
||||
throw new Error(`${key} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const forbiddenRolePermissions = [
|
||||
"Administrator",
|
||||
"ManageRoles",
|
||||
"ManageChannels",
|
||||
"BanMembers",
|
||||
"KickMembers",
|
||||
"MentionEveryone",
|
||||
"ManageGuild",
|
||||
"ManageWebhooks"
|
||||
] as const;
|
||||
|
||||
export const cosmeticRolePermissions: string[] = [];
|
||||
@@ -0,0 +1,9 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export function createDb(databaseUrl: string) {
|
||||
const sqlitePath = databaseUrl.replace(/^file:/, "");
|
||||
const sqlite = new Database(sqlitePath);
|
||||
return drizzle(sqlite, { schema });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const boosterRoles = sqliteTable(
|
||||
"booster_roles",
|
||||
{
|
||||
guildId: text("guild_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
roleId: text("role_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: uniqueIndex("booster_roles_guild_user_idx").on(table.guildId, table.userId),
|
||||
roleIdx: uniqueIndex("booster_roles_guild_role_idx").on(table.guildId, table.roleId)
|
||||
})
|
||||
);
|
||||
|
||||
export type BoosterRoleRow = typeof boosterRoles.$inferSelect;
|
||||
export type NewBoosterRoleRow = typeof boosterRoles.$inferInsert;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Client, GatewayIntentBits } from "discord.js";
|
||||
|
||||
export function createDiscordClient(): Client {
|
||||
return new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { SlashCommandBuilder } from "discord.js";
|
||||
|
||||
export const boosterRoleCommand = new SlashCommandBuilder()
|
||||
.setName("booster-role")
|
||||
.setDescription("Manage your cosmetic booster role")
|
||||
.addSubcommand((command) =>
|
||||
command
|
||||
.setName("claim")
|
||||
.setDescription("Claim a new custom cosmetic booster role")
|
||||
.addStringOption((option) => option.setName("name").setDescription("Role name").setRequired(true))
|
||||
.addStringOption((option) => option.setName("color").setDescription("Hex color like #AABBCC"))
|
||||
)
|
||||
.addSubcommand((command) =>
|
||||
command
|
||||
.setName("rename")
|
||||
.setDescription("Rename your bot-managed booster role")
|
||||
.addStringOption((option) => option.setName("name").setDescription("New role name").setRequired(true))
|
||||
)
|
||||
.addSubcommand((command) =>
|
||||
command
|
||||
.setName("recolor")
|
||||
.setDescription("Recolor your bot-managed booster role")
|
||||
.addStringOption((option) => option.setName("color").setDescription("Hex color like #AABBCC").setRequired(true))
|
||||
)
|
||||
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"));
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { GuildMember } from "discord.js";
|
||||
import type { BoosterRoleService } from "../../services/boosterRoleService";
|
||||
|
||||
export async function handleGuildMemberUpdate(oldMember: GuildMember, newMember: GuildMember, service: BoosterRoleService): Promise<void> {
|
||||
const hadBoost = Boolean(oldMember.premiumSince);
|
||||
const hasBoost = Boolean(newMember.premiumSince);
|
||||
|
||||
if (hadBoost && !hasBoost) {
|
||||
await service.removeRoleForLostBoost({ guildId: newMember.guild.id, userId: newMember.id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export type BoostEligibilityInput = {
|
||||
verifiedBoostCount: number | null;
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
assertCanManageStoredRole,
|
||||
assertCosmeticPermissions,
|
||||
assertRoleNameIsAvailable,
|
||||
assertRolePositionIsSafe,
|
||||
normalizeHexColor,
|
||||
validateRoleName
|
||||
} from "./roleGuards";
|
||||
|
||||
const dangerousPermissions = [
|
||||
"Administrator",
|
||||
"ManageRoles",
|
||||
"ManageChannels",
|
||||
"BanMembers",
|
||||
"KickMembers",
|
||||
"MentionEveryone",
|
||||
"ManageGuild",
|
||||
"ManageWebhooks"
|
||||
];
|
||||
|
||||
describe("role guards", () => {
|
||||
test("rejects claiming a name already used by an unmanaged role", () => {
|
||||
expect(() =>
|
||||
assertRoleNameIsAvailable("VIP", [
|
||||
{ id: "role-1", name: "VIP" },
|
||||
{ id: "role-2", name: "Member" }
|
||||
])
|
||||
).toThrow("Role name is already used");
|
||||
});
|
||||
|
||||
test("allows managing only the role stored for the requesting user", () => {
|
||||
expect(() =>
|
||||
assertCanManageStoredRole(
|
||||
{ guildId: "guild", userId: "user", roleId: "managed-role" },
|
||||
{ guildId: "guild", userId: "attacker", roleId: "managed-role" }
|
||||
)
|
||||
).toThrow("Role is not owned by this user");
|
||||
|
||||
expect(() =>
|
||||
assertCanManageStoredRole(
|
||||
{ guildId: "guild", userId: "user", roleId: "managed-role" },
|
||||
{ guildId: "guild", userId: "user", roleId: "other-role" }
|
||||
)
|
||||
).toThrow("Role is not bot-managed");
|
||||
});
|
||||
|
||||
test("rejects dangerous permissions", () => {
|
||||
for (const permission of dangerousPermissions) {
|
||||
expect(() => assertCosmeticPermissions([permission])).toThrow("Booster roles must be cosmetic");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects role positions at or above the anchor role", () => {
|
||||
expect(() => assertRolePositionIsSafe(10, 10)).toThrow("Role position is not safe");
|
||||
expect(() => assertRolePositionIsSafe(11, 10)).toThrow("Role position is not safe");
|
||||
expect(() => assertRolePositionIsSafe(9, 10)).not.toThrow();
|
||||
});
|
||||
|
||||
test("validates role names and hex colors", () => {
|
||||
expect(validateRoleName(" My Booster Role ")).toBe("My Booster Role");
|
||||
expect(() => validateRoleName("@everyone")).toThrow("Role name is not allowed");
|
||||
expect(() => validateRoleName("ab")).toThrow("Role name must be 3-32 characters");
|
||||
expect(normalizeHexColor("#aabbcc")).toBe("#AABBCC");
|
||||
expect(() => normalizeHexColor("blue")).toThrow("Color must be a hex value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
export type ExistingRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
import { forbiddenRolePermissions } from "../config/permissions";
|
||||
|
||||
export type ManagedRoleIdentity = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
const forbiddenPermissions = new Set<string>(forbiddenRolePermissions);
|
||||
const reservedRoleNames = new Set(["@everyone", "here", "everyone"]);
|
||||
|
||||
export function assertRoleNameIsAvailable(name: string, existingRoles: ExistingRole[]): void {
|
||||
const normalizedName = normalizeName(name);
|
||||
const hasUnmanagedRoleName = existingRoles.some((role) => normalizeName(role.name) === normalizedName);
|
||||
|
||||
if (hasUnmanagedRoleName) {
|
||||
throw new Error("Role name is already used by an existing server role");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCanManageStoredRole(stored: ManagedRoleIdentity, requested: ManagedRoleIdentity): void {
|
||||
if (stored.guildId !== requested.guildId) {
|
||||
throw new Error("Role is not bot-managed in this guild");
|
||||
}
|
||||
|
||||
if (stored.userId !== requested.userId) {
|
||||
throw new Error("Role is not owned by this user");
|
||||
}
|
||||
|
||||
if (stored.roleId !== requested.roleId) {
|
||||
throw new Error("Role is not bot-managed for this user");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCosmeticPermissions(permissions: string[]): void {
|
||||
const hasDangerousPermission = permissions.some((permission) => forbiddenPermissions.has(permission));
|
||||
|
||||
if (hasDangerousPermission) {
|
||||
throw new Error("Booster roles must be cosmetic and cannot grant elevated permissions");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRolePositionIsSafe(targetPosition: number, anchorPosition: number): void {
|
||||
if (targetPosition >= anchorPosition) {
|
||||
throw new Error("Role position is not safe for a cosmetic booster role");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateRoleName(name: string): string {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (trimmedName.length < 3 || trimmedName.length > 32) {
|
||||
throw new Error("Role name must be 3-32 characters");
|
||||
}
|
||||
|
||||
if (reservedRoleNames.has(normalizeName(trimmedName)) || trimmedName.includes("@")) {
|
||||
throw new Error("Role name is not allowed");
|
||||
}
|
||||
|
||||
return trimmedName;
|
||||
}
|
||||
|
||||
export function normalizeHexColor(color: string): `#${string}` {
|
||||
const normalizedColor = color.trim().toUpperCase();
|
||||
|
||||
if (!/^#[0-9A-F]{6}$/.test(normalizedColor)) {
|
||||
throw new Error("Color must be a hex value like #AABBCC");
|
||||
}
|
||||
|
||||
return normalizedColor as `#${string}`;
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { loadConfig } from "./config";
|
||||
import { createDiscordClient } from "./discord/client";
|
||||
|
||||
const config = loadConfig();
|
||||
const client = createDiscordClient();
|
||||
|
||||
client.once("ready", () => {
|
||||
console.log(`Logged in as ${client.user?.tag ?? "unknown bot"}`);
|
||||
});
|
||||
|
||||
await client.login(config.discordToken);
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { BoosterRoleService, type BoosterRoleRecord, type RoleRepository } from "./boosterRoleService";
|
||||
|
||||
class MemoryRoleStore {
|
||||
private records = new Map<string, BoosterRoleRecord>();
|
||||
|
||||
async findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null> {
|
||||
return this.records.get(`${guildId}:${userId}`) ?? null;
|
||||
}
|
||||
|
||||
async create(record: BoosterRoleRecord): Promise<void> {
|
||||
this.records.set(`${record.guildId}:${record.userId}`, record);
|
||||
}
|
||||
|
||||
async delete(guildId: string, userId: string): Promise<void> {
|
||||
this.records.delete(`${guildId}:${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRoleRepository implements RoleRepository {
|
||||
roles = new Map<string, { id: string; name: string; permissions: string[]; position: number; color: string | null }>();
|
||||
deletedRoleIds: string[] = [];
|
||||
|
||||
constructor(initialRoles = [{ id: "existing-vip", name: "VIP", permissions: [], position: 1, color: null }]) {
|
||||
for (const role of initialRoles) {
|
||||
this.roles.set(role.id, role);
|
||||
}
|
||||
}
|
||||
|
||||
async listRoles() {
|
||||
return [...this.roles.values()];
|
||||
}
|
||||
|
||||
async createRole(input: { name: string; color: string | null; permissions: string[]; position: number }) {
|
||||
const id = `created-${this.roles.size + 1}`;
|
||||
this.roles.set(id, { id, ...input });
|
||||
return { id };
|
||||
}
|
||||
|
||||
async updateRole(roleId: string, input: { name?: string; color?: string | null }) {
|
||||
const role = this.roles.get(roleId);
|
||||
if (!role) throw new Error("Role does not exist");
|
||||
this.roles.set(roleId, { ...role, ...input });
|
||||
}
|
||||
|
||||
async deleteRole(roleId: string) {
|
||||
this.deletedRoleIds.push(roleId);
|
||||
this.roles.delete(roleId);
|
||||
}
|
||||
}
|
||||
|
||||
describe("BoosterRoleService", () => {
|
||||
test("eligible user claims one new cosmetic managed role", 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: "My Role", color: "#aabbcc", verifiedBoostCount: 2 });
|
||||
|
||||
expect(claimed.roleId).toBe("created-2");
|
||||
expect(roles.roles.get(claimed.roleId)?.permissions).toEqual([]);
|
||||
expect(roles.roles.get(claimed.roleId)?.position).toBe(9);
|
||||
expect(await store.findByUser("guild", "user")).toEqual(claimed);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("rejects duplicate claims instead of creating another role", async () => {
|
||||
const store = new MemoryRoleStore();
|
||||
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 expect(service.claimRole({ guildId: "guild", userId: "user", name: "Second Role", color: null, verifiedBoostCount: 2 })).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 });
|
||||
|
||||
await service.renameRole({ guildId: "guild", userId: "user", name: "Renamed" });
|
||||
|
||||
expect(roles.roles.get(claimed.roleId)?.name).toBe("Renamed");
|
||||
await expect(service.renameRole({ guildId: "guild", userId: "attacker", name: "Stolen" })).rejects.toThrow("No booster role found");
|
||||
});
|
||||
|
||||
test("removes managed role when eligibility is lost", 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 });
|
||||
|
||||
await service.removeRoleForLostBoost({ guildId: "guild", userId: "user" });
|
||||
|
||||
expect(roles.deletedRoleIds).toEqual([claimed.roleId]);
|
||||
expect(await store.findByUser("guild", "user")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { assertBoostEligibility } from "../domain/boostEligibility";
|
||||
import {
|
||||
assertRoleNameIsAvailable,
|
||||
assertRolePositionIsSafe,
|
||||
normalizeHexColor,
|
||||
validateRoleName,
|
||||
type ExistingRole
|
||||
} from "../domain/roleGuards";
|
||||
|
||||
export type BoosterRoleRecord = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
roleId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type BoosterRoleStore = {
|
||||
findByUser(guildId: string, userId: string): Promise<BoosterRoleRecord | null>;
|
||||
create(record: BoosterRoleRecord): Promise<void>;
|
||||
delete(guildId: string, userId: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type RoleRepository = {
|
||||
listRoles(): Promise<ExistingRole[]>;
|
||||
createRole(input: { name: string; color: string | null; permissions: string[]; position: number }): Promise<{ id: string }>;
|
||||
updateRole(roleId: string, input: { name?: string; color?: string | null }): Promise<void>;
|
||||
deleteRole(roleId: string): Promise<void>;
|
||||
};
|
||||
|
||||
export type BoosterRoleServiceOptions = {
|
||||
anchorPosition: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export class BoosterRoleService {
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly store: BoosterRoleStore,
|
||||
private readonly roles: RoleRepository,
|
||||
private readonly options: BoosterRoleServiceOptions
|
||||
) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async claimRole(input: {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
verifiedBoostCount: number | null;
|
||||
}): Promise<BoosterRoleRecord> {
|
||||
const { guildId, userId, verifiedBoostCount } = input;
|
||||
assertBoostEligibility({ verifiedBoostCount });
|
||||
|
||||
const existingRecord = await this.store.findByUser(guildId, userId);
|
||||
if (existingRecord) {
|
||||
throw new Error("User already has a booster role");
|
||||
}
|
||||
|
||||
const name = validateRoleName(input.name);
|
||||
const color = input.color ? normalizeHexColor(input.color) : null;
|
||||
assertRoleNameIsAvailable(name, await this.roles.listRoles());
|
||||
|
||||
const position = this.options.anchorPosition - 1;
|
||||
assertRolePositionIsSafe(position, this.options.anchorPosition);
|
||||
|
||||
const role = await this.roles.createRole({ name, color, permissions: [], position });
|
||||
const timestamp = this.now();
|
||||
const record = {
|
||||
guildId,
|
||||
userId,
|
||||
roleId: role.id,
|
||||
name,
|
||||
color,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
|
||||
await this.store.create(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async renameRole(input: { guildId: string; userId: string; name: string }): Promise<void> {
|
||||
const { guildId, userId } = input;
|
||||
const record = await this.getUserRecord(guildId, userId);
|
||||
const name = validateRoleName(input.name);
|
||||
assertRoleNameIsAvailable(name, (await this.roles.listRoles()).filter((role) => role.id !== record.roleId));
|
||||
await this.roles.updateRole(record.roleId, { name });
|
||||
}
|
||||
|
||||
async recolorRole(input: { guildId: string; userId: string; color: string }): Promise<void> {
|
||||
const { guildId, userId, color } = input;
|
||||
const record = await this.getUserRecord(guildId, userId);
|
||||
await this.roles.updateRole(record.roleId, { color: normalizeHexColor(color) });
|
||||
}
|
||||
|
||||
async deleteRole(input: { guildId: string; userId: string }): Promise<void> {
|
||||
const { guildId, userId } = input;
|
||||
const record = await this.getUserRecord(guildId, userId);
|
||||
await this.roles.deleteRole(record.roleId);
|
||||
await this.store.delete(guildId, userId);
|
||||
}
|
||||
|
||||
async removeRoleForLostBoost(input: { guildId: string; userId: string }): Promise<void> {
|
||||
const { guildId, userId } = input;
|
||||
const record = await this.store.findByUser(guildId, userId);
|
||||
if (!record) return;
|
||||
|
||||
await this.roles.deleteRole(record.roleId);
|
||||
await this.store.delete(guildId, userId);
|
||||
}
|
||||
|
||||
private async getUserRecord(guildId: string, userId: string): Promise<BoosterRoleRecord> {
|
||||
const record = await this.store.findByUser(guildId, userId);
|
||||
if (!record) {
|
||||
throw new Error("No booster role found for this user");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user