diff --git a/src/discord/interactionHandler.ts b/src/discord/interactionHandler.ts index 12b82a2..dbb6c6c 100644 --- a/src/discord/interactionHandler.ts +++ b/src/discord/interactionHandler.ts @@ -1,5 +1,6 @@ import { MessageFlags, PermissionFlagsBits } from "discord.js"; import { logger } from "../logger"; +import { ValidationError, NotFoundError, PermissionError } from "../domain/errors"; import type { BoosterRoleRecord, RoleIcon } from "../services/boosterRoleService"; export type ChatInputInteractionLike = { @@ -124,17 +125,21 @@ export async function handleInteraction( } function toUserErrorMessage(error: unknown): string { - if (!(error instanceof Error)) return "Command failed"; - - if (error.message.includes("Failed query")) { - return "Failed to save booster role. Any created role was cleaned up. Try again."; + if (error instanceof ValidationError || error instanceof NotFoundError || error instanceof PermissionError) { + return error.message; } - if (error.message.includes("Missing Permissions")) { - return "Bot is missing permissions or role position to manage this role."; + if (error instanceof Error) { + if (error.message.includes("Missing Permissions")) { + return "Bot is missing permissions or role position to manage this role."; + } + + if (error.message.includes("Failed query") || error.message.includes("insert")) { + return "Failed to save booster role. Any created role was cleaned up. Try again."; + } } - return error.message; + return "Command failed"; } function requireGuildId(guildId: string | null): string { diff --git a/src/domain/boostEligibility.ts b/src/domain/boostEligibility.ts index b9c5182..e5803aa 100644 --- a/src/domain/boostEligibility.ts +++ b/src/domain/boostEligibility.ts @@ -2,8 +2,10 @@ export type BoostEligibilityInput = { isBoosting: boolean; }; +import { PermissionError } from "./errors"; + export function assertBoostEligibility(input: BoostEligibilityInput): void { if (!input.isBoosting) { - throw new Error("User must be currently boosting to claim a custom role"); + throw new PermissionError("User must be currently boosting to claim a custom role"); } } diff --git a/src/domain/errors.ts b/src/domain/errors.ts new file mode 100644 index 0000000..c4ec812 --- /dev/null +++ b/src/domain/errors.ts @@ -0,0 +1,45 @@ +/** + * Base error for all Booster Role bot errors. + * Enables reliable error type checking via `instanceof` instead of fragile + * string matching on `error.message`. + */ +export class BoosterRoleError extends Error { + constructor(message: string) { + super(message); + this.name = "BoosterRoleError"; + } +} + +/** + * Input validation failures — user provided an invalid name, color, icon, etc. + * Safe to show the message directly to the end user. + */ +export class ValidationError extends BoosterRoleError { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +/** + * Resource not found — no stored role, no Discord role, etc. + * Safe to show the message directly to the end user. + */ +export class NotFoundError extends BoosterRoleError { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } +} + +/** + * Permission / authorisation failures — user doesn't own the role, bot + * lacks permissions, position is unsafe, etc. + * Safe to show the message directly to the end user. + */ +export class PermissionError extends BoosterRoleError { + constructor(message: string) { + super(message); + this.name = "PermissionError"; + } +} diff --git a/src/domain/roleGuards.ts b/src/domain/roleGuards.ts index 4fb3f78..54b1b02 100644 --- a/src/domain/roleGuards.ts +++ b/src/domain/roleGuards.ts @@ -4,6 +4,7 @@ export type ExistingRole = { }; import { forbiddenRolePermissions } from "../config/permissions"; +import { ValidationError, PermissionError } from "./errors"; export type ManagedRoleIdentity = { guildId: string; @@ -19,21 +20,21 @@ export function assertRoleNameIsAvailable(name: string, existingRoles: ExistingR const hasUnmanagedRoleName = existingRoles.some((role) => normalizeName(role.name) === normalizedName); if (hasUnmanagedRoleName) { - throw new Error("Role name is already used by an existing server role"); + throw new ValidationError("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"); + throw new PermissionError("Role is not bot-managed in this guild"); } if (stored.userId !== requested.userId) { - throw new Error("Role is not owned by this user"); + throw new PermissionError("Role is not owned by this user"); } if (stored.roleId !== requested.roleId) { - throw new Error("Role is not bot-managed for this user"); + throw new PermissionError("Role is not bot-managed for this user"); } } @@ -41,13 +42,13 @@ 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"); + throw new PermissionError("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"); + throw new PermissionError("Role position is not safe for a cosmetic booster role"); } } @@ -55,11 +56,11 @@ 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"); + throw new ValidationError("Role name must be 3-32 characters"); } if (reservedRoleNames.has(normalizeName(trimmedName)) || trimmedName.includes("@")) { - throw new Error("Role name is not allowed"); + throw new ValidationError("Role name is not allowed"); } return trimmedName; @@ -69,7 +70,7 @@ 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"); + throw new ValidationError("Color must be a hex value like #AABBCC"); } return normalizedColor as `#${string}`; diff --git a/src/services/boosterRoleService.ts b/src/services/boosterRoleService.ts index 98e7f27..b0713a8 100644 --- a/src/services/boosterRoleService.ts +++ b/src/services/boosterRoleService.ts @@ -1,12 +1,16 @@ import { assertBoostEligibility } from "../domain/boostEligibility"; import { assertRoleNameIsAvailable, + assertCanManageStoredRole, + assertCosmeticPermissions, assertRolePositionIsSafe, normalizeHexColor, normalizeOptionalHexColor, validateRoleName, - type ExistingRole + type ExistingRole, + type ManagedRoleIdentity } from "../domain/roleGuards"; +import { ValidationError, NotFoundError } from "../domain/errors"; export type BoosterRoleRecord = { guildId: string; @@ -73,7 +77,7 @@ export class BoosterRoleService { const existingRecord = await this.store.findByUser(guildId, userId); if (existingRecord) { - throw new Error("User already has a booster role"); + throw new ValidationError("User already has a booster role"); } const name = validateRoleName(input.name); @@ -81,6 +85,7 @@ export class BoosterRoleService { const color2 = normalizeOptionalHexColor(input.color2 ?? null); const colors = resolveGradientColors(color, color2); assertRoleNameIsAvailable(name, await this.roles.listRoles()); + assertCosmeticPermissions([]); // booster roles always start with no permissions const position = this.options.anchorPosition - 1; assertRolePositionIsSafe(position, this.options.anchorPosition); @@ -121,6 +126,7 @@ export class BoosterRoleService { async renameRole(input: { guildId: string; userId: string; name: string }): Promise { const { guildId, userId } = input; const record = await this.getUserRecord(guildId, userId); + assertCanManageStoredRole(this.identity(record), { guildId, userId, roleId: record.roleId }); const name = validateRoleName(input.name); assertRoleNameIsAvailable(name, (await this.roles.listRoles()).filter((role) => role.id !== record.roleId)); await this.roles.updateRole(record.roleId, { name }); @@ -129,6 +135,7 @@ export class BoosterRoleService { async recolorRole(input: { guildId: string; userId: string; color: string; color2?: string | null }): Promise { const { guildId, userId, color, color2 } = input; const record = await this.getUserRecord(guildId, userId); + assertCanManageStoredRole(this.identity(record), { guildId, userId, roleId: record.roleId }); const primaryColor = normalizeHexColor(color); const secondaryColor = normalizeOptionalHexColor(color2 ?? null); const colors = resolveGradientColors(primaryColor, secondaryColor); @@ -138,6 +145,7 @@ export class BoosterRoleService { async setRoleIcon(input: { guildId: string; userId: string; icon: RoleIcon }): Promise { const { guildId, userId, icon } = input; const record = await this.getUserRecord(guildId, userId); + assertCanManageStoredRole(this.identity(record), { guildId, userId, roleId: record.roleId }); this.validateRoleIcon(icon); await this.roles.updateRole(record.roleId, { icon: icon.dataUri }); } @@ -145,6 +153,7 @@ export class BoosterRoleService { async deleteRole(input: { guildId: string; userId: string }): Promise { const { guildId, userId } = input; const record = await this.getUserRecord(guildId, userId); + assertCanManageStoredRole(this.identity(record), { guildId, userId, roleId: record.roleId }); await this.roles.deleteRole(record.roleId); await this.store.delete(guildId, userId); } @@ -169,21 +178,25 @@ export class BoosterRoleService { private validateRoleIcon(icon: RoleIcon): void { if (!icon.contentType.startsWith("image/")) { - throw new Error("Role icon must be an image"); + throw new ValidationError("Role icon must be an image"); } if (icon.size > (this.options.maxIconBytes ?? 512_000)) { - throw new Error("Role icon is too large"); + throw new ValidationError("Role icon is too large"); } } private async getUserRecord(guildId: string, userId: string): Promise { const record = await this.store.findByUser(guildId, userId); if (!record) { - throw new Error("No booster role found for this user"); + throw new NotFoundError("No booster role found for this user"); } return record; } + + private identity(record: BoosterRoleRecord): ManagedRoleIdentity { + return { guildId: record.guildId, userId: record.userId, roleId: record.roleId }; + } } async function ignoreRollbackError(action: () => Promise): Promise {