(Feat): Added a minimal pikanetwork client
This commit is contained in:
334
src/utils/embeds.ts
Normal file
334
src/utils/embeds.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Embed Utilities for Elly Discord Bot
|
||||
* Provides helper functions for creating consistent embeds
|
||||
*/
|
||||
|
||||
import { EmbedBuilder, type ColorResolvable, type User } from 'discord.js';
|
||||
import type { EmbedColors } from '../types/index.ts';
|
||||
|
||||
/**
|
||||
* Default embed colors
|
||||
*/
|
||||
export const DEFAULT_COLORS: EmbedColors = {
|
||||
primary: 0x5865f2,
|
||||
success: 0x57f287,
|
||||
warning: 0xfee75c,
|
||||
error: 0xed4245,
|
||||
info: 0x5865f2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a success embed
|
||||
*/
|
||||
export function successEmbed(
|
||||
title: string,
|
||||
description?: string,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(colors.success as ColorResolvable)
|
||||
.setTitle(`✅ ${title}`);
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error embed
|
||||
*/
|
||||
export function errorEmbed(
|
||||
title: string,
|
||||
description?: string,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(colors.error as ColorResolvable)
|
||||
.setTitle(`❌ ${title}`);
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a warning embed
|
||||
*/
|
||||
export function warningEmbed(
|
||||
title: string,
|
||||
description?: string,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(colors.warning as ColorResolvable)
|
||||
.setTitle(`⚠️ ${title}`);
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an info embed
|
||||
*/
|
||||
export function infoEmbed(
|
||||
title: string,
|
||||
description?: string,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(colors.info as ColorResolvable)
|
||||
.setTitle(`ℹ️ ${title}`);
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a primary embed
|
||||
*/
|
||||
export function primaryEmbed(
|
||||
title: string,
|
||||
description?: string,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(colors.primary as ColorResolvable)
|
||||
.setTitle(title);
|
||||
|
||||
if (description) {
|
||||
embed.setDescription(description);
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a footer with user info
|
||||
*/
|
||||
export function withUserFooter(embed: EmbedBuilder, user: User, text?: string): EmbedBuilder {
|
||||
const footerText = text ? `${text} • Requested by ${user.tag}` : `Requested by ${user.tag}`;
|
||||
return embed.setFooter({
|
||||
text: footerText,
|
||||
iconURL: user.displayAvatarURL(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a timestamp to an embed
|
||||
*/
|
||||
export function withTimestamp(embed: EmbedBuilder, date?: Date): EmbedBuilder {
|
||||
return embed.setTimestamp(date ?? new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loading embed
|
||||
*/
|
||||
export function loadingEmbed(
|
||||
message: string = 'Loading...',
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setColor(colors.info as ColorResolvable)
|
||||
.setDescription(`⏳ ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stats embed for BedWars/SkyWars
|
||||
*/
|
||||
export function statsEmbed(
|
||||
title: string,
|
||||
username: string,
|
||||
stats: Record<string, string | number>,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(colors.primary as ColorResolvable)
|
||||
.setTitle(title)
|
||||
.setThumbnail(`https://mc-heads.net/head/${username}/right`);
|
||||
|
||||
for (const [key, value] of Object.entries(stats)) {
|
||||
embed.addFields({
|
||||
name: key,
|
||||
value: String(value),
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a guild info embed
|
||||
*/
|
||||
export function guildInfoEmbed(
|
||||
guildName: string,
|
||||
owner: string,
|
||||
members: number,
|
||||
level: number,
|
||||
exp: number,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setColor(colors.primary as ColorResolvable)
|
||||
.setTitle(`📜 ${guildName} | Guild Information`)
|
||||
.setThumbnail(`https://mc-heads.net/head/${owner}/right`)
|
||||
.addFields(
|
||||
{ name: 'Owner', value: owner, inline: true },
|
||||
{ name: 'Members', value: String(members), inline: true },
|
||||
{ name: 'Level', value: String(level), inline: true },
|
||||
{ name: 'Experience', value: exp.toLocaleString(), inline: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an application embed
|
||||
*/
|
||||
export function applicationEmbed(
|
||||
applicant: User,
|
||||
answers: Record<string, string>,
|
||||
rating: number = 0,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(colors.warning as ColorResolvable)
|
||||
.setTitle(`${applicant.globalName ?? applicant.username}'s Application`)
|
||||
.setThumbnail(applicant.displayAvatarURL())
|
||||
.setFooter({ text: `User ID: ${applicant.id}` });
|
||||
|
||||
for (const [question, answer] of Object.entries(answers)) {
|
||||
embed.addFields({
|
||||
name: question,
|
||||
value: answer,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
embed.addFields({
|
||||
name: 'Application Rating',
|
||||
value: String(rating),
|
||||
inline: false,
|
||||
});
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a suggestion embed
|
||||
*/
|
||||
export function suggestionEmbed(
|
||||
author: User,
|
||||
title: string,
|
||||
description: string,
|
||||
suggestionNumber: number,
|
||||
rating: number = 0,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setColor(colors.success as ColorResolvable)
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.addFields({
|
||||
name: 'Suggestion Rating',
|
||||
value: String(rating),
|
||||
inline: false,
|
||||
})
|
||||
.setFooter({
|
||||
text: `By ${author.tag} • Suggestion #${suggestionNumber}`,
|
||||
iconURL: author.displayAvatarURL(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a relationship embed
|
||||
*/
|
||||
export function relationshipEmbed(
|
||||
user: User,
|
||||
partner: string | null,
|
||||
parent: string | null,
|
||||
children: string[],
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x1ab968)
|
||||
.setTitle(`${user.tag}'s Relationships`)
|
||||
.setThumbnail(user.displayAvatarURL())
|
||||
.addFields(
|
||||
{
|
||||
name: 'Parent',
|
||||
value: parent ?? 'Nobody',
|
||||
inline: false,
|
||||
},
|
||||
{
|
||||
name: 'Partner',
|
||||
value: partner ?? 'Nobody',
|
||||
inline: false,
|
||||
},
|
||||
{
|
||||
name: 'Children',
|
||||
value: children.length > 0 ? children.join('\n') : 'None',
|
||||
inline: false,
|
||||
}
|
||||
);
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reminder embed
|
||||
*/
|
||||
export function reminderEmbed(
|
||||
user: User,
|
||||
reminderText: string,
|
||||
reminderId: string,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setColor(colors.info as ColorResolvable)
|
||||
.setTitle('⏰ Reminder')
|
||||
.setDescription(`\`\`\`${reminderText}\`\`\``)
|
||||
.setFooter({
|
||||
text: `Reminder ID: ${reminderId}`,
|
||||
iconURL: user.displayAvatarURL(),
|
||||
})
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a moderation log embed
|
||||
*/
|
||||
export function modLogEmbed(
|
||||
action: string,
|
||||
moderator: User,
|
||||
target: User,
|
||||
reason: string,
|
||||
colors: EmbedColors = DEFAULT_COLORS
|
||||
): EmbedBuilder {
|
||||
const colorMap: Record<string, number> = {
|
||||
BAN: colors.error,
|
||||
KICK: colors.warning,
|
||||
MUTE: colors.warning,
|
||||
WARN: colors.warning,
|
||||
UNBAN: colors.success,
|
||||
UNMUTE: colors.success,
|
||||
};
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setColor((colorMap[action] ?? colors.info) as ColorResolvable)
|
||||
.setTitle(`📋 ${action}`)
|
||||
.addFields(
|
||||
{ name: 'Moderator', value: `<@${moderator.id}>`, inline: true },
|
||||
{ name: 'Target', value: `<@${target.id}>`, inline: true },
|
||||
{ name: 'Reason', value: reason || 'No reason provided', inline: false }
|
||||
)
|
||||
.setTimestamp();
|
||||
}
|
||||
492
src/utils/errors.ts
Normal file
492
src/utils/errors.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Error Handling Utilities
|
||||
* Provides comprehensive error handling for the bot
|
||||
*/
|
||||
|
||||
import { EmbedBuilder, type ChatInputCommandInteraction } from 'discord.js';
|
||||
import { createLogger } from './logger.ts';
|
||||
|
||||
const logger = createLogger('ErrorHandler');
|
||||
|
||||
// ============================================================================
|
||||
// Error Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base error class for all bot errors
|
||||
*/
|
||||
export class BotError extends Error {
|
||||
public readonly code: string;
|
||||
public readonly userMessage: string;
|
||||
public readonly isOperational: boolean;
|
||||
public readonly timestamp: Date;
|
||||
public readonly context?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
options: {
|
||||
userMessage?: string;
|
||||
isOperational?: boolean;
|
||||
context?: Record<string, unknown>;
|
||||
cause?: Error;
|
||||
} = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BotError';
|
||||
this.code = code;
|
||||
this.userMessage = options.userMessage ?? 'An unexpected error occurred.';
|
||||
this.isOperational = options.isOperational ?? true;
|
||||
this.timestamp = new Date();
|
||||
this.context = options.context;
|
||||
this.cause = options.cause;
|
||||
|
||||
// Capture stack trace
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON for logging
|
||||
*/
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
userMessage: this.userMessage,
|
||||
isOperational: this.isOperational,
|
||||
timestamp: this.timestamp.toISOString(),
|
||||
context: this.context,
|
||||
stack: this.stack,
|
||||
cause: this.cause instanceof Error ? {
|
||||
name: this.cause.name,
|
||||
message: this.cause.message,
|
||||
stack: this.cause.stack,
|
||||
} : this.cause,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command execution error
|
||||
*/
|
||||
export class CommandError extends BotError {
|
||||
public readonly commandName: string;
|
||||
public readonly userId: string;
|
||||
public readonly guildId?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
commandName: string,
|
||||
userId: string,
|
||||
options: {
|
||||
guildId?: string;
|
||||
userMessage?: string;
|
||||
context?: Record<string, unknown>;
|
||||
cause?: Error;
|
||||
} = {}
|
||||
) {
|
||||
super(message, 'COMMAND_ERROR', {
|
||||
userMessage: options.userMessage ?? 'Failed to execute command.',
|
||||
context: { commandName, userId, guildId: options.guildId, ...options.context },
|
||||
cause: options.cause,
|
||||
});
|
||||
this.name = 'CommandError';
|
||||
this.commandName = commandName;
|
||||
this.userId = userId;
|
||||
this.guildId = options.guildId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission error
|
||||
*/
|
||||
export class PermissionError extends BotError {
|
||||
constructor(
|
||||
requiredPermission: string,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
super(
|
||||
`Missing required permission: ${requiredPermission}`,
|
||||
'PERMISSION_ERROR',
|
||||
{
|
||||
userMessage: 'You do not have permission to use this command.',
|
||||
context: { requiredPermission, ...options.context },
|
||||
}
|
||||
);
|
||||
this.name = 'PermissionError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error
|
||||
*/
|
||||
export class ValidationError extends BotError {
|
||||
public readonly field?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
field?: string;
|
||||
userMessage?: string;
|
||||
context?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
super(message, 'VALIDATION_ERROR', {
|
||||
userMessage: options.userMessage ?? message,
|
||||
context: { field: options.field, ...options.context },
|
||||
});
|
||||
this.name = 'ValidationError';
|
||||
this.field = options.field;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API error
|
||||
*/
|
||||
export class APIError extends BotError {
|
||||
public readonly statusCode?: number;
|
||||
public readonly endpoint?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
statusCode?: number;
|
||||
endpoint?: string;
|
||||
userMessage?: string;
|
||||
context?: Record<string, unknown>;
|
||||
cause?: Error;
|
||||
} = {}
|
||||
) {
|
||||
super(message, 'API_ERROR', {
|
||||
userMessage: options.userMessage ?? 'Failed to fetch data from external service.',
|
||||
context: { statusCode: options.statusCode, endpoint: options.endpoint, ...options.context },
|
||||
cause: options.cause,
|
||||
});
|
||||
this.name = 'APIError';
|
||||
this.statusCode = options.statusCode;
|
||||
this.endpoint = options.endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit error
|
||||
*/
|
||||
export class RateLimitError extends BotError {
|
||||
public readonly retryAfter: number;
|
||||
|
||||
constructor(
|
||||
retryAfter: number,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
super(
|
||||
`Rate limited. Retry after ${retryAfter}ms`,
|
||||
'RATE_LIMIT_ERROR',
|
||||
{
|
||||
userMessage: `Please wait ${Math.ceil(retryAfter / 1000)} seconds before trying again.`,
|
||||
context: { retryAfter, ...options.context },
|
||||
}
|
||||
);
|
||||
this.name = 'RateLimitError';
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration error
|
||||
*/
|
||||
export class ConfigError extends BotError {
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
context?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
super(message, 'CONFIG_ERROR', {
|
||||
userMessage: 'Bot configuration error. Please contact an administrator.',
|
||||
isOperational: false,
|
||||
context: options.context,
|
||||
});
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Handler Class
|
||||
// ============================================================================
|
||||
|
||||
export class ErrorHandler {
|
||||
private static instance: ErrorHandler;
|
||||
private errorCount = 0;
|
||||
private lastErrors: BotError[] = [];
|
||||
private readonly maxStoredErrors = 100;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): ErrorHandler {
|
||||
if (!ErrorHandler.instance) {
|
||||
ErrorHandler.instance = new ErrorHandler();
|
||||
}
|
||||
return ErrorHandler.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error
|
||||
*/
|
||||
async handle(error: unknown, context?: Record<string, unknown>): Promise<BotError> {
|
||||
const botError = this.normalize(error, context);
|
||||
|
||||
// Log the error
|
||||
this.log(botError);
|
||||
|
||||
// Store for analysis
|
||||
this.store(botError);
|
||||
|
||||
// Track count
|
||||
this.errorCount++;
|
||||
|
||||
return botError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle command error and respond to interaction
|
||||
*/
|
||||
async handleCommandError(
|
||||
error: unknown,
|
||||
interaction: ChatInputCommandInteraction
|
||||
): Promise<void> {
|
||||
const botError = await this.handle(error, {
|
||||
commandName: interaction.commandName,
|
||||
userId: interaction.user.id,
|
||||
guildId: interaction.guildId,
|
||||
});
|
||||
|
||||
// Create error embed
|
||||
const embed = this.createErrorEmbed(botError);
|
||||
|
||||
// Respond to interaction
|
||||
try {
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
await interaction.followUp({ embeds: [embed], ephemeral: true });
|
||||
} else {
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
} catch (replyError) {
|
||||
logger.error('Failed to send error response:', replyError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize any error to BotError
|
||||
*/
|
||||
private normalize(error: unknown, context?: Record<string, unknown>): BotError {
|
||||
if (error instanceof BotError) {
|
||||
if (context) {
|
||||
error.context = { ...error.context, ...context };
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new BotError(error.message, 'UNKNOWN_ERROR', {
|
||||
context,
|
||||
cause: error,
|
||||
isOperational: false,
|
||||
});
|
||||
}
|
||||
|
||||
return new BotError(String(error), 'UNKNOWN_ERROR', {
|
||||
context,
|
||||
isOperational: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error
|
||||
*/
|
||||
private log(error: BotError): void {
|
||||
const logData = error.toJSON();
|
||||
|
||||
if (error.isOperational) {
|
||||
logger.warn(`[${error.code}] ${error.message}`, logData);
|
||||
} else {
|
||||
logger.error(`[${error.code}] ${error.message}`, logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store error for analysis
|
||||
*/
|
||||
private store(error: BotError): void {
|
||||
this.lastErrors.push(error);
|
||||
|
||||
// Keep only recent errors
|
||||
if (this.lastErrors.length > this.maxStoredErrors) {
|
||||
this.lastErrors.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error embed for user
|
||||
*/
|
||||
private createErrorEmbed(error: BotError): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setColor(0xED4245)
|
||||
.setTitle('❌ Error')
|
||||
.setDescription(error.userMessage)
|
||||
.addFields({
|
||||
name: 'Error Code',
|
||||
value: `\`${error.code}\``,
|
||||
inline: true,
|
||||
})
|
||||
.setFooter({ text: `Error ID: ${error.timestamp.getTime()}` })
|
||||
.setTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error statistics
|
||||
*/
|
||||
getStats(): {
|
||||
totalErrors: number;
|
||||
recentErrors: number;
|
||||
errorsByCode: Record<string, number>;
|
||||
} {
|
||||
const errorsByCode: Record<string, number> = {};
|
||||
|
||||
for (const error of this.lastErrors) {
|
||||
errorsByCode[error.code] = (errorsByCode[error.code] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalErrors: this.errorCount,
|
||||
recentErrors: this.lastErrors.length,
|
||||
errorsByCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent errors
|
||||
*/
|
||||
getRecentErrors(limit: number = 10): BotError[] {
|
||||
return this.lastErrors.slice(-limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored errors
|
||||
*/
|
||||
clearErrors(): void {
|
||||
this.lastErrors = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the global error handler instance
|
||||
*/
|
||||
export function getErrorHandler(): ErrorHandler {
|
||||
return ErrorHandler.getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an async function with error handling
|
||||
*/
|
||||
export function withErrorHandling<T extends (...args: unknown[]) => Promise<unknown>>(
|
||||
fn: T,
|
||||
context?: Record<string, unknown>
|
||||
): T {
|
||||
return (async (...args: Parameters<T>) => {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
await getErrorHandler().handle(error, context);
|
||||
throw error;
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a condition, throwing ValidationError if false
|
||||
*/
|
||||
export function assert(
|
||||
condition: boolean,
|
||||
message: string,
|
||||
options?: { field?: string; userMessage?: string }
|
||||
): asserts condition {
|
||||
if (!condition) {
|
||||
throw new ValidationError(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a value is not null/undefined
|
||||
*/
|
||||
export function assertDefined<T>(
|
||||
value: T | null | undefined,
|
||||
message: string
|
||||
): asserts value is T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new ValidationError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to execute a function, returning Result type
|
||||
*/
|
||||
export async function tryAsync<T>(
|
||||
fn: () => Promise<T>
|
||||
): Promise<{ ok: true; value: T } | { ok: false; error: Error }> {
|
||||
try {
|
||||
const value = await fn();
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
initialDelay = 1000,
|
||||
maxDelay = 30000,
|
||||
backoffFactor = 2,
|
||||
} = options;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
let delay = initialDelay;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
break;
|
||||
}
|
||||
|
||||
logger.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay = Math.min(delay * backoffFactor, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
163
src/utils/logger.ts
Normal file
163
src/utils/logger.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Logger Utility for Elly Discord Bot
|
||||
* Provides structured logging with levels and formatting
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
context?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
const LOG_COLORS = {
|
||||
debug: '\x1b[36m', // Cyan
|
||||
info: '\x1b[32m', // Green
|
||||
warn: '\x1b[33m', // Yellow
|
||||
error: '\x1b[31m', // Red
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Logger class with configurable log level and file output
|
||||
*/
|
||||
export class Logger {
|
||||
private level: LogLevel;
|
||||
private logFile?: string;
|
||||
private context?: string;
|
||||
|
||||
constructor(options: { level?: LogLevel; logFile?: string; context?: string } = {}) {
|
||||
this.level = options.level ?? 'info';
|
||||
this.logFile = options.logFile;
|
||||
this.context = options.context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with a specific context
|
||||
*/
|
||||
child(context: string): Logger {
|
||||
return new Logger({
|
||||
level: this.level,
|
||||
logFile: this.logFile,
|
||||
context: this.context ? `${this.context}:${context}` : context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log level should be output
|
||||
*/
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log entry for console output
|
||||
*/
|
||||
private formatConsole(entry: LogEntry): string {
|
||||
const color = LOG_COLORS[entry.level];
|
||||
const reset = LOG_COLORS.reset;
|
||||
const levelStr = entry.level.toUpperCase().padEnd(5);
|
||||
const contextStr = entry.context ? `[${entry.context}] ` : '';
|
||||
|
||||
let output = `${color}${entry.timestamp} ${levelStr}${reset} ${contextStr}${entry.message}`;
|
||||
|
||||
if (entry.data !== undefined) {
|
||||
output += `\n${JSON.stringify(entry.data, null, 2)}`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a log entry for file output
|
||||
*/
|
||||
private formatFile(entry: LogEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a log entry
|
||||
*/
|
||||
private async write(level: LogLevel, message: string, data?: unknown): Promise<void> {
|
||||
if (!this.shouldLog(level)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
context: this.context,
|
||||
data,
|
||||
};
|
||||
|
||||
// Console output
|
||||
console.log(this.formatConsole(entry));
|
||||
|
||||
// File output
|
||||
if (this.logFile) {
|
||||
try {
|
||||
const line = this.formatFile(entry) + '\n';
|
||||
await Deno.writeTextFile(this.logFile, line, { append: true });
|
||||
} catch {
|
||||
// Silently fail file logging
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message
|
||||
*/
|
||||
debug(message: string, data?: unknown): void {
|
||||
this.write('debug', message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message
|
||||
*/
|
||||
info(message: string, data?: unknown): void {
|
||||
this.write('info', message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message
|
||||
*/
|
||||
warn(message: string, data?: unknown): void {
|
||||
this.write('warn', message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*/
|
||||
error(message: string, data?: unknown): void {
|
||||
this.write('error', message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the log level
|
||||
*/
|
||||
setLevel(level: LogLevel): void {
|
||||
this.level = level;
|
||||
}
|
||||
}
|
||||
|
||||
// Default logger instance
|
||||
export const logger = new Logger();
|
||||
|
||||
/**
|
||||
* Create a logger with a specific context
|
||||
*/
|
||||
export function createLogger(context: string, options?: { level?: LogLevel; logFile?: string }): Logger {
|
||||
return new Logger({
|
||||
...options,
|
||||
context,
|
||||
});
|
||||
}
|
||||
290
src/utils/pagination.ts
Normal file
290
src/utils/pagination.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Pagination Utility for Elly Discord Bot
|
||||
* Provides interactive pagination for embeds and content
|
||||
*/
|
||||
|
||||
import {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
type ChatInputCommandInteraction,
|
||||
type Message,
|
||||
type ButtonInteraction,
|
||||
ComponentType,
|
||||
} from 'discord.js';
|
||||
|
||||
export interface PaginatorOptions {
|
||||
itemsPerPage?: number;
|
||||
timeout?: number;
|
||||
authorId?: string;
|
||||
showPageNumbers?: boolean;
|
||||
deleteOnTimeout?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button-based paginator for embeds
|
||||
*/
|
||||
export class ButtonPaginator<T = EmbedBuilder> {
|
||||
private pages: T[];
|
||||
private currentPage = 0;
|
||||
private message: Message | null = null;
|
||||
private readonly options: Required<PaginatorOptions>;
|
||||
private collector: ReturnType<Message['createMessageComponentCollector']> | null = null;
|
||||
|
||||
constructor(pages: T[], options: PaginatorOptions = {}) {
|
||||
this.pages = pages;
|
||||
this.options = {
|
||||
itemsPerPage: options.itemsPerPage ?? 1,
|
||||
timeout: options.timeout ?? 60000,
|
||||
authorId: options.authorId ?? '',
|
||||
showPageNumbers: options.showPageNumbers ?? true,
|
||||
deleteOnTimeout: options.deleteOnTimeout ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of pages
|
||||
*/
|
||||
get totalPages(): number {
|
||||
return this.pages.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page content
|
||||
*/
|
||||
get currentContent(): T {
|
||||
return this.pages[this.currentPage];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pagination buttons (max 5 per row - Discord limit)
|
||||
*/
|
||||
private createButtons(): ActionRowBuilder<ButtonBuilder> {
|
||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('paginator:prev')
|
||||
.setEmoji('◀️')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setDisabled(this.currentPage === 0),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('paginator:page')
|
||||
.setLabel(`${this.currentPage + 1}/${this.totalPages}`)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setDisabled(true),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('paginator:next')
|
||||
.setEmoji('▶️')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setDisabled(this.currentPage >= this.totalPages - 1),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('paginator:stop')
|
||||
.setEmoji('⏹️')
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message payload for current page
|
||||
*/
|
||||
private getPayload(): { embeds?: EmbedBuilder[]; components: ActionRowBuilder<ButtonBuilder>[] } {
|
||||
const content = this.currentContent;
|
||||
const components = this.totalPages > 1 ? [this.createButtons()] : [];
|
||||
|
||||
if (content instanceof EmbedBuilder) {
|
||||
const embed = EmbedBuilder.from(content);
|
||||
if (this.options.showPageNumbers && this.totalPages > 1) {
|
||||
const existingFooter = embed.data.footer?.text ?? '';
|
||||
embed.setFooter({
|
||||
text: existingFooter
|
||||
? `${existingFooter} • Page ${this.currentPage + 1}/${this.totalPages}`
|
||||
: `Page ${this.currentPage + 1}/${this.totalPages}`,
|
||||
iconURL: embed.data.footer?.icon_url,
|
||||
});
|
||||
}
|
||||
return { embeds: [embed], components };
|
||||
}
|
||||
|
||||
return { components };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle button interaction
|
||||
*/
|
||||
private async handleInteraction(interaction: ButtonInteraction): Promise<void> {
|
||||
// Check if the user is authorized
|
||||
if (this.options.authorId && interaction.user.id !== this.options.authorId) {
|
||||
await interaction.reply({
|
||||
content: 'You cannot interact with this menu.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const action = interaction.customId.split(':')[1];
|
||||
|
||||
switch (action) {
|
||||
case 'prev':
|
||||
this.currentPage = Math.max(0, this.currentPage - 1);
|
||||
break;
|
||||
case 'next':
|
||||
this.currentPage = Math.min(this.totalPages - 1, this.currentPage + 1);
|
||||
break;
|
||||
case 'stop':
|
||||
await this.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.update(this.getPayload());
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the paginator
|
||||
*/
|
||||
async start(interaction: ChatInputCommandInteraction): Promise<Message | null> {
|
||||
if (this.pages.length === 0) {
|
||||
await interaction.reply({
|
||||
content: 'No content to display.',
|
||||
ephemeral: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = this.getPayload();
|
||||
|
||||
if (interaction.replied || interaction.deferred) {
|
||||
this.message = await interaction.followUp({
|
||||
...payload,
|
||||
fetchReply: true,
|
||||
});
|
||||
} else {
|
||||
this.message = await interaction.reply({
|
||||
...payload,
|
||||
fetchReply: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.totalPages <= 1) {
|
||||
return this.message;
|
||||
}
|
||||
|
||||
// Set up collector
|
||||
this.collector = this.message.createMessageComponentCollector({
|
||||
componentType: ComponentType.Button,
|
||||
time: this.options.timeout,
|
||||
filter: (i) => i.customId.startsWith('paginator:'),
|
||||
});
|
||||
|
||||
this.collector.on('collect', (i) => this.handleInteraction(i));
|
||||
this.collector.on('end', () => this.onTimeout());
|
||||
|
||||
return this.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle timeout
|
||||
*/
|
||||
private async onTimeout(): Promise<void> {
|
||||
if (!this.message) return;
|
||||
|
||||
try {
|
||||
if (this.options.deleteOnTimeout) {
|
||||
await this.message.delete();
|
||||
} else {
|
||||
// Disable all buttons
|
||||
const disabledRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
...this.createButtons().components.map((btn) => btn.setDisabled(true))
|
||||
);
|
||||
await this.message.edit({ components: [disabledRow] });
|
||||
}
|
||||
} catch {
|
||||
// Message might have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the paginator
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.collector) {
|
||||
this.collector.stop();
|
||||
}
|
||||
|
||||
if (this.message) {
|
||||
try {
|
||||
const disabledRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
...this.createButtons().components.map((btn) => btn.setDisabled(true))
|
||||
);
|
||||
await this.message.edit({ components: [disabledRow] });
|
||||
} catch {
|
||||
// Message might have been deleted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to a specific page
|
||||
*/
|
||||
goToPage(page: number): void {
|
||||
this.currentPage = Math.max(0, Math.min(page, this.totalPages - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple paginator from an array of embeds
|
||||
*/
|
||||
export function createPaginator(
|
||||
embeds: EmbedBuilder[],
|
||||
options?: PaginatorOptions
|
||||
): ButtonPaginator<EmbedBuilder> {
|
||||
return new ButtonPaginator(embeds, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk an array into pages
|
||||
*/
|
||||
export function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create paginated embeds from an array of items
|
||||
*/
|
||||
export function createPaginatedEmbeds<T>(
|
||||
items: T[],
|
||||
itemsPerPage: number,
|
||||
formatItem: (item: T, index: number) => string,
|
||||
embedOptions: {
|
||||
title?: string;
|
||||
color?: number;
|
||||
description?: string;
|
||||
} = {}
|
||||
): EmbedBuilder[] {
|
||||
const chunks = chunkArray(items, itemsPerPage);
|
||||
|
||||
return chunks.map((chunk, pageIndex) => {
|
||||
const embed = new EmbedBuilder();
|
||||
|
||||
if (embedOptions.title) {
|
||||
embed.setTitle(embedOptions.title);
|
||||
}
|
||||
|
||||
if (embedOptions.color) {
|
||||
embed.setColor(embedOptions.color);
|
||||
}
|
||||
|
||||
const content = chunk
|
||||
.map((item, index) => formatItem(item, pageIndex * itemsPerPage + index))
|
||||
.join('\n');
|
||||
|
||||
embed.setDescription(
|
||||
embedOptions.description ? `${embedOptions.description}\n\n${content}` : content
|
||||
);
|
||||
|
||||
return embed;
|
||||
});
|
||||
}
|
||||
254
src/utils/time.ts
Normal file
254
src/utils/time.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Time Utilities for Elly Discord Bot
|
||||
* Provides time parsing and formatting functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Time unit multipliers in milliseconds
|
||||
*/
|
||||
const TIME_UNITS: Record<string, number> = {
|
||||
s: 1000,
|
||||
sec: 1000,
|
||||
second: 1000,
|
||||
seconds: 1000,
|
||||
m: 60 * 1000,
|
||||
min: 60 * 1000,
|
||||
minute: 60 * 1000,
|
||||
minutes: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
hr: 60 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
hours: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
days: 24 * 60 * 60 * 1000,
|
||||
w: 7 * 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
weeks: 7 * 24 * 60 * 60 * 1000,
|
||||
mo: 30 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
months: 30 * 24 * 60 * 60 * 1000,
|
||||
y: 365 * 24 * 60 * 60 * 1000,
|
||||
year: 365 * 24 * 60 * 60 * 1000,
|
||||
years: 365 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a time string into milliseconds
|
||||
* Examples: "15m", "2h30m", "1d", "1 day 2 hours"
|
||||
*/
|
||||
export function parseTime(input: string): number | null {
|
||||
if (!input) return null;
|
||||
|
||||
// Try to parse as a simple number (assume minutes)
|
||||
const simpleNumber = parseInt(input, 10);
|
||||
if (!isNaN(simpleNumber) && input === String(simpleNumber)) {
|
||||
return simpleNumber * 60 * 1000;
|
||||
}
|
||||
|
||||
// Parse time string with units
|
||||
const regex = /(\d+)\s*([a-zA-Z]+)/g;
|
||||
let totalMs = 0;
|
||||
let match;
|
||||
let hasMatch = false;
|
||||
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2].toLowerCase();
|
||||
|
||||
if (TIME_UNITS[unit]) {
|
||||
totalMs += value * TIME_UNITS[unit];
|
||||
hasMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasMatch ? totalMs : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds into a human-readable string
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 0) return 'Invalid duration';
|
||||
if (ms === 0) return '0 seconds';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
const years = Math.floor(ms / (365 * 24 * 60 * 60 * 1000));
|
||||
ms %= 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const months = Math.floor(ms / (30 * 24 * 60 * 60 * 1000));
|
||||
ms %= 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const weeks = Math.floor(ms / (7 * 24 * 60 * 60 * 1000));
|
||||
ms %= 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
||||
ms %= 24 * 60 * 60 * 1000;
|
||||
|
||||
const hours = Math.floor(ms / (60 * 60 * 1000));
|
||||
ms %= 60 * 60 * 1000;
|
||||
|
||||
const minutes = Math.floor(ms / (60 * 1000));
|
||||
ms %= 60 * 1000;
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
|
||||
if (years > 0) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
|
||||
if (months > 0) parts.push(`${months} month${months !== 1 ? 's' : ''}`);
|
||||
if (weeks > 0) parts.push(`${weeks} week${weeks !== 1 ? 's' : ''}`);
|
||||
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`);
|
||||
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
|
||||
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`);
|
||||
if (seconds > 0) parts.push(`${seconds} second${seconds !== 1 ? 's' : ''}`);
|
||||
|
||||
if (parts.length === 0) return '0 seconds';
|
||||
if (parts.length === 1) return parts[0];
|
||||
if (parts.length === 2) return parts.join(' and ');
|
||||
|
||||
return parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds into a short string
|
||||
* Example: "2d 5h 30m"
|
||||
*/
|
||||
export function formatDurationShort(ms: number): string {
|
||||
if (ms < 0) return 'Invalid';
|
||||
if (ms === 0) return '0s';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
||||
ms %= 24 * 60 * 60 * 1000;
|
||||
|
||||
const hours = Math.floor(ms / (60 * 60 * 1000));
|
||||
ms %= 60 * 60 * 1000;
|
||||
|
||||
const minutes = Math.floor(ms / (60 * 1000));
|
||||
ms %= 60 * 1000;
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0 && parts.length === 0) parts.push(`${seconds}s`);
|
||||
|
||||
return parts.join(' ') || '0s';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a relative time string (e.g., "2 hours ago", "in 3 days")
|
||||
*/
|
||||
export function relativeTime(date: Date | number): string {
|
||||
const now = Date.now();
|
||||
const timestamp = date instanceof Date ? date.getTime() : date;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
const formatUnit = (value: number, unit: string): string => {
|
||||
const rounded = Math.round(value);
|
||||
const unitStr = rounded === 1 ? unit : unit + 's';
|
||||
return diff < 0 ? `${rounded} ${unitStr} ago` : `in ${rounded} ${unitStr}`;
|
||||
};
|
||||
|
||||
if (absDiff < 60 * 1000) {
|
||||
return diff < 0 ? 'just now' : 'in a moment';
|
||||
}
|
||||
|
||||
if (absDiff < 60 * 60 * 1000) {
|
||||
return formatUnit(absDiff / (60 * 1000), 'minute');
|
||||
}
|
||||
|
||||
if (absDiff < 24 * 60 * 60 * 1000) {
|
||||
return formatUnit(absDiff / (60 * 60 * 1000), 'hour');
|
||||
}
|
||||
|
||||
if (absDiff < 7 * 24 * 60 * 60 * 1000) {
|
||||
return formatUnit(absDiff / (24 * 60 * 60 * 1000), 'day');
|
||||
}
|
||||
|
||||
if (absDiff < 30 * 24 * 60 * 60 * 1000) {
|
||||
return formatUnit(absDiff / (7 * 24 * 60 * 60 * 1000), 'week');
|
||||
}
|
||||
|
||||
if (absDiff < 365 * 24 * 60 * 60 * 1000) {
|
||||
return formatUnit(absDiff / (30 * 24 * 60 * 60 * 1000), 'month');
|
||||
}
|
||||
|
||||
return formatUnit(absDiff / (365 * 24 * 60 * 60 * 1000), 'year');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to ISO string (YYYY-MM-DD)
|
||||
*/
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to datetime string (YYYY-MM-DD HH:MM:SS)
|
||||
*/
|
||||
export function formatDateTime(date: Date): string {
|
||||
return date.toISOString().replace('T', ' ').split('.')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Discord timestamp format
|
||||
* @param date The date to format
|
||||
* @param style The timestamp style (t, T, d, D, f, F, R)
|
||||
*/
|
||||
export function discordTimestamp(
|
||||
date: Date | number,
|
||||
style: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R' = 'f'
|
||||
): string {
|
||||
const timestamp = Math.floor((date instanceof Date ? date.getTime() : date) / 1000);
|
||||
return `<t:${timestamp}:${style}>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is in the past
|
||||
*/
|
||||
export function isPast(date: Date | number): boolean {
|
||||
const timestamp = date instanceof Date ? date.getTime() : date;
|
||||
return timestamp < Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is in the future
|
||||
*/
|
||||
export function isFuture(date: Date | number): boolean {
|
||||
const timestamp = date instanceof Date ? date.getTime() : date;
|
||||
return timestamp > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add time to a date
|
||||
*/
|
||||
export function addTime(date: Date, ms: number): Date {
|
||||
return new Date(date.getTime() + ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of today (midnight)
|
||||
*/
|
||||
export function startOfDay(date: Date = new Date()): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end of today (23:59:59.999)
|
||||
*/
|
||||
export function endOfDay(date: Date = new Date()): Date {
|
||||
const result = new Date(date);
|
||||
result.setHours(23, 59, 59, 999);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for parseTime for backward compatibility
|
||||
*/
|
||||
export const parseDuration = parseTime;
|
||||
Reference in New Issue
Block a user