feat: add optional role icon support and validation for booster roles

This commit is contained in:
MythEclipse
2026-05-15 23:23:16 +07:00
parent c6b1087eb3
commit 9d0322b118
5 changed files with 147 additions and 3 deletions
+90
View File
@@ -0,0 +1,90 @@
# 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.
## Tech stack
- Bun + TypeScript
- discord.js
- SQLite + Drizzle ORM
- Bun test runner
## Prasyarat
- Bun terinstall
- Bot Discord dengan token dari Developer Portal
- Test guild/server Discord
- Bot punya permission `Manage Roles`
- Posisi role bot harus lebih tinggi dari role custom yang akan dibuat
## Setup
```bash
bun install
cp .env.example .env
```
Isi `.env`:
```env
DISCORD_TOKEN=token_bot_discord
DISCORD_CLIENT_ID=client_id_aplikasi_discord
DISCORD_GUILD_ID=id_server_discord
DATABASE_URL=file:./data/booster-role.sqlite
BOOSTER_ROLE_ANCHOR_ROLE_ID=id_role_pembatas_opsional
```
`BOOSTER_ROLE_ANCHOR_ROLE_ID` dipakai sebagai batas posisi role. Role booster custom harus berada di bawah role ini agar tetap kosmetik dan tidak menyentuh role staff/admin.
## Database
Generate dan jalankan migration setelah schema siap:
```bash
bun run db:generate
bun run db:migrate
```
SQLite default tersimpan di `./data/booster-role.sqlite`.
## Menjalankan bot
```bash
bun run dev
```
## Testing
```bash
bun test
bun test src/domain/roleGuards.test.ts
bun run typecheck
bun run lint
```
## Keamanan role
Bot ini dirancang supaya aman dari abuse:
- User tidak bisa claim role Discord yang sudah ada.
- Claim selalu membuat role baru yang dikelola bot.
- Rename, recolor, dan delete hanya berlaku untuk role yang tercatat di database sebagai milik user tersebut.
- Role custom dibuat dengan permission kosong.
- 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.
## Slash command target
Command utama yang disiapkan:
- `/booster-role claim name color icon` - claim role custom baru, dengan icon opsional.
- `/booster-role rename name` - rename role milik sendiri.
- `/booster-role recolor color` - ubah warna role milik sendiri.
- `/booster-role icon image` - pasang atau ganti logo/icon role milik sendiri.
- `/booster-role delete` - hapus role milik sendiri.
## Catatan eligibility boost 2x
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.
+1
View File
@@ -8,6 +8,7 @@ export const boosterRoles = sqliteTable(
roleId: text("role_id").notNull(),
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull()
},
+7
View File
@@ -9,6 +9,7 @@ export const boosterRoleCommand = new SlashCommandBuilder()
.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"))
.addAttachmentOption((option) => option.setName("icon").setDescription("Optional role icon image"))
)
.addSubcommand((command) =>
command
@@ -22,4 +23,10 @@ export const boosterRoleCommand = new SlashCommandBuilder()
.setDescription("Recolor your bot-managed booster role")
.addStringOption((option) => option.setName("color").setDescription("Hex color like #AABBCC").setRequired(true))
)
.addSubcommand((command) =>
command
.setName("icon")
.setDescription("Set an optional icon on your bot-managed booster role")
.addAttachmentOption((option) => option.setName("image").setDescription("Role icon image").setRequired(true))
)
.addSubcommand((command) => command.setName("delete").setDescription("Delete your bot-managed booster role"));
+24 -2
View File
@@ -18,7 +18,7 @@ class MemoryRoleStore {
}
class FakeRoleRepository implements RoleRepository {
roles = new Map<string, { id: string; name: string; permissions: string[]; position: number; color: string | null }>();
roles = new Map<string, { id: string; name: string; permissions: string[]; position: number; color: string | null; icon?: string | null }>();
deletedRoleIds: string[] = [];
constructor(initialRoles = [{ id: "existing-vip", name: "VIP", permissions: [], position: 1, color: null }]) {
@@ -37,7 +37,7 @@ class FakeRoleRepository implements RoleRepository {
return { id };
}
async updateRole(roleId: string, input: { name?: string; color?: string | null }) {
async updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }) {
const role = this.roles.get(roleId);
if (!role) throw new Error("Role does not exist");
this.roles.set(roleId, { ...role, ...input });
@@ -91,6 +91,28 @@ describe("BoosterRoleService", () => {
await expect(service.renameRole({ guildId: "guild", userId: "attacker", name: "Stolen" })).rejects.toThrow("No booster role found");
});
test("sets icon only on 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.setRoleIcon({ guildId: "guild", userId: "user", icon: { contentType: "image/png", size: 128_000, dataUri: "data:image/png;base64,abc" } });
expect(roles.roles.get(claimed.roleId)?.icon).toBe("data:image/png;base64,abc");
await expect(service.setRoleIcon({ guildId: "guild", userId: "attacker", icon: { contentType: "image/png", size: 128_000, dataUri: "data:image/png;base64,abc" } })).rejects.toThrow("No booster role found");
});
test("rejects oversized or non-image role icons", async () => {
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 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");
});
test("removes managed role when eligibility is lost", async () => {
const store = new MemoryRoleStore();
const roles = new FakeRoleRepository([]);
+25 -1
View File
@@ -26,12 +26,19 @@ export type BoosterRoleStore = {
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>;
updateRole(roleId: string, input: { name?: string; color?: string | null; icon?: string | null }): Promise<void>;
deleteRole(roleId: string): Promise<void>;
};
export type RoleIcon = {
contentType: string;
size: number;
dataUri: string;
};
export type BoosterRoleServiceOptions = {
anchorPosition: number;
maxIconBytes?: number;
now?: () => number;
};
@@ -98,6 +105,13 @@ export class BoosterRoleService {
await this.roles.updateRole(record.roleId, { color: normalizeHexColor(color) });
}
async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise<void> {
const { guildId, userId, icon } = input;
const record = await this.getUserRecord(guildId, userId);
this.validateRoleIcon(icon);
await this.roles.updateRole(record.roleId, { icon: icon.dataUri });
}
async deleteRole(input: { guildId: string; userId: string }): Promise<void> {
const { guildId, userId } = input;
const record = await this.getUserRecord(guildId, userId);
@@ -114,6 +128,16 @@ export class BoosterRoleService {
await this.store.delete(guildId, userId);
}
private validateRoleIcon(icon: RoleIcon): void {
if (!icon.contentType.startsWith("image/")) {
throw new Error("Role icon must be an image");
}
if (icon.size > (this.options.maxIconBytes ?? 512_000)) {
throw new Error("Role icon is too large");
}
}
private async getUserRecord(guildId: string, userId: string): Promise<BoosterRoleRecord> {
const record = await this.store.findByUser(guildId, userId);
if (!record) {