(Feat): Added a minimal pikanetwork client

This commit is contained in:
2025-12-01 13:08:01 +00:00
commit 101d093965
68 changed files with 18007 additions and 0 deletions

334
src/utils/embeds.ts Normal file
View 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
View 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
View 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
View 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
View 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;