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:
MythEclipse
2026-05-15 23:03:35 +07:00
parent 63d3e0a8c1
commit c6b1087eb3
20 changed files with 919 additions and 0 deletions
+25
View File
@@ -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;
}
+12
View File
@@ -0,0 +1,12 @@
export const forbiddenRolePermissions = [
"Administrator",
"ManageRoles",
"ManageChannels",
"BanMembers",
"KickMembers",
"MentionEveryone",
"ManageGuild",
"ManageWebhooks"
] as const;
export const cosmeticRolePermissions: string[] = [];
+9
View File
@@ -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 });
}
+21
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import { Client, GatewayIntentBits } from "discord.js";
export function createDiscordClient(): Client {
return new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
});
}
+25
View File
@@ -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"));
+11
View File
@@ -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 });
}
}
+18
View File
@@ -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");
});
});
+13
View File
@@ -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");
}
}
+67
View File
@@ -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");
});
});
+80
View File
@@ -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();
}
+11
View File
@@ -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);
+105
View File
@@ -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();
});
});
+124
View File
@@ -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;
}
}