feat(domain): introduce typed error classes and replace fragile string-based error handling
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<BoosterRoleRecord> {
|
||||
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<void>): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user