148 lines
4.6 KiB
TypeScript
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);
|
|
}
|
|
}
|