Kord/src/errors/ErrorReporter.ts

148 lines
4.6 KiB
TypeScript

import {
Interaction,
EmbedBuilder,
ChatInputCommandInteraction,
MessageComponentInteraction,
ModalSubmitInteraction,
} from 'discord.js';
import { BotError, ErrorCategory } from './BotError';
import { ErrorDefs, createBotError } from './ErrorCodes';
import { logger } from '../utils/logger';
import { t, SupportedLocale, DEFAULT_LOCALE } from '../i18n';
/** Embed color per error category */
const CATEGORY_COLORS: Record<ErrorCategory, number> = {
[ErrorCategory.USER_INPUT]: 0xffa500, // Orange
[ErrorCategory.PERMISSION]: 0xff6b6b, // Red
[ErrorCategory.BOT_INTERNAL]: 0xff4444, // Dark Red
[ErrorCategory.DISCORD_API]: 0x7289da, // Discord Blurple
};
/** Emoji per error category */
const CATEGORY_EMOJI: Record<ErrorCategory, string> = {
[ErrorCategory.USER_INPUT]: '⚠️',
[ErrorCategory.PERMISSION]: '🔒',
[ErrorCategory.BOT_INTERNAL]: '❌',
[ErrorCategory.DISCORD_API]: '🔄',
};
export type RepliableInteraction =
| ChatInputCommandInteraction
| MessageComponentInteraction
| ModalSubmitInteraction;
/**
* Utility class for reporting errors as user-friendly, localized Embeds.
* Error codes and stack traces are logged server-side only.
*/
export class ErrorReporter {
/**
* Reports a BotError to the user via an ephemeral Embed,
* and logs the full error details to the server console.
*/
static async report(
interaction: RepliableInteraction,
error: BotError,
locale: SupportedLocale = DEFAULT_LOCALE,
): Promise<void> {
// ── Server-side logging (detailed) ──
logger.error(
`${error.code}: ${error.message}`,
error.cause ? error.cause : '',
);
// ── User-facing Embed (friendly, localized, no error codes) ──
const embed = this.buildEmbed(error, locale);
try {
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [embed], ephemeral: true });
} else {
await interaction.reply({ embeds: [embed], ephemeral: true });
}
} catch (replyError) {
// If even the error reply fails, log it silently
logger.error('ErrorReporter: Failed to send error response to user', replyError);
}
}
/**
* Wraps an unknown error into a structured BotError.
* Automatically maps known Discord API error codes.
*/
static wrap(error: unknown): BotError {
// Already a BotError — return as-is
if (error instanceof BotError) {
return error;
}
const err = error instanceof Error ? error : new Error(String(error));
// Map known Discord API error codes
const discordCode = (error as any)?.code;
if (typeof discordCode === 'number') {
switch (discordCode) {
case 50013:
return createBotError(ErrorDefs.DISCORD_MISSING_PERMISSIONS, err);
case 50001: // Missing Access
return createBotError(ErrorDefs.BOT_MISSING_MANAGE_CHANNELS, err);
default:
break;
}
}
// Map rate limit errors
if ((error as any)?.httpStatus === 429) {
return createBotError(ErrorDefs.DISCORD_RATE_LIMITED, err);
}
// Fallback — unknown internal error
return createBotError(ErrorDefs.UNKNOWN_ERROR, err);
}
/**
* Builds a user-friendly, localized Embed from a BotError.
* Does NOT include error codes — those stay in logs only.
*/
private static buildEmbed(error: BotError, locale: SupportedLocale): EmbedBuilder {
const emoji = CATEGORY_EMOJI[error.category];
const title = t(locale, `errorTitles.${error.category}`);
const userMessage = t(locale, `${error.messageKey}.userMessage`);
const resolution = t(locale, `${error.messageKey}.resolution`);
const fieldName = t(locale, 'errorFields.resolution');
const embed = new EmbedBuilder()
.setColor(CATEGORY_COLORS[error.category])
.setTitle(`${emoji} ${title}`)
.setDescription(userMessage)
.setTimestamp();
// Only add resolution field if the translation exists (not the key itself)
if (resolution && resolution !== `${error.messageKey}.resolution`) {
embed.addFields({
name: fieldName,
value: resolution,
});
}
return embed;
}
}
/**
* Wraps an async handler function with standardized, localized error handling.
* Any thrown BotError is reported to the user; unknown errors are wrapped first.
*/
export async function withErrorHandler(
interaction: RepliableInteraction,
fn: () => Promise<void>,
locale: SupportedLocale = DEFAULT_LOCALE,
): Promise<void> {
try {
await fn();
} catch (error) {
const botError = ErrorReporter.wrap(error);
await ErrorReporter.report(interaction, botError, locale);
}
}