feat: add optional role icon support and validation for booster roles
This commit is contained in:
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user