feat(domain): introduce typed error classes and replace fragile string-based error handling

This commit is contained in:
MythEclipse
2026-06-06 03:04:18 +07:00
parent e90a776a21
commit 03e2a28e97
5 changed files with 88 additions and 22 deletions
+12 -7
View File
@@ -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 {
+3 -1
View File
@@ -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");
}
}
+45
View File
@@ -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";
}
}
+10 -9
View File
@@ -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}`;
+18 -5
View File
@@ -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> {