From 101d093965db2c173614f2b081d9cda56f641692 Mon Sep 17 00:00:00 2001 From: bereck-work Date: Mon, 1 Dec 2025 13:08:01 +0000 Subject: [PATCH] (Feat): Added a minimal pikanetwork client --- src/api/pika/cache.ts | 280 +++++ src/api/pika/client.ts | 779 ++++++++++++++ src/api/pika/index.ts | 63 ++ src/api/pika/types.ts | 354 +++++++ src/client/EllyClient.ts | 361 +++++++ src/commands/applications/handlers/admin.ts | 276 +++++ src/commands/applications/handlers/apply.ts | 708 +++++++++++++ src/commands/applications/handlers/list.ts | 299 ++++++ src/commands/applications/handlers/review.ts | 278 +++++ .../applications/handlers/settings.ts | 89 ++ src/commands/applications/handlers/stats.ts | 219 ++++ src/commands/applications/handlers/view.ts | 159 +++ src/commands/applications/index.ts | 271 +++++ src/commands/developer/blacklist.ts | 292 ++++++ src/commands/developer/database.ts | 315 ++++++ src/commands/developer/debug.ts | 240 +++++ src/commands/developer/emit.ts | 101 ++ src/commands/developer/eval.ts | 134 +++ src/commands/developer/reload.ts | 119 +++ src/commands/developer/shell.ts | 167 +++ src/commands/developer/sync.ts | 174 +++ src/commands/family/adopt.ts | 192 ++++ src/commands/family/divorce.ts | 125 +++ src/commands/family/marry.ts | 196 ++++ src/commands/family/relationship.ts | 114 ++ src/commands/moderation/filter.ts | 360 +++++++ src/commands/moderation/purge.ts | 180 ++++ src/commands/qotd/index.ts | 611 +++++++++++ src/commands/statistics/bedwars.ts | 173 +++ src/commands/statistics/guild.ts | 128 +++ src/commands/statistics/server.ts | 96 ++ src/commands/statistics/skywars.ts | 176 ++++ src/commands/suggestions/index.ts | 988 ++++++++++++++++++ src/commands/utility/away.ts | 354 +++++++ src/commands/utility/champion.ts | 379 +++++++ src/commands/utility/remind.ts | 265 +++++ src/commands/utility/role.ts | 261 +++++ src/commands/utility/staff.ts | 402 +++++++ src/config/config.ts | 427 ++++++++ src/config/types.ts | 98 ++ src/database/BaseRepository.ts | 382 +++++++ src/database/DatabaseManager.ts | 266 +++++ src/database/connection.ts | 244 +++++ src/database/index.ts | 36 + .../repositories/ApplicationRepository.ts | 209 ++++ src/database/repositories/AwayRepository.ts | 82 ++ .../repositories/ChampionRepository.ts | 156 +++ src/database/repositories/FamilyRepository.ts | 220 ++++ .../repositories/FamilyRepositorySQLite.ts | 422 ++++++++ src/database/repositories/FilterRepository.ts | 273 +++++ src/database/repositories/QOTDRepository.ts | 205 ++++ .../repositories/ReminderRepository.ts | 102 ++ .../repositories/ReminderRepositorySQLite.ts | 204 ++++ src/database/repositories/StaffRepository.ts | 264 +++++ .../repositories/SuggestionRepository.ts | 221 ++++ src/database/schema.ts | 339 ++++++ src/database/sqlite.ts | 388 +++++++ src/events/interactionCreate.ts | 212 ++++ src/events/messageCreate.ts | 167 +++ src/events/ready.ts | 119 +++ src/index.ts | 268 +++++ src/services/PermissionService.ts | 225 ++++ src/types/index.ts | 267 +++++ src/utils/embeds.ts | 334 ++++++ src/utils/errors.ts | 492 +++++++++ src/utils/logger.ts | 163 +++ src/utils/pagination.ts | 290 +++++ src/utils/time.ts | 254 +++++ 68 files changed, 18007 insertions(+) create mode 100644 src/api/pika/cache.ts create mode 100644 src/api/pika/client.ts create mode 100644 src/api/pika/index.ts create mode 100644 src/api/pika/types.ts create mode 100644 src/client/EllyClient.ts create mode 100644 src/commands/applications/handlers/admin.ts create mode 100644 src/commands/applications/handlers/apply.ts create mode 100644 src/commands/applications/handlers/list.ts create mode 100644 src/commands/applications/handlers/review.ts create mode 100644 src/commands/applications/handlers/settings.ts create mode 100644 src/commands/applications/handlers/stats.ts create mode 100644 src/commands/applications/handlers/view.ts create mode 100644 src/commands/applications/index.ts create mode 100644 src/commands/developer/blacklist.ts create mode 100644 src/commands/developer/database.ts create mode 100644 src/commands/developer/debug.ts create mode 100644 src/commands/developer/emit.ts create mode 100644 src/commands/developer/eval.ts create mode 100644 src/commands/developer/reload.ts create mode 100644 src/commands/developer/shell.ts create mode 100644 src/commands/developer/sync.ts create mode 100644 src/commands/family/adopt.ts create mode 100644 src/commands/family/divorce.ts create mode 100644 src/commands/family/marry.ts create mode 100644 src/commands/family/relationship.ts create mode 100644 src/commands/moderation/filter.ts create mode 100644 src/commands/moderation/purge.ts create mode 100644 src/commands/qotd/index.ts create mode 100644 src/commands/statistics/bedwars.ts create mode 100644 src/commands/statistics/guild.ts create mode 100644 src/commands/statistics/server.ts create mode 100644 src/commands/statistics/skywars.ts create mode 100644 src/commands/suggestions/index.ts create mode 100644 src/commands/utility/away.ts create mode 100644 src/commands/utility/champion.ts create mode 100644 src/commands/utility/remind.ts create mode 100644 src/commands/utility/role.ts create mode 100644 src/commands/utility/staff.ts create mode 100644 src/config/config.ts create mode 100644 src/config/types.ts create mode 100644 src/database/BaseRepository.ts create mode 100644 src/database/DatabaseManager.ts create mode 100644 src/database/connection.ts create mode 100644 src/database/index.ts create mode 100644 src/database/repositories/ApplicationRepository.ts create mode 100644 src/database/repositories/AwayRepository.ts create mode 100644 src/database/repositories/ChampionRepository.ts create mode 100644 src/database/repositories/FamilyRepository.ts create mode 100644 src/database/repositories/FamilyRepositorySQLite.ts create mode 100644 src/database/repositories/FilterRepository.ts create mode 100644 src/database/repositories/QOTDRepository.ts create mode 100644 src/database/repositories/ReminderRepository.ts create mode 100644 src/database/repositories/ReminderRepositorySQLite.ts create mode 100644 src/database/repositories/StaffRepository.ts create mode 100644 src/database/repositories/SuggestionRepository.ts create mode 100644 src/database/schema.ts create mode 100644 src/database/sqlite.ts create mode 100644 src/events/interactionCreate.ts create mode 100644 src/events/messageCreate.ts create mode 100644 src/events/ready.ts create mode 100644 src/index.ts create mode 100644 src/services/PermissionService.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/embeds.ts create mode 100644 src/utils/errors.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/pagination.ts create mode 100644 src/utils/time.ts diff --git a/src/api/pika/cache.ts b/src/api/pika/cache.ts new file mode 100644 index 0000000..a445ac4 --- /dev/null +++ b/src/api/pika/cache.ts @@ -0,0 +1,280 @@ +/** + * PikaNetwork API Cache System + * Implements TTL-based caching for API responses + */ + +import type { ProfileResponse, ClanResponse, LeaderboardResponse } from './types.ts'; + +interface CacheEntry { + data: T; + expires: number; +} + +/** + * Generic cache with TTL support + */ +class TTLCache { + private cache = new Map>(); + private readonly defaultTTL: number; + + constructor(defaultTTL: number) { + this.defaultTTL = defaultTTL; + } + + /** + * Check if an entry has expired + */ + private isExpired(expires: number): boolean { + return Date.now() > expires; + } + + /** + * Get a value from the cache + */ + get(key: string): T | null { + const entry = this.cache.get(key.toLowerCase()); + if (!entry) return null; + + if (this.isExpired(entry.expires)) { + this.cache.delete(key.toLowerCase()); + return null; + } + + return entry.data; + } + + /** + * Set a value in the cache + */ + set(key: string, data: T, ttl?: number): void { + this.cache.set(key.toLowerCase(), { + data, + expires: Date.now() + (ttl ?? this.defaultTTL), + }); + } + + /** + * Check if a key exists and is not expired + */ + has(key: string): boolean { + const entry = this.cache.get(key.toLowerCase()); + if (!entry) return false; + + if (this.isExpired(entry.expires)) { + this.cache.delete(key.toLowerCase()); + return false; + } + + return true; + } + + /** + * Delete a specific key + */ + delete(key: string): boolean { + return this.cache.delete(key.toLowerCase()); + } + + /** + * Clear all entries + */ + clear(): void { + this.cache.clear(); + } + + /** + * Get the number of entries + */ + get size(): number { + return this.cache.size; + } + + /** + * Remove expired entries + */ + cleanup(): number { + const now = Date.now(); + let removed = 0; + + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expires) { + this.cache.delete(key); + removed++; + } + } + + return removed; + } +} + +/** + * PikaNetwork API Cache + * Manages caching for profiles, clans, and leaderboards + */ +export class PikaCache { + private profiles: TTLCache; + private clans: TTLCache; + private leaderboards: TTLCache; + private cleanupInterval: number | undefined; + + constructor(ttl: number = 3600000) { + this.profiles = new TTLCache(ttl); + this.clans = new TTLCache(ttl); + this.leaderboards = new TTLCache(ttl); + + // Run cleanup every 5 minutes + this.cleanupInterval = setInterval(() => this.cleanup(), 300000); + } + + // ========================================================================= + // Profile Cache + // ========================================================================= + + /** + * Get a cached profile + */ + getProfile(username: string): ProfileResponse | null { + return this.profiles.get(username); + } + + /** + * Cache a profile + */ + setProfile(username: string, data: ProfileResponse, ttl?: number): void { + this.profiles.set(username, data, ttl); + } + + /** + * Check if a profile is cached + */ + hasProfile(username: string): boolean { + return this.profiles.has(username); + } + + // ========================================================================= + // Clan Cache + // ========================================================================= + + /** + * Get a cached clan + */ + getClan(name: string): ClanResponse | null { + return this.clans.get(name); + } + + /** + * Cache a clan + */ + setClan(name: string, data: ClanResponse, ttl?: number): void { + this.clans.set(name, data, ttl); + } + + /** + * Check if a clan is cached + */ + hasClan(name: string): boolean { + return this.clans.has(name); + } + + // ========================================================================= + // Leaderboard Cache + // ========================================================================= + + /** + * Generate a cache key for leaderboard data + */ + private getLeaderboardKey( + username: string, + gamemode: string, + mode: string, + interval: string + ): string { + return `${username}:${gamemode}:${mode}:${interval}`; + } + + /** + * Get cached leaderboard data + */ + getLeaderboard( + username: string, + gamemode: string, + mode: string, + interval: string + ): LeaderboardResponse | null { + const key = this.getLeaderboardKey(username, gamemode, mode, interval); + return this.leaderboards.get(key); + } + + /** + * Cache leaderboard data + */ + setLeaderboard( + username: string, + gamemode: string, + mode: string, + interval: string, + data: LeaderboardResponse, + ttl?: number + ): void { + const key = this.getLeaderboardKey(username, gamemode, mode, interval); + this.leaderboards.set(key, data, ttl); + } + + /** + * Check if leaderboard data is cached + */ + hasLeaderboard( + username: string, + gamemode: string, + mode: string, + interval: string + ): boolean { + const key = this.getLeaderboardKey(username, gamemode, mode, interval); + return this.leaderboards.has(key); + } + + // ========================================================================= + // General Methods + // ========================================================================= + + /** + * Clear all caches + */ + clear(): void { + this.profiles.clear(); + this.clans.clear(); + this.leaderboards.clear(); + } + + /** + * Run cleanup on all caches + */ + cleanup(): { profiles: number; clans: number; leaderboards: number } { + return { + profiles: this.profiles.cleanup(), + clans: this.clans.cleanup(), + leaderboards: this.leaderboards.cleanup(), + }; + } + + /** + * Get cache statistics + */ + getStats(): { profiles: number; clans: number; leaderboards: number } { + return { + profiles: this.profiles.size, + clans: this.clans.size, + leaderboards: this.leaderboards.size, + }; + } + + /** + * Destroy the cache and stop cleanup interval + */ + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + this.clear(); + } +} diff --git a/src/api/pika/client.ts b/src/api/pika/client.ts new file mode 100644 index 0000000..4da4a52 --- /dev/null +++ b/src/api/pika/client.ts @@ -0,0 +1,779 @@ +/** + * PikaNetwork API Client + * Modern TypeScript implementation without proxy support + * Based on pikanetwork.js but rewritten with improvements + */ + +import { PikaCache } from './cache.ts'; +import type { + ProfileResponse, + ClanResponse, + LeaderboardResponse, + GameMode, + Interval, + PikaAPIOptions, + BedWarsStats, + SkyWarsStats, + MinimalLeaderboardData, + Punishment, + PunishmentType, + StaffList, + VoteLeaderboard, + VoteEntry, + ServerStatus, + TotalLeaderboardEntry, + TotalLeaderboardOptions, + JoinInfo, + MiscInfo, +} from './types.ts'; + +/** + * PikaNetwork API Client + * Provides methods to fetch player profiles, clan information, leaderboard data, + * punishments, staff lists, vote leaderboards, and server status + */ +export class PikaNetworkAPI { + private readonly baseUrl = 'https://stats.pika-network.net/api'; + private readonly forumUrl = 'https://pika-network.net'; + private readonly cache: PikaCache; + private readonly timeout: number; + private readonly userAgent: string; + + // Staff roles for scraping + private readonly staffRoles = new Set([ + 'owner', 'manager', 'lead developer', 'developer', + 'admin', 'sr mod', 'moderator', 'helper', 'trial' + ]); + + // Punishment type mapping + private readonly punishmentMap: Record = { + warn: 'warnings', + mute: 'mutes', + kick: 'kicks', + ban: 'bans', + }; + + constructor(options: PikaAPIOptions = {}) { + this.cache = new PikaCache(options.cacheTTL ?? 3600000); // 1 hour default + this.timeout = options.timeout ?? 10000; // 10 seconds default + this.userAgent = options.userAgent ?? 'Elly Discord Bot/1.0'; + } + + // ========================================================================= + // Private Helper Methods + // ========================================================================= + + /** + * Make an HTTP request with timeout and error handling + */ + private async request(endpoint: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(`${this.baseUrl}${endpoint}`, { + signal: controller.signal, + headers: { + 'User-Agent': this.userAgent, + 'Accept': 'application/json', + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error(`[PikaAPI] Request failed: ${response.status} ${response.statusText} for ${endpoint}`); + return null; + } + + // Get response text first to handle empty responses + const text = await response.text(); + + // Handle empty responses + if (!text || text.trim() === '') { + console.warn(`[PikaAPI] Empty response for ${endpoint}`); + return null; + } + + // Try to parse JSON + try { + return JSON.parse(text) as T; + } catch { + console.error(`[PikaAPI] Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`); + return null; + } + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + console.error(`[PikaAPI] Request timeout for ${endpoint}`); + } else { + console.error(`[PikaAPI] Request error for ${endpoint}: ${error.message}`); + } + } + return null; + } + } + + /** + * Delay execution for rate limiting + */ + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // ========================================================================= + // Profile Methods + // ========================================================================= + + /** + * Get a player's profile + */ + async getProfile(username: string): Promise { + // Check cache first + const cached = this.cache.getProfile(username); + if (cached) return cached; + + // Fetch from API + const data = await this.request(`/profile/${encodeURIComponent(username)}`); + + if (data && typeof data === 'object' && 'username' in data) { + this.cache.setProfile(username, data); + return data; + } + + return null; + } + + /** + * Check if a player exists + */ + async playerExists(username: string): Promise { + const profile = await this.getProfile(username); + return profile !== null; + } + + // ========================================================================= + // Clan Methods + // ========================================================================= + + /** + * Get clan information + */ + async getClan(name: string): Promise { + // Check cache first + const cached = this.cache.getClan(name); + if (cached) return cached; + + // Fetch from API + const data = await this.request(`/clans/${encodeURIComponent(name)}`); + + if (data && typeof data === 'object' && 'name' in data) { + this.cache.setClan(name, data); + return data; + } + + return null; + } + + /** + * Get all members of a clan + */ + async getClanMembers(name: string): Promise { + const clan = await this.getClan(name); + if (!clan) return []; + + return clan.members.map((member) => member.user.username); + } + + // ========================================================================= + // Leaderboard Methods + // ========================================================================= + + /** + * Get leaderboard data for a player + */ + async getLeaderboard( + username: string, + gamemode: GameMode, + interval: Interval = 'lifetime', + mode: string = 'all_modes' + ): Promise { + // Check cache first + const cached = this.cache.getLeaderboard(username, gamemode, mode, interval); + if (cached) return cached; + + // Build URL with query parameters + const params = new URLSearchParams({ + type: gamemode, + interval: interval, + mode: mode, + }); + + const data = await this.request( + `/profile/${encodeURIComponent(username)}/leaderboard?${params.toString()}` + ); + + if (data) { + this.cache.setLeaderboard(username, gamemode, mode, interval, data); + return data; + } + + return null; + } + + /** + * Get parsed BedWars stats for a player + */ + async getBedWarsStats( + username: string, + interval: Interval = 'lifetime', + mode: string = 'all_modes' + ): Promise { + const data = await this.getLeaderboard(username, 'bedwars', interval, mode); + if (!data) return null; + + return this.parseBedWarsStats(data); + } + + /** + * Get parsed SkyWars stats for a player + */ + async getSkyWarsStats( + username: string, + interval: Interval = 'lifetime', + mode: string = 'all_modes' + ): Promise { + const data = await this.getLeaderboard(username, 'skywars', interval, mode); + if (!data) return null; + + return this.parseSkyWarsStats(data); + } + + // ========================================================================= + // Batch Methods + // ========================================================================= + + /** + * Get minimal leaderboard data for multiple players + * Useful for guild activity reports + */ + async getMinimalBatchLeaderboard( + usernames: string[], + interval: Interval = 'lifetime' + ): Promise { + const results: MinimalLeaderboardData[] = []; + const batchSize = 5; + const delayMs = 200; + + for (let i = 0; i < usernames.length; i += batchSize) { + const batch = usernames.slice(i, i + batchSize); + + const promises = batch.map(async (username) => { + const [bedwars, skywars] = await Promise.all([ + this.getLeaderboard(username, 'bedwars', interval), + this.getLeaderboard(username, 'skywars', interval), + ]); + + const bwWins = this.getStatValue(bedwars, 'Wins'); + const swWins = this.getStatValue(skywars, 'Wins'); + + return { + username, + bedwars_wins: bwWins, + skywars_wins: swWins, + total_wins: bwWins >= 0 && swWins >= 0 ? bwWins + swWins : -1, + }; + }); + + const batchResults = await Promise.all(promises); + results.push(...batchResults); + + // Rate limiting delay between batches + if (i + batchSize < usernames.length) { + await this.delay(delayMs); + } + } + + return results; + } + + /** + * Get full leaderboard data for multiple players + */ + async getBatchLeaderboard( + usernames: string[], + gamemode: GameMode, + interval: Interval = 'lifetime' + ): Promise> { + const results = new Map(); + const batchSize = 5; + const delayMs = 200; + + for (let i = 0; i < usernames.length; i += batchSize) { + const batch = usernames.slice(i, i + batchSize); + + const promises = batch.map(async (username) => { + const data = await this.getLeaderboard(username, gamemode, interval); + return { username, data }; + }); + + const batchResults = await Promise.all(promises); + batchResults.forEach(({ username, data }) => results.set(username, data)); + + // Rate limiting delay between batches + if (i + batchSize < usernames.length) { + await this.delay(delayMs); + } + } + + return results; + } + + // ========================================================================= + // Stat Parsing Methods + // ========================================================================= + + /** + * Get a stat value from leaderboard data + */ + private getStatValue(data: LeaderboardResponse | null, key: string): number { + if (!data || !data[key] || !data[key].entries || data[key].entries.length === 0) { + return 0; + } + return data[key].entries[0].value; + } + + /** + * Get a stat position from leaderboard data + */ + private getStatPosition(data: LeaderboardResponse | null, key: string): number { + if (!data || !data[key] || !data[key].entries || data[key].entries.length === 0) { + return 0; + } + return data[key].entries[0].place; + } + + /** + * Calculate ratio between two numbers + */ + private calculateRatio(numerator: number, denominator: number): number { + if (denominator === 0) return numerator; + if (numerator === 0) return 0; + return Math.round((numerator / denominator) * 100) / 100; + } + + /** + * Parse raw leaderboard data into BedWars stats + */ + private parseBedWarsStats(data: LeaderboardResponse): BedWarsStats { + const kills = this.getStatValue(data, 'Kills'); + const deaths = this.getStatValue(data, 'Deaths'); + const finalKills = this.getStatValue(data, 'Final kills'); + const finalDeaths = this.getStatValue(data, 'Final deaths'); + const wins = this.getStatValue(data, 'Wins'); + const losses = this.getStatValue(data, 'Losses'); + + return { + kills, + deaths, + finalKills, + finalDeaths, + wins, + losses, + bedsDestroyed: this.getStatValue(data, 'Beds destroyed'), + gamesPlayed: this.getStatValue(data, 'Games played'), + highestWinstreak: this.getStatValue(data, 'Highest winstreak reached'), + bowKills: this.getStatValue(data, 'Bow kills'), + arrowsShot: this.getStatValue(data, 'Arrows shot'), + arrowsHit: this.getStatValue(data, 'Arrows hit'), + meleeKills: this.getStatValue(data, 'Melee kills'), + voidKills: this.getStatValue(data, 'Void kills'), + kdr: this.calculateRatio(kills, deaths), + fkdr: this.calculateRatio(finalKills, finalDeaths), + wlr: this.calculateRatio(wins, losses), + positions: { + kills: this.getStatPosition(data, 'Kills'), + deaths: this.getStatPosition(data, 'Deaths'), + finalKills: this.getStatPosition(data, 'Final kills'), + finalDeaths: this.getStatPosition(data, 'Final deaths'), + wins: this.getStatPosition(data, 'Wins'), + losses: this.getStatPosition(data, 'Losses'), + bedsDestroyed: this.getStatPosition(data, 'Beds destroyed'), + gamesPlayed: this.getStatPosition(data, 'Games played'), + highestWinstreak: this.getStatPosition(data, 'Highest winstreak reached'), + }, + }; + } + + /** + * Parse raw leaderboard data into SkyWars stats + */ + private parseSkyWarsStats(data: LeaderboardResponse): SkyWarsStats { + const kills = this.getStatValue(data, 'Kills'); + const deaths = this.getStatValue(data, 'Deaths'); + const wins = this.getStatValue(data, 'Wins'); + const losses = this.getStatValue(data, 'Losses'); + + return { + kills, + deaths, + wins, + losses, + gamesPlayed: this.getStatValue(data, 'Games played'), + highestWinstreak: this.getStatValue(data, 'Highest winstreak reached'), + bowKills: this.getStatValue(data, 'Bow kills'), + arrowsShot: this.getStatValue(data, 'Arrows shot'), + arrowsHit: this.getStatValue(data, 'Arrows hit'), + meleeKills: this.getStatValue(data, 'Melee kills'), + voidKills: this.getStatValue(data, 'Void kills'), + kdr: this.calculateRatio(kills, deaths), + wlr: this.calculateRatio(wins, losses), + positions: { + kills: this.getStatPosition(data, 'Kills'), + deaths: this.getStatPosition(data, 'Deaths'), + wins: this.getStatPosition(data, 'Wins'), + losses: this.getStatPosition(data, 'Losses'), + gamesPlayed: this.getStatPosition(data, 'Games played'), + highestWinstreak: this.getStatPosition(data, 'Highest winstreak reached'), + }, + }; + } + + // ========================================================================= + // Total Leaderboard Methods + // ========================================================================= + + /** + * Get total leaderboard data (top players for a stat) + */ + async getTotalLeaderboard(options: TotalLeaderboardOptions): Promise { + const params = new URLSearchParams({ + type: options.gamemode, + interval: options.interval, + stat: options.stat, + mode: options.mode, + offset: String(options.offset ?? 0), + limit: String(options.limit ?? 15), + }); + + const data = await this.request( + `/leaderboards?${params.toString()}` + ); + + return data; + } + + // ========================================================================= + // Profile Extended Methods + // ========================================================================= + + /** + * Get friend list for a player + */ + async getFriendList(username: string): Promise { + const profile = await this.getProfile(username); + if (!profile) return []; + return profile.friends.map((f) => f.username); + } + + /** + * Get guild info for a player + */ + async getPlayerGuild(username: string): Promise { + const profile = await this.getProfile(username); + if (!profile?.clan) return null; + return profile.clan as ClanResponse; + } + + /** + * Get rank info for a player + */ + async getRankInfo(username: string): Promise<{ level: number; percentage: number; display: string } | null> { + const profile = await this.getProfile(username); + if (!profile) return null; + return { + level: profile.rank.level, + percentage: profile.rank.percentage, + display: profile.rank.rankDisplay, + }; + } + + /** + * Get miscellaneous info for a player + */ + async getMiscInfo(username: string): Promise { + const profile = await this.getProfile(username); + if (!profile) return null; + return { + discordBoosting: profile.discord_boosting, + discordVerified: profile.discord_verified, + emailVerified: profile.email_verified, + username: profile.username, + }; + } + + /** + * Get join info for a player + */ + async getJoinInfo(username: string): Promise { + const profile = await this.getProfile(username); + if (!profile) return null; + + const lastJoinDate = new Date(profile.lastSeen); + const formatOptions: Intl.DateTimeFormatOptions = { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }; + + return { + lastJoin: profile.lastSeen, + lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions), + estimatedFirstJoin: null, // Would require punishment scraping + estimatedFirstJoinFormatted: 'N/A', + }; + } + + // ========================================================================= + // Server Status Methods + // ========================================================================= + + /** + * Get PikaNetwork server status + */ + async getServerStatus(serverIP: string = 'play.pika-network.net'): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(`https://api.mcstatus.io/v2/status/java/${serverIP}`, { + signal: controller.signal, + headers: { + 'User-Agent': this.userAgent, + 'Accept': 'application/json', + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) return null; + + const text = await response.text(); + if (!text || text.trim() === '') return null; + + const data = JSON.parse(text); + + const motdLines = data.motd?.clean?.split('\n').map((p: string) => p.trim()) ?? []; + + const serverData: ServerStatus = { + host: data.host ?? serverIP, + ip: data.ip_address ?? serverIP, + port: data.port ?? 25565, + icon: `https://eu.mc-api.net/v3/server/favicon/${serverIP}`, + banner: `https://api.loohpjames.com/serverbanner.png?ip=${serverIP}`, + online: data.online ?? false, + software: data.version?.name_clean ?? 'Unknown', + protocol: data.version?.protocol ?? 0, + playersOnline: data.players?.online ?? 0, + playersMax: data.players?.max ?? 0, + motd: motdLines, + }; + + if (serverIP === 'play.pika-network.net') { + serverData.website = 'https://pika-network.net/'; + serverData.discord = 'https://discord.gg/pikanetwork'; + } + + return serverData; + } catch (error) { + console.error(`[PikaAPI] Server status error: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + // ========================================================================= + // HTML Scraping Methods (Forum Data) + // ========================================================================= + + /** + * Fetch HTML from a URL + */ + private async fetchHtml(url: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + 'User-Agent': this.userAgent, + 'Accept': 'text/html', + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) return null; + return await response.text(); + } catch (error) { + console.error(`[PikaAPI] HTML fetch error for ${url}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + /** + * Clean punishment reason (remove Minecraft formatting codes) + */ + private cleanReason(reason: string): string { + const minecraftRegex = /(?:^\s+|(&|ยง)([0-9A-Fa-f])\b|&e[0-9]?\s?|^\[VL[^\]]*\]|^\?\s*)/g; + const formattingCodesRegex = /(ยง[0-9a-fk-or])|(&[0-9a-fk-or])/gi; + const cleaned = reason.replace(minecraftRegex, '').replace(formattingCodesRegex, ''); + return cleaned || 'N/A'; + } + + /** + * Parse HTML to extract text (simple implementation without cheerio) + */ + private extractTextFromHtml(html: string, selector: string): string[] { + // Simple regex-based extraction for common patterns + const results: string[] = []; + + // Match class-based selectors + const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/g); + if (classMatch) { + const className = classMatch[0].substring(1); + const regex = new RegExp(`class="[^"]*${className}[^"]*"[^>]*>([^<]+)`, 'gi'); + let match; + while ((match = regex.exec(html)) !== null) { + results.push(match[1].trim()); + } + } + + return results; + } + + /** + * Get punishments for a player (basic implementation) + * Note: Full implementation would require cheerio for HTML parsing + */ + async getPunishments( + username: string, + filter?: PunishmentType, + includeConsole: boolean = true + ): Promise { + const html = await this.fetchHtml(`${this.forumUrl}/bans/search/${encodeURIComponent(username)}/`); + if (!html) return []; + + // Basic parsing - for full implementation, use a proper HTML parser + const punishments: Punishment[] = []; + + // Extract punishment rows using regex (simplified) + const rowRegex = /
]*>([\s\S]*?)<\/div>\s*<\/div>\s*<\/div>/gi; + let match; + + while ((match = rowRegex.exec(html)) !== null) { + const row = match[1]; + + // Extract type + const typeMatch = row.match(/class="td _type"[^>]*>.*?([^<]+)<\/b>/i); + const type = typeMatch ? typeMatch[1].trim().toLowerCase() : ''; + + // Extract staff + const staffMatch = row.match(/class="td _staff"[^>]*>([^<]+)/i); + const staff = staffMatch ? staffMatch[1].trim() : 'N/A'; + + // Extract reason + const reasonMatch = row.match(/class="td _reason"[^>]*>([^<]+)/i); + const reason = reasonMatch ? this.cleanReason(reasonMatch[1].trim()) : 'N/A'; + + // Extract date + const dateMatch = row.match(/class="td _date"[^>]*>([^<]+)/i); + const date = dateMatch ? dateMatch[1].trim() : ''; + + // Extract expires + const expiresMatch = row.match(/class="td _expires"[^>]*>([^<]+)/i); + const expires = expiresMatch ? expiresMatch[1].trim() : ''; + + if (type) { + const punishment: Punishment = { + type, + staff, + reason, + date, + expires, + }; + + // Filter by type if specified + if (filter && type !== filter) continue; + + // Filter console punishments if needed + if (!includeConsole && staff.toLowerCase().includes('console')) continue; + + punishments.push(punishment); + } + } + + return punishments; + } + + /** + * Get vote leaderboard (basic implementation) + */ + async getVoteLeaderboard(): Promise { + const html = await this.fetchHtml(`${this.forumUrl}/vote`); + if (!html) return null; + + const voters: VoteEntry[] = []; + const runnerUps: VoteEntry[] = []; + + // Extract voters using regex (simplified) + const voterRegex = /class="voter[^"]*"[^>]*>[\s\S]*?class="position"[^>]*>#?(\d+)[\s\S]*?class="username"[^>]*>([^<]+)[\s\S]*?(\d+)\s*votes/gi; + let match; + let position = 1; + + while ((match = voterRegex.exec(html)) !== null) { + const entry: VoteEntry = { + position: parseInt(match[1]) || position, + username: match[2].trim(), + votes: parseInt(match[3]) || 0, + }; + + if (html.indexOf(match[0]) < html.indexOf('runners-up')) { + voters.push(entry); + } else { + runnerUps.push(entry); + } + position++; + } + + return { voters, runnerUps }; + } + + // ========================================================================= + // Cache Management + // ========================================================================= + + /** + * Clear all cached data + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Get cache statistics + */ + getCacheStats(): { profiles: number; clans: number; leaderboards: number } { + return this.cache.getStats(); + } + + /** + * Destroy the client and cleanup resources + */ + destroy(): void { + this.cache.destroy(); + } +} diff --git a/src/api/pika/index.ts b/src/api/pika/index.ts new file mode 100644 index 0000000..0a10af9 --- /dev/null +++ b/src/api/pika/index.ts @@ -0,0 +1,63 @@ +/** + * PikaNetwork API Module + * Exports all API-related types and classes + * Based on pikanetwork.js but rewritten in TypeScript with improvements + */ + +export { PikaNetworkAPI } from './client.ts'; +export { PikaCache } from './cache.ts'; +export type { + // Profile types + ProfileResponse, + Rank, + PlayerRank, + Friend, + ClanInfo, + ClanLeveling, + ClanMember, + ClanMemberUser, + ClanOwner, + // Clan types + ClanResponse, + // Leaderboard types + LeaderboardResponse, + LeaderboardEntry, + LeaderboardEntryValue, + // Parsed stats types + BedWarsStats, + SkyWarsStats, + // Options and enums + GameMode, + Interval, + BedWarsMode, + SkyWarsMode, + PikaAPIOptions, + // Batch types + BatchLeaderboardResult, + MinimalLeaderboardData, + // Error types + PikaAPIError, + // Punishment types + Punishment, + PunishmentType, + // Staff types + StaffList, + StaffRole, + // Vote types + VoteEntry, + VoteLeaderboard, + // Server types + ServerStatus, + // Total leaderboard types + TotalLeaderboardEntry, + TotalLeaderboardOptions, + // Extended profile types + JoinInfo, + MiscInfo, +} from './types.ts'; + +export { + isProfileResponse, + isClanResponse, + isLeaderboardResponse, +} from './types.ts'; diff --git a/src/api/pika/types.ts b/src/api/pika/types.ts new file mode 100644 index 0000000..6a22cd0 --- /dev/null +++ b/src/api/pika/types.ts @@ -0,0 +1,354 @@ +/** + * PikaNetwork API Type Definitions + * Comprehensive TypeScript interfaces for all API responses + */ + +// ============================================================================ +// Profile Types +// ============================================================================ + +export interface Rank { + displayName: string; + name: string; + priority: number; +} + +export interface PlayerRank { + level: number; + percentage: number; + rankDisplay: string; +} + +export interface Friend { + username: string; +} + +export interface ClanLeveling { + level: number; + exp: number; + totalExp: number; +} + +export interface ClanMemberUser { + username: string; +} + +export interface ClanMember { + user: ClanMemberUser; + joinTime: string; +} + +export interface ClanOwner { + username: string; +} + +export interface ClanInfo { + name: string; + tag: string; + currentTrophies: number; + creationTime: string; + members: ClanMember[]; + owner: ClanOwner; + leveling: ClanLeveling; +} + +export interface ProfileResponse { + username: string; + discord_verified: boolean; + lastSeen: number; + ranks: Rank[]; + email_verified: boolean; + discord_boosting: boolean; + clan: ClanInfo | null; + rank: PlayerRank; + friends: Friend[]; +} + +// ============================================================================ +// Clan Types +// ============================================================================ + +export interface ClanResponse { + name: string; + tag: string; + currentTrophies: number; + creationTime: string; + members: ClanMember[]; + owner: ClanOwner; + leveling: ClanLeveling; +} + +// ============================================================================ +// Leaderboard Types +// ============================================================================ + +export interface LeaderboardEntryValue { + value: number; + place: number; +} + +export interface LeaderboardEntry { + entries: LeaderboardEntryValue[] | null; +} + +export interface LeaderboardResponse { + [key: string]: LeaderboardEntry; +} + +// ============================================================================ +// Parsed Stats Types +// ============================================================================ + +export interface BedWarsStats { + kills: number; + deaths: number; + finalKills: number; + finalDeaths: number; + wins: number; + losses: number; + bedsDestroyed: number; + gamesPlayed: number; + highestWinstreak: number; + bowKills: number; + arrowsShot: number; + arrowsHit: number; + meleeKills: number; + voidKills: number; + // Calculated ratios + kdr: number; + fkdr: number; + wlr: number; + // Leaderboard positions + positions: { + kills: number; + deaths: number; + finalKills: number; + finalDeaths: number; + wins: number; + losses: number; + bedsDestroyed: number; + gamesPlayed: number; + highestWinstreak: number; + }; +} + +export interface SkyWarsStats { + kills: number; + deaths: number; + wins: number; + losses: number; + gamesPlayed: number; + highestWinstreak: number; + bowKills: number; + arrowsShot: number; + arrowsHit: number; + meleeKills: number; + voidKills: number; + // Calculated ratios + kdr: number; + wlr: number; + // Leaderboard positions + positions: { + kills: number; + deaths: number; + wins: number; + losses: number; + gamesPlayed: number; + highestWinstreak: number; + }; +} + +// ============================================================================ +// API Options & Enums +// ============================================================================ + +export type GameMode = 'bedwars' | 'skywars'; + +export type Interval = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime'; + +export type BedWarsMode = 'solo' | 'doubles' | 'triples' | 'quad' | 'all_modes'; + +export type SkyWarsMode = 'solo' | 'doubles' | 'all_modes'; + +export interface PikaAPIOptions { + cacheTTL?: number; + timeout?: number; + userAgent?: string; +} + +// ============================================================================ +// Batch Request Types +// ============================================================================ + +export interface BatchLeaderboardResult { + username: string; + bedwarsWins: number; + skywarsWins: number; + totalWins: number; +} + +export interface MinimalLeaderboardData { + username: string; + bedwars_wins: number; + skywars_wins: number; + total_wins: number; +} + +// ============================================================================ +// Error Types +// ============================================================================ + +export interface PikaAPIError { + status: number; + message: string; + endpoint: string; +} + +// ============================================================================ +// Helper Functions for Type Guards +// ============================================================================ + +export function isProfileResponse(data: unknown): data is ProfileResponse { + return ( + typeof data === 'object' && + data !== null && + 'username' in data && + 'rank' in data && + 'ranks' in data + ); +} + +export function isClanResponse(data: unknown): data is ClanResponse { + return ( + typeof data === 'object' && + data !== null && + 'name' in data && + 'tag' in data && + 'members' in data && + 'owner' in data + ); +} + +export function isLeaderboardResponse(data: unknown): data is LeaderboardResponse { + return typeof data === 'object' && data !== null; +} + +// ============================================================================ +// Punishment Types (Forum Scraping) +// ============================================================================ + +export type PunishmentType = 'warn' | 'kick' | 'ban' | 'mute'; + +export interface Punishment { + type: string; + player?: string; + playerAvatar?: string; + staff?: string; + staffAvatar?: string; + reason: string; + date: string; + expires: string; +} + +// ============================================================================ +// Staff Types (Forum Scraping) +// ============================================================================ + +export type StaffRole = + | 'owner' + | 'manager' + | 'leaddeveloper' + | 'developer' + | 'admin' + | 'srmod' + | 'moderator' + | 'helper' + | 'trial'; + +export interface StaffList { + owner: string[]; + manager: string[]; + leaddeveloper: string[]; + developer: string[]; + admin: string[]; + srmod: string[]; + moderator: string[]; + helper: string[]; + trial: string[]; +} + +// ============================================================================ +// Vote Leaderboard Types +// ============================================================================ + +export interface VoteEntry { + position: number; + username: string; + votes: number; +} + +export interface VoteLeaderboard { + voters: VoteEntry[]; + runnerUps: VoteEntry[]; +} + +// ============================================================================ +// Server Status Types +// ============================================================================ + +export interface ServerStatus { + host: string; + ip: string; + port: number; + icon: string; + banner: string; + online: boolean; + software: string; + protocol: number; + playersOnline: number; + playersMax: number; + motd: string[]; + website?: string; + discord?: string; +} + +// ============================================================================ +// Total Leaderboard Types +// ============================================================================ + +export interface TotalLeaderboardEntry { + name: string; + value: number; + place: number; +} + +export interface TotalLeaderboardOptions { + gamemode: GameMode; + interval: Interval; + stat: string; + mode: string; + offset?: number; + limit?: number; +} + +// ============================================================================ +// Join Info Types +// ============================================================================ + +export interface JoinInfo { + lastJoin: number; + lastJoinFormatted: string; + estimatedFirstJoin: Date | null; + estimatedFirstJoinFormatted: string; +} + +// ============================================================================ +// Misc Info Types +// ============================================================================ + +export interface MiscInfo { + discordBoosting: boolean; + discordVerified: boolean; + emailVerified: boolean; + username: string; +} diff --git a/src/client/EllyClient.ts b/src/client/EllyClient.ts new file mode 100644 index 0000000..7a6e85c --- /dev/null +++ b/src/client/EllyClient.ts @@ -0,0 +1,361 @@ +/** + * Elly Discord Bot Client + * Extended Discord.js Client with custom functionality + */ + +import { + Client, + Collection, + GatewayIntentBits, + Partials, + ActivityType, + type TextChannel, + type Role, + type Guild, +} from 'discord.js'; +import type { Config } from '../config/types.ts'; +import type { Command } from '../types/index.ts'; +import { PikaNetworkAPI } from '../api/pika/index.ts'; +import { JsonDatabase } from '../database/connection.ts'; +import { DatabaseManager, createDatabaseManager } from '../database/DatabaseManager.ts'; +import { PermissionService } from '../services/PermissionService.ts'; +import { Logger, createLogger } from '../utils/logger.ts'; +import { getErrorHandler, type ErrorHandler } from '../utils/errors.ts'; + +/** + * Extended Discord.js Client for Elly + */ +export class EllyClient extends Client { + // Configuration + public readonly config: Config; + + // Services + public readonly pikaAPI: PikaNetworkAPI; + public readonly database: JsonDatabase; // Legacy JSON database + public dbManager: DatabaseManager | null = null; // New SQLite database + public readonly permissions: PermissionService; + public readonly logger: Logger; + public readonly errorHandler: ErrorHandler; + + // Collections + public readonly commands = new Collection(); + public readonly cooldowns = new Collection>(); + + // Cached references + public mainGuild: Guild | null = null; + + // Cached roles + public roles: { + admin: Role | null; + leader: Role | null; + officer: Role | null; + developer: Role | null; + guildMember: Role | null; + champion: Role | null; + away: Role | null; + applicationsBlacklisted: Role | null; + suggestionsBlacklisted: Role | null; + } = { + admin: null, + leader: null, + officer: null, + developer: null, + guildMember: null, + champion: null, + away: null, + applicationsBlacklisted: null, + suggestionsBlacklisted: null, + }; + + // Cached channels + public channels_cache: { + applications: TextChannel | null; + applicationLogs: TextChannel | null; + suggestions: TextChannel | null; + suggestionLogs: TextChannel | null; + guildUpdates: TextChannel | null; + discordChangelog: TextChannel | null; + inactivity: TextChannel | null; + developmentLogs: TextChannel | null; + donations: TextChannel | null; + reminders: TextChannel | null; + } = { + applications: null, + applicationLogs: null, + suggestions: null, + suggestionLogs: null, + guildUpdates: null, + discordChangelog: null, + inactivity: null, + developmentLogs: null, + donations: null, + reminders: null, + }; + + // State + private refreshInterval: number | undefined; + + constructor(config: Config) { + super({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [ + Partials.Message, + Partials.Channel, + Partials.Reaction, + Partials.User, + Partials.GuildMember, + ], + }); + + this.config = config; + + // Initialize services + this.logger = createLogger('Elly', { + level: config.logging.level, + logFile: config.logging.file, + }); + + this.pikaAPI = new PikaNetworkAPI({ + cacheTTL: config.api.pika_cache_ttl, + timeout: config.api.pika_request_timeout, + }); + + this.database = new JsonDatabase(config.database.path.replace('.db', '.json')); + this.permissions = new PermissionService(config); + this.errorHandler = getErrorHandler(); + } + + /** + * Initialize the bot + */ + async initialize(): Promise { + this.logger.info('Initializing Elly...'); + + // Load legacy JSON database (for migration) + await this.database.load(); + this.logger.info('Legacy JSON database loaded'); + + // Initialize SQLite database + try { + const sqlitePath = this.config.database.path.replace('.json', '.sqlite'); + this.dbManager = await createDatabaseManager(sqlitePath); + this.logger.info('SQLite database initialized'); + } catch (error) { + this.logger.error('Failed to initialize SQLite database:', error); + // Continue with JSON database as fallback + } + + // Set up event handlers + this.setupEventHandlers(); + + // Start refresh interval + this.startRefreshInterval(); + + this.logger.info('Initialization complete'); + } + + /** + * Set up core event handlers + */ + private setupEventHandlers(): void { + this.once('ready', () => this.onReady()); + this.on('error', (error) => this.logger.error('Client error', error)); + this.on('warn', (warning) => this.logger.warn('Client warning', warning)); + } + + /** + * Handle ready event + */ + private async onReady(): Promise { + this.logger.info(`Logged in as ${this.user?.tag}`); + + // Set presence + this.user?.setPresence({ + activities: [ + { + name: this.config.bot.status, + type: this.getActivityType(this.config.bot.activity_type), + }, + ], + status: 'online', + }); + + // Cache guild and roles + await this.refreshCache(); + + this.logger.info('Elly is ready!'); + } + + /** + * Get Discord.js ActivityType from config string + */ + private getActivityType(type: string): ActivityType { + const types: Record = { + playing: ActivityType.Playing, + streaming: ActivityType.Streaming, + listening: ActivityType.Listening, + watching: ActivityType.Watching, + competing: ActivityType.Competing, + }; + return types[type] ?? ActivityType.Watching; + } + + /** + * Refresh cached guild, roles, and channels + */ + async refreshCache(): Promise { + try { + // Get main guild + this.mainGuild = await this.guilds.fetch(this.config.guild.id); + + if (!this.mainGuild) { + this.logger.error('Could not find main guild'); + return; + } + + // Cache roles + const roleConfig = this.config.roles; + this.roles = { + admin: this.findRole(roleConfig.admin), + leader: this.findRole(roleConfig.leader), + officer: this.findRole(roleConfig.officer), + developer: this.findRole(roleConfig.developer), + guildMember: this.findRole(roleConfig.guild_member), + champion: this.findRole(roleConfig.champion), + away: this.findRole(roleConfig.away), + applicationsBlacklisted: this.findRole(roleConfig.applications_blacklisted), + suggestionsBlacklisted: this.findRole(roleConfig.suggestions_blacklisted), + }; + + // Cache channels + const channelConfig = this.config.channels; + this.channels_cache = { + applications: this.findChannel(channelConfig.applications), + applicationLogs: this.findChannel(channelConfig.application_logs), + suggestions: this.findChannel(channelConfig.suggestions), + suggestionLogs: this.findChannel(channelConfig.suggestion_logs), + guildUpdates: this.findChannel(channelConfig.guild_updates), + discordChangelog: this.findChannel(channelConfig.discord_changelog), + inactivity: this.findChannel(channelConfig.inactivity), + developmentLogs: this.findChannel(channelConfig.development_logs), + donations: this.findChannel(channelConfig.donations), + reminders: this.findChannel(channelConfig.reminders), + }; + + this.logger.debug('Cache refreshed'); + } catch (error) { + this.logger.error('Failed to refresh cache', error); + } + } + + /** + * Find a role by name in the main guild + */ + private findRole(name: string): Role | null { + if (!this.mainGuild) return null; + return this.mainGuild.roles.cache.find( + (role) => role.name.toLowerCase() === name.toLowerCase() + ) ?? null; + } + + /** + * Find a channel by name in the main guild + */ + private findChannel(name: string): TextChannel | null { + if (!this.mainGuild) return null; + const channel = this.mainGuild.channels.cache.find( + (ch) => ch.name.toLowerCase() === name.toLowerCase() + ); + return channel?.isTextBased() ? (channel as TextChannel) : null; + } + + /** + * Start the cache refresh interval + */ + private startRefreshInterval(): void { + // Refresh cache every 10 minutes + this.refreshInterval = setInterval(() => { + this.refreshCache(); + }, 600000); + + // Clear PikaNetwork API cache every hour + setInterval(() => { + this.pikaAPI.clearCache(); + this.logger.debug('PikaNetwork API cache cleared'); + }, 3600000); + } + + /** + * Register a command + */ + registerCommand(command: Command): void { + this.commands.set(command.data.name, command); + this.logger.debug(`Registered command: ${command.data.name}`); + } + + /** + * Check if a user is on cooldown for a command + */ + isOnCooldown(userId: string, commandName: string): number { + const commandCooldowns = this.cooldowns.get(commandName); + if (!commandCooldowns) return 0; + + const expiresAt = commandCooldowns.get(userId); + if (!expiresAt) return 0; + + const now = Date.now(); + if (now < expiresAt) { + return Math.ceil((expiresAt - now) / 1000); + } + + commandCooldowns.delete(userId); + return 0; + } + + /** + * Set a cooldown for a user on a command + */ + setCooldown(userId: string, commandName: string, seconds: number): void { + if (!this.cooldowns.has(commandName)) { + this.cooldowns.set(commandName, new Collection()); + } + + const commandCooldowns = this.cooldowns.get(commandName)!; + commandCooldowns.set(userId, Date.now() + seconds * 1000); + } + + /** + * Graceful shutdown + */ + async shutdown(): Promise { + this.logger.info('Shutting down...'); + + // Clear intervals + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + + // Close SQLite database + if (this.dbManager) { + this.dbManager.close(); + } + + // Save legacy JSON database + await this.database.close(); + + // Destroy API client + this.pikaAPI.destroy(); + + // Destroy Discord client + this.destroy(); + + this.logger.info('Shutdown complete'); + } +} diff --git a/src/commands/applications/handlers/admin.ts b/src/commands/applications/handlers/admin.ts new file mode 100644 index 0000000..f6fd2f0 --- /dev/null +++ b/src/commands/applications/handlers/admin.ts @@ -0,0 +1,276 @@ +/** + * Application Admin Handlers (Export/Purge) + */ + +import { + EmbedBuilder, + AttachmentBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { EllyClient } from '../../../client/EllyClient.ts'; +import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts'; +import { PermissionLevel } from '../../../types/index.ts'; + +/** + * Handle exporting applications to CSV + */ +export async function handleExport( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) { + await interaction.reply({ + content: 'โŒ You need Admin permission to export applications.', + ephemeral: true, + }); + return; + } + + const statusFilter = interaction.options.getString('status') ?? 'all'; + + await interaction.deferReply({ ephemeral: true }); + + // Get applications + let applications: Application[]; + if (statusFilter === 'all') { + applications = await repo.getAll(); + } else { + applications = await repo.getByStatus(statusFilter as Application['status']); + } + + if (applications.length === 0) { + await interaction.editReply({ + content: '๐Ÿ“ญ No applications to export.', + }); + return; + } + + // Create CSV content + const headers = [ + 'ID', + 'User ID', + 'MC Username', + 'Status', + 'Discord Age', + 'Timezone', + 'Activity', + 'Why Join', + 'Experience', + 'Reviewed By', + 'Created At', + 'Reviewed At', + ]; + + const rows = applications.map((app) => [ + app.id, + app.userId, + app.minecraftUsername, + app.status, + app.discordAge ?? '', + app.timezone ?? '', + app.activity ?? '', + `"${(app.whyJoin ?? '').replace(/"/g, '""')}"`, + `"${(app.experience ?? '').replace(/"/g, '""')}"`, + app.reviewedBy ?? '', + new Date(app.createdAt).toISOString(), + app.reviewedAt ? new Date(app.reviewedAt).toISOString() : '', + ]); + + const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n'); + + // Create attachment + const buffer = new TextEncoder().encode(csv); + const attachment = new AttachmentBuilder(buffer, { + name: `applications_${statusFilter}_${Date.now()}.csv`, + }); + + const embed = new EmbedBuilder() + .setColor(0x57f287) + .setTitle('๐Ÿ“ค Applications Exported') + .addFields( + { name: 'Filter', value: statusFilter === 'all' ? 'All' : statusFilter, inline: true }, + { name: 'Count', value: String(applications.length), inline: true }, + { name: 'Format', value: 'CSV', inline: true } + ) + .setFooter({ text: `Exported by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.editReply({ + embeds: [embed], + files: [attachment], + }); + + // Log export + const logChannel = client.channels_cache.applicationLogs; + if (logChannel) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x3498db) + .setTitle('๐Ÿ“ค Applications Exported') + .addFields( + { name: 'Exported By', value: interaction.user.tag, inline: true }, + { name: 'Filter', value: statusFilter, inline: true }, + { name: 'Count', value: String(applications.length), inline: true } + ) + .setTimestamp(), + ], + }); + } +} + +/** + * Handle purging old applications + */ +export async function handlePurge( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) { + await interaction.reply({ + content: 'โŒ You need Admin permission to purge applications.', + ephemeral: true, + }); + return; + } + + const days = interaction.options.getInteger('days', true); + const statusFilter = interaction.options.getString('status'); + + await interaction.deferReply({ ephemeral: true }); + + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + + // Get applications to purge + const allApplications = await repo.getAll(); + let toPurge = allApplications.filter((a) => a.createdAt < cutoff); + + // Apply status filter + if (statusFilter === 'denied') { + toPurge = toPurge.filter((a) => a.status === 'denied'); + } else if (statusFilter === 'reviewed') { + toPurge = toPurge.filter((a) => a.status !== 'pending'); + } + + if (toPurge.length === 0) { + await interaction.editReply({ + content: '๐Ÿ“ญ No applications match the purge criteria.', + }); + return; + } + + // Confirm purge + const { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } = await import('discord.js'); + + const confirmEmbed = new EmbedBuilder() + .setColor(0xed4245) + .setTitle('โš ๏ธ Confirm Purge') + .setDescription( + `You are about to permanently delete **${toPurge.length}** applications.\n\n` + + `**Criteria:**\n` + + `โ€ข Older than: ${days} days\n` + + `โ€ข Status filter: ${statusFilter ?? 'All reviewed'}\n\n` + + `This action cannot be undone!` + ) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('purge:confirm') + .setLabel(`Delete ${toPurge.length} Applications`) + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('purge:cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + + const response = await interaction.editReply({ + embeds: [confirmEmbed], + components: [row], + }); + + try { + const buttonInteraction = await response.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === interaction.user.id, + time: 30000, + }); + + if (buttonInteraction.customId === 'purge:cancel') { + await buttonInteraction.update({ + embeds: [ + new EmbedBuilder() + .setColor(0x3498db) + .setTitle('โŒ Purge Cancelled') + .setDescription('No applications were deleted.') + .setTimestamp(), + ], + components: [], + }); + return; + } + + // Perform purge + let deleted = 0; + for (const app of toPurge) { + const success = await repo.delete(app.id); + if (success) deleted++; + } + + // Also delete associated notes + const notes = client.database.get>('application_notes') ?? []; + const purgedIds = new Set(toPurge.map((a) => a.id)); + const remainingNotes = notes.filter((n) => !purgedIds.has(n.appId)); + client.database.set('application_notes', remainingNotes); + + const resultEmbed = new EmbedBuilder() + .setColor(0x57f287) + .setTitle('๐Ÿ—‘๏ธ Purge Complete') + .addFields( + { name: 'Deleted', value: String(deleted), inline: true }, + { name: 'Notes Removed', value: String(notes.length - remainingNotes.length), inline: true }, + { name: 'Criteria', value: `Older than ${days} days`, inline: true } + ) + .setFooter({ text: `Purged by ${interaction.user.tag}` }) + .setTimestamp(); + + await buttonInteraction.update({ + embeds: [resultEmbed], + components: [], + }); + + // Log purge + const logChannel = client.channels_cache.applicationLogs; + if (logChannel) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0xed4245) + .setTitle('๐Ÿ—‘๏ธ Applications Purged') + .addFields( + { name: 'Purged By', value: interaction.user.tag, inline: true }, + { name: 'Count', value: String(deleted), inline: true }, + { name: 'Criteria', value: `Older than ${days} days, ${statusFilter ?? 'all reviewed'}`, inline: true } + ) + .setTimestamp(), + ], + }); + } + } catch { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xed4245) + .setTitle('โฐ Timed Out') + .setDescription('Purge cancelled due to timeout.') + .setTimestamp(), + ], + components: [], + }); + } +} diff --git a/src/commands/applications/handlers/apply.ts b/src/commands/applications/handlers/apply.ts new file mode 100644 index 0000000..b2f30b0 --- /dev/null +++ b/src/commands/applications/handlers/apply.ts @@ -0,0 +1,708 @@ +/** + * Application Submission Handler + */ + +import { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelType, + type ChatInputCommandInteraction, + type ModalSubmitInteraction, + ComponentType, + type ButtonInteraction, +} from 'discord.js'; +import type { EllyClient } from '../../../client/EllyClient.ts'; +import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts'; +import { PermissionLevel } from '../../../types/index.ts'; + +/** + * Handle application submission + */ +export async function handleApply( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + // Check if user is blacklisted + const blacklists = client.database.get>('blacklists') ?? []; + const isBlacklisted = blacklists.some( + (b) => b.userId === interaction.user.id && (b.type === 'applications' || b.type === 'bot') + ); + + if (isBlacklisted) { + await interaction.reply({ + content: 'โŒ You are blacklisted from submitting applications.', + ephemeral: true, + }); + return; + } + + // Check if user already has a pending application + const existing = await repo.hasPendingApplication(interaction.user.id); + if (existing) { + await interaction.reply({ + content: 'โŒ You already have a pending application. Please wait for it to be reviewed.', + ephemeral: true, + }); + return; + } + + // Check cooldown (can't apply again within 7 days of denial) + const recentDenied = await repo.getRecentDenied(interaction.user.id, 7 * 24 * 60 * 60 * 1000); + if (recentDenied) { + const waitUntil = recentDenied.createdAt + 7 * 24 * 60 * 60 * 1000; + await interaction.reply({ + content: `โŒ You were recently denied. You can apply again .`, + ephemeral: true, + }); + return; + } + + // Show application modal + const modal = new ModalBuilder() + .setCustomId('application:submit') + .setTitle('Guild Application') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('minecraft_username') + .setLabel('Minecraft Username') + .setPlaceholder('Your in-game name (case-sensitive)') + .setStyle(TextInputStyle.Short) + .setMaxLength(16) + .setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('timezone') + .setLabel('Timezone & Availability') + .setPlaceholder('e.g., EST, usually online 6-10 PM') + .setStyle(TextInputStyle.Short) + .setMaxLength(50) + .setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('activity') + .setLabel('Weekly Activity') + .setPlaceholder('How many hours per week can you play?') + .setStyle(TextInputStyle.Short) + .setMaxLength(100) + .setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('why_join') + .setLabel('Why do you want to join our guild?') + .setPlaceholder('Tell us about yourself and why you want to join') + .setStyle(TextInputStyle.Paragraph) + .setMinLength(50) + .setMaxLength(1000) + .setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('experience') + .setLabel('Gaming Experience') + .setPlaceholder('Your experience with Minecraft, PvP, guilds, etc.') + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(500) + .setRequired(true) + ) + ); + + await interaction.showModal(modal); + + // Wait for modal submission + try { + const modalInteraction = await interaction.awaitModalSubmit({ + time: 600000, // 10 minutes + filter: (i) => i.customId === 'application:submit' && i.user.id === interaction.user.id, + }); + + await processApplicationSubmit(modalInteraction, client, repo); + } catch { + // Modal timed out - no action needed + } +} + +/** + * Process application modal submission + */ +async function processApplicationSubmit( + interaction: ModalSubmitInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + await interaction.deferReply({ ephemeral: true }); + + const minecraftUsername = interaction.fields.getTextInputValue('minecraft_username'); + const timezone = interaction.fields.getTextInputValue('timezone'); + const activity = interaction.fields.getTextInputValue('activity'); + const whyJoin = interaction.fields.getTextInputValue('why_join'); + const experience = interaction.fields.getTextInputValue('experience'); + + // Calculate Discord account age + const accountAge = Date.now() - interaction.user.createdTimestamp; + const days = Math.floor(accountAge / (1000 * 60 * 60 * 24)); + const discordAge = `${days} days`; + + // Fetch PikaNetwork stats if available + let pikaStats = null; + try { + const profile = await client.pikaAPI.getProfile(minecraftUsername); + if (profile) { + pikaStats = { + exists: true, + lastSeen: profile.lastSeen, + }; + } + } catch { + // API error - continue without stats + } + + // Create application + const application = await repo.create({ + userId: interaction.user.id, + messageId: '', + channelId: '', + minecraftUsername, + discordAge, + timezone, + activity, + whyJoin, + experience, + extra: pikaStats ? JSON.stringify(pikaStats) : undefined, + }); + + // Find applications channel + const channel = client.channels_cache.applications; + + if (!channel) { + await interaction.editReply({ + content: 'โŒ Applications channel not found. Please contact an administrator.', + }); + return; + } + + // Create application embed + const embed = createApplicationEmbed(application, interaction.user, client, pikaStats); + + // Create action buttons + const row1 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app:accept:${application.id}`) + .setLabel('Accept') + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + new ButtonBuilder() + .setCustomId(`app:deny:${application.id}`) + .setLabel('Deny') + .setStyle(ButtonStyle.Danger) + .setEmoji('โŒ'), + new ButtonBuilder() + .setCustomId(`app:interview:${application.id}`) + .setLabel('Request Interview') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐ŸŽค'), + new ButtonBuilder() + .setCustomId(`app:note:${application.id}`) + .setLabel('Add Note') + .setStyle(ButtonStyle.Secondary) + .setEmoji('๐Ÿ“') + ); + + const row2 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app:stats:${minecraftUsername}`) + .setLabel('View Stats') + .setStyle(ButtonStyle.Secondary) + .setEmoji('๐Ÿ“Š'), + new ButtonBuilder() + .setCustomId(`app:history:${interaction.user.id}`) + .setLabel('User History') + .setStyle(ButtonStyle.Secondary) + .setEmoji('๐Ÿ“œ') + ); + + // Post to applications channel + const message = await channel.send({ + content: `<@&${client.roles.officer?.id ?? ''}>`, + embeds: [embed], + components: [row1, row2], + }); + + // Update application with message ID + await repo.updateMessageId(application.id, message.id, channel.id); + + await interaction.editReply({ + content: `โœ… Your application has been submitted!\n\n**Application ID:** \`${application.id}\`\n**Status:** โณ Pending Review\n\nYou will be notified via DM when your application is reviewed.`, + }); + + // Set up button collector + setupApplicationCollector(message, application.id, client, repo); + + // Log to application logs channel + const logChannel = client.channels_cache.applicationLogs; + if (logChannel) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x3498db) + .setTitle('๐Ÿ“ฅ New Application Received') + .addFields( + { name: 'Applicant', value: `${interaction.user.tag} (<@${interaction.user.id}>)`, inline: true }, + { name: 'MC Username', value: minecraftUsername, inline: true }, + { name: 'Application ID', value: `\`${application.id}\``, inline: true } + ) + .setTimestamp(), + ], + }); + } +} + +/** + * Create application embed + */ +function createApplicationEmbed( + application: Application, + user: import('discord.js').User, + client: EllyClient, + pikaStats: { exists: boolean; lastSeen?: string } | null +): EmbedBuilder { + const embed = new EmbedBuilder() + .setColor(0xfee75c) + .setTitle('๐Ÿ“ New Guild Application') + .setThumbnail(user.displayAvatarURL({ size: 256 })) + .addFields( + { name: '๐Ÿ‘ค Applicant', value: `${user.tag}\n<@${user.id}>`, inline: true }, + { name: '๐ŸŽฎ MC Username', value: `\`${application.minecraftUsername}\``, inline: true }, + { name: '๐Ÿ†” Application ID', value: `\`${application.id}\``, inline: true }, + { name: '๐Ÿ“… Discord Age', value: application.discordAge, inline: true }, + { name: '๐ŸŒ Timezone', value: application.timezone, inline: true }, + { name: 'โฐ Activity', value: application.activity, inline: true }, + { name: 'โ“ Why Join', value: application.whyJoin.substring(0, 1024) }, + { name: '๐ŸŽฏ Experience', value: application.experience.substring(0, 1024) } + ) + .setFooter({ text: `Submitted at` }) + .setTimestamp(application.createdAt); + + // Add PikaNetwork verification + if (pikaStats) { + embed.addFields({ + name: '๐Ÿ” PikaNetwork', + value: pikaStats.exists ? 'โœ… Account Found' : 'โŒ Account Not Found', + inline: true, + }); + } + + return embed; +} + +/** + * Set up collector for application buttons + */ +function setupApplicationCollector( + message: import('discord.js').Message, + applicationId: string, + client: EllyClient, + repo: ApplicationRepository +): void { + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 14 * 24 * 60 * 60 * 1000, // 14 days + }); + + collector.on('collect', async (i: ButtonInteraction) => { + const member = i.guild?.members.cache.get(i.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await i.reply({ + content: 'โŒ You need Officer permission to review applications.', + ephemeral: true, + }); + return; + } + + const [, action, id] = i.customId.split(':'); + + switch (action) { + case 'accept': + await handleButtonAccept(i, applicationId, client, repo, message); + break; + case 'deny': + await handleButtonDeny(i, applicationId, client, repo, message); + break; + case 'interview': + await handleButtonInterview(i, applicationId, client, repo); + break; + case 'note': + await handleButtonNote(i, applicationId, client, repo); + break; + case 'stats': + await handleButtonStats(i, id, client); + break; + case 'history': + await handleButtonHistory(i, id, client, repo); + break; + } + }); + + collector.on('end', async () => { + // Disable buttons after collector ends + try { + const row1 = ActionRowBuilder.from(message.components[0]).setComponents( + message.components[0].components.map((c) => + ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true) + ) + ); + const row2 = ActionRowBuilder.from(message.components[1]).setComponents( + message.components[1].components.map((c) => + ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true) + ) + ); + await message.edit({ components: [row1 as ActionRowBuilder, row2 as ActionRowBuilder] }); + } catch { + // Message might be deleted + } + }); +} + +async function handleButtonAccept( + i: ButtonInteraction, + applicationId: string, + client: EllyClient, + repo: ApplicationRepository, + message: import('discord.js').Message +): Promise { + const app = await repo.updateStatus(applicationId, 'accepted', i.user.id); + if (!app) { + await i.reply({ content: 'โŒ Application not found.', ephemeral: true }); + return; + } + + // Update message + const embed = EmbedBuilder.from(message.embeds[0]) + .setColor(0x57f287) + .setTitle('โœ… Application Accepted') + .addFields({ name: '๐Ÿ‘ค Reviewed By', value: i.user.tag, inline: true }); + + // Disable buttons + const row1 = new ActionRowBuilder().addComponents( + ...message.components[0].components.map((c) => + ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true) + ) + ); + const row2 = new ActionRowBuilder().addComponents( + ...message.components[1].components.map((c) => + ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true) + ) + ); + + await message.edit({ embeds: [embed], components: [row1, row2] }); + + // Notify applicant + await notifyApplicant(client, app.userId, 'accepted'); + + // Add guild member role + try { + const member = await i.guild?.members.fetch(app.userId); + if (member && client.roles.guildMember) { + await member.roles.add(client.roles.guildMember); + } + } catch { + // Member might have left + } + + await i.reply({ content: 'โœ… Application accepted! User has been notified.', ephemeral: true }); + + // Log + const logChannel = client.channels_cache.applicationLogs; + if (logChannel) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x57f287) + .setTitle('โœ… Application Accepted') + .addFields( + { name: 'Applicant', value: `<@${app.userId}>`, inline: true }, + { name: 'MC Username', value: app.minecraftUsername, inline: true }, + { name: 'Reviewed By', value: i.user.tag, inline: true } + ) + .setTimestamp(), + ], + }); + } +} + +async function handleButtonDeny( + i: ButtonInteraction, + applicationId: string, + client: EllyClient, + repo: ApplicationRepository, + message: import('discord.js').Message +): Promise { + // Show denial reason modal + const modal = new ModalBuilder() + .setCustomId(`app:deny_modal:${applicationId}`) + .setTitle('Deny Application') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('reason') + .setLabel('Reason for denial') + .setPlaceholder('This will be sent to the applicant') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + ) + ); + + await i.showModal(modal); + + try { + const modalInteraction = await i.awaitModalSubmit({ + time: 300000, + filter: (mi) => mi.customId === `app:deny_modal:${applicationId}`, + }); + + const reason = modalInteraction.fields.getTextInputValue('reason'); + const app = await repo.updateStatus(applicationId, 'denied', i.user.id); + + if (!app) { + await modalInteraction.reply({ content: 'โŒ Application not found.', ephemeral: true }); + return; + } + + // Update message + const embed = EmbedBuilder.from(message.embeds[0]) + .setColor(0xed4245) + .setTitle('โŒ Application Denied') + .addFields( + { name: '๐Ÿ‘ค Reviewed By', value: i.user.tag, inline: true }, + { name: '๐Ÿ“ Reason', value: reason } + ); + + const row1 = new ActionRowBuilder().addComponents( + ...message.components[0].components.map((c) => + ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true) + ) + ); + const row2 = new ActionRowBuilder().addComponents( + ...message.components[1].components.map((c) => + ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true) + ) + ); + + await message.edit({ embeds: [embed], components: [row1, row2] }); + + // Notify applicant + await notifyApplicant(client, app.userId, 'denied', reason); + + await modalInteraction.reply({ content: 'โŒ Application denied. User has been notified.', ephemeral: true }); + + // Log + const logChannel = client.channels_cache.applicationLogs; + if (logChannel) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0xed4245) + .setTitle('โŒ Application Denied') + .addFields( + { name: 'Applicant', value: `<@${app.userId}>`, inline: true }, + { name: 'Reviewed By', value: i.user.tag, inline: true }, + { name: 'Reason', value: reason } + ) + .setTimestamp(), + ], + }); + } + } catch { + // Modal timed out + } +} + +async function handleButtonInterview( + i: ButtonInteraction, + applicationId: string, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const app = await repo.getById(applicationId); + if (!app) { + await i.reply({ content: 'โŒ Application not found.', ephemeral: true }); + return; + } + + // Notify applicant about interview request + try { + const user = await client.users.fetch(app.userId); + await user.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x3498db) + .setTitle('๐ŸŽค Interview Requested') + .setDescription( + `A staff member has requested an interview regarding your guild application.\n\n` + + `Please contact <@${i.user.id}> to schedule your interview.` + ) + .setTimestamp(), + ], + }); + + await i.reply({ + content: `โœ… Interview request sent to <@${app.userId}>.`, + ephemeral: true, + }); + } catch { + await i.reply({ + content: 'โŒ Could not send interview request. User may have DMs disabled.', + ephemeral: true, + }); + } +} + +async function handleButtonNote( + i: ButtonInteraction, + applicationId: string, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const modal = new ModalBuilder() + .setCustomId(`app:add_note:${applicationId}`) + .setTitle('Add Note') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('note') + .setLabel('Internal Note') + .setPlaceholder('This note is only visible to staff') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + ) + ); + + await i.showModal(modal); + + try { + const modalInteraction = await i.awaitModalSubmit({ + time: 300000, + filter: (mi) => mi.customId === `app:add_note:${applicationId}`, + }); + + const note = modalInteraction.fields.getTextInputValue('note'); + + // Store note (would need to add notes array to application) + const notes = client.database.get>('application_notes') ?? []; + notes.push({ + appId: applicationId, + note, + by: i.user.id, + at: Date.now(), + }); + client.database.set('application_notes', notes); + + await modalInteraction.reply({ + content: 'โœ… Note added to application.', + ephemeral: true, + }); + } catch { + // Modal timed out + } +} + +async function handleButtonStats( + i: ButtonInteraction, + username: string, + client: EllyClient +): Promise { + await i.deferReply({ ephemeral: true }); + + try { + const profile = await client.pikaAPI.getProfile(username); + + if (!profile) { + await i.editReply({ content: `โŒ Could not find PikaNetwork profile for \`${username}\`.` }); + return; + } + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ“Š ${username}'s PikaNetwork Stats`) + .addFields( + { name: 'Last Seen', value: profile.lastSeen ?? 'Unknown', inline: true }, + { name: 'Rank', value: profile.rank ?? 'None', inline: true } + ) + .setTimestamp(); + + await i.editReply({ embeds: [embed] }); + } catch { + await i.editReply({ content: 'โŒ Failed to fetch stats.' }); + } +} + +async function handleButtonHistory( + i: ButtonInteraction, + userId: string, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const applications = await repo.getByUserId(userId); + + if (applications.length === 0) { + await i.reply({ + content: '๐Ÿ“ญ This user has no previous applications.', + ephemeral: true, + }); + return; + } + + const history = applications.slice(0, 5).map((app) => { + const status = app.status === 'accepted' ? 'โœ…' : app.status === 'denied' ? 'โŒ' : 'โณ'; + const date = new Date(app.createdAt).toLocaleDateString(); + return `${status} \`${app.id}\` - ${date}`; + }); + + await i.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“œ Application History') + .setDescription(history.join('\n')) + .setFooter({ text: `Total: ${applications.length} applications` }), + ], + ephemeral: true, + }); +} + +/** + * Notify applicant of decision + */ +async function notifyApplicant( + client: EllyClient, + userId: string, + status: 'accepted' | 'denied', + reason?: string +): Promise { + try { + const user = await client.users.fetch(userId); + + const embed = new EmbedBuilder() + .setColor(status === 'accepted' ? 0x57f287 : 0xed4245) + .setTitle(status === 'accepted' ? '๐ŸŽ‰ Application Accepted!' : 'โŒ Application Denied') + .setDescription( + status === 'accepted' + ? `Congratulations! Your guild application has been accepted!\n\nWelcome to **${client.config.guild.name}**! You now have access to guild channels and features.` + : `Unfortunately, your guild application has been denied.\n\n**Reason:** ${reason ?? 'No reason provided'}\n\nYou may reapply in 7 days.` + ) + .setTimestamp(); + + await user.send({ embeds: [embed] }); + } catch { + // User might have DMs disabled + } +} diff --git a/src/commands/applications/handlers/list.ts b/src/commands/applications/handlers/list.ts new file mode 100644 index 0000000..24f89cd --- /dev/null +++ b/src/commands/applications/handlers/list.ts @@ -0,0 +1,299 @@ +/** + * Application List/Search/History Handlers + */ + +import { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + ComponentType, +} from 'discord.js'; +import type { EllyClient } from '../../../client/EllyClient.ts'; +import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts'; +import { PermissionLevel } from '../../../types/index.ts'; + +const ITEMS_PER_PAGE = 10; + +/** + * Handle listing applications with pagination + */ +export async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + // Check permission for viewing all applications + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to list applications.', + ephemeral: true, + }); + return; + } + + const statusFilter = interaction.options.getString('status') ?? 'pending'; + const sortOrder = interaction.options.getString('sort') ?? 'newest'; + const page = interaction.options.getInteger('page') ?? 1; + + await interaction.deferReply({ ephemeral: true }); + + // Get applications + let applications: Application[]; + if (statusFilter === 'all') { + applications = await repo.getAll(); + } else { + applications = await repo.getByStatus(statusFilter as Application['status']); + } + + // Sort + applications.sort((a, b) => { + return sortOrder === 'newest' + ? b.createdAt - a.createdAt + : a.createdAt - b.createdAt; + }); + + if (applications.length === 0) { + await interaction.editReply({ + content: `๐Ÿ“ญ No ${statusFilter === 'all' ? '' : statusFilter} applications found.`, + }); + return; + } + + const totalPages = Math.ceil(applications.length / ITEMS_PER_PAGE); + const currentPage = Math.min(page, totalPages); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const pageApplications = applications.slice(startIndex, startIndex + ITEMS_PER_PAGE); + + const embed = createListEmbed(pageApplications, statusFilter, currentPage, totalPages, applications.length); + + // Create pagination buttons + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`applist:prev:${currentPage}:${statusFilter}:${sortOrder}`) + .setLabel('โ—€ Previous') + .setStyle(ButtonStyle.Secondary) + .setDisabled(currentPage <= 1), + new ButtonBuilder() + .setCustomId(`applist:page:${currentPage}`) + .setLabel(`Page ${currentPage}/${totalPages}`) + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`applist:next:${currentPage}:${statusFilter}:${sortOrder}`) + .setLabel('Next โ–ถ') + .setStyle(ButtonStyle.Secondary) + .setDisabled(currentPage >= totalPages) + ); + + const response = await interaction.editReply({ + embeds: [embed], + components: [row], + }); + + // Set up pagination collector + const collector = response.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 300000, // 5 minutes + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ content: 'โŒ This is not your menu.', ephemeral: true }); + return; + } + + const [, action, pageStr, status, sort] = i.customId.split(':'); + let newPage = parseInt(pageStr); + + if (action === 'prev') newPage--; + if (action === 'next') newPage++; + + const newStartIndex = (newPage - 1) * ITEMS_PER_PAGE; + const newPageApplications = applications.slice(newStartIndex, newStartIndex + ITEMS_PER_PAGE); + + const newEmbed = createListEmbed(newPageApplications, status, newPage, totalPages, applications.length); + + const newRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`applist:prev:${newPage}:${status}:${sort}`) + .setLabel('โ—€ Previous') + .setStyle(ButtonStyle.Secondary) + .setDisabled(newPage <= 1), + new ButtonBuilder() + .setCustomId(`applist:page:${newPage}`) + .setLabel(`Page ${newPage}/${totalPages}`) + .setStyle(ButtonStyle.Primary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`applist:next:${newPage}:${status}:${sort}`) + .setLabel('Next โ–ถ') + .setStyle(ButtonStyle.Secondary) + .setDisabled(newPage >= totalPages) + ); + + await i.update({ embeds: [newEmbed], components: [newRow] }); + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ components: [] }); + } catch { + // Message might be deleted + } + }); +} + +/** + * Create list embed + */ +function createListEmbed( + applications: Application[], + status: string, + page: number, + totalPages: number, + total: number +): EmbedBuilder { + const statusEmoji: Record = { + pending: 'โณ', + accepted: 'โœ…', + denied: 'โŒ', + all: '๐Ÿ“‹', + }; + + const statusColors: Record = { + pending: 0xfee75c, + accepted: 0x57f287, + denied: 0xed4245, + all: 0x3498db, + }; + + const list = applications.map((app, i) => { + const num = (page - 1) * ITEMS_PER_PAGE + i + 1; + const emoji = statusEmoji[app.status]; + const date = new Date(app.createdAt).toLocaleDateString(); + return `**${num}.** ${emoji} \`${app.id}\` - **${app.minecraftUsername}** (${date})`; + }); + + return new EmbedBuilder() + .setColor(statusColors[status] ?? 0x3498db) + .setTitle(`${statusEmoji[status]} ${status === 'all' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)} Applications`) + .setDescription(list.join('\n') || 'No applications') + .setFooter({ text: `Page ${page}/${totalPages} โ€ข Total: ${total} applications` }) + .setTimestamp(); +} + +/** + * Handle searching applications + */ +export async function handleSearch( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to search applications.', + ephemeral: true, + }); + return; + } + + const query = interaction.options.getString('query', true).toLowerCase(); + + await interaction.deferReply({ ephemeral: true }); + + const allApplications = await repo.getAll(); + + // Search by ID, username, or user ID + const results = allApplications.filter((app) => + app.id.toLowerCase().includes(query) || + app.minecraftUsername.toLowerCase().includes(query) || + app.userId.includes(query) + ); + + if (results.length === 0) { + await interaction.editReply({ + content: `๐Ÿ” No applications found matching \`${query}\`.`, + }); + return; + } + + const list = results.slice(0, 15).map((app, i) => { + const emoji = app.status === 'accepted' ? 'โœ…' : app.status === 'denied' ? 'โŒ' : 'โณ'; + const date = new Date(app.createdAt).toLocaleDateString(); + return `**${i + 1}.** ${emoji} \`${app.id}\` - **${app.minecraftUsername}** (<@${app.userId}>) - ${date}`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ” Search Results for "${query}"`) + .setDescription(list.join('\n')) + .setFooter({ text: `Found ${results.length} application(s)` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +/** + * Handle viewing user application history + */ +export async function handleHistory( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to view application history.', + ephemeral: true, + }); + return; + } + + const targetUser = interaction.options.getUser('user', true); + + await interaction.deferReply({ ephemeral: true }); + + const applications = await repo.getByUserId(targetUser.id); + + if (applications.length === 0) { + await interaction.editReply({ + content: `๐Ÿ“ญ ${targetUser.tag} has no application history.`, + }); + return; + } + + // Calculate stats + const accepted = applications.filter((a) => a.status === 'accepted').length; + const denied = applications.filter((a) => a.status === 'denied').length; + const pending = applications.filter((a) => a.status === 'pending').length; + + const history = applications.slice(0, 10).map((app, i) => { + const emoji = app.status === 'accepted' ? 'โœ…' : app.status === 'denied' ? 'โŒ' : 'โณ'; + const date = new Date(app.createdAt).toLocaleDateString(); + const reviewer = app.reviewedBy ? ` by <@${app.reviewedBy}>` : ''; + return `**${i + 1}.** ${emoji} \`${app.id}\` - ${app.minecraftUsername} (${date})${reviewer}`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ“œ Application History: ${targetUser.tag}`) + .setThumbnail(targetUser.displayAvatarURL()) + .setDescription(history.join('\n')) + .addFields( + { name: 'โœ… Accepted', value: String(accepted), inline: true }, + { name: 'โŒ Denied', value: String(denied), inline: true }, + { name: 'โณ Pending', value: String(pending), inline: true }, + { name: '๐Ÿ“Š Total', value: String(applications.length), inline: true }, + { name: '๐Ÿ“ˆ Accept Rate', value: applications.length > 0 ? `${Math.round((accepted / applications.length) * 100)}%` : 'N/A', inline: true } + ) + .setFooter({ text: `Showing last ${Math.min(10, applications.length)} applications` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/src/commands/applications/handlers/review.ts b/src/commands/applications/handlers/review.ts new file mode 100644 index 0000000..438fec2 --- /dev/null +++ b/src/commands/applications/handlers/review.ts @@ -0,0 +1,278 @@ +/** + * Application Review Handlers (Accept/Deny) + */ + +import { + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { EllyClient } from '../../../client/EllyClient.ts'; +import { ApplicationRepository } from '../../../database/repositories/ApplicationRepository.ts'; +import { PermissionLevel } from '../../../types/index.ts'; + +/** + * Handle accepting an application via command + */ +export async function handleAccept( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + // Check permission + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to accept applications.', + ephemeral: true, + }); + return; + } + + const id = interaction.options.getString('id', true); + const note = interaction.options.getString('note'); + + const application = await repo.getById(id); + if (!application) { + await interaction.reply({ + content: 'โŒ Application not found.', + ephemeral: true, + }); + return; + } + + if (application.status !== 'pending') { + await interaction.reply({ + content: `โŒ This application has already been ${application.status}.`, + ephemeral: true, + }); + return; + } + + // Update status + const updated = await repo.updateStatus(id, 'accepted', interaction.user.id); + if (!updated) { + await interaction.reply({ + content: 'โŒ Failed to update application.', + ephemeral: true, + }); + return; + } + + // Add note if provided + if (note) { + const notes = client.database.get>('application_notes') ?? []; + notes.push({ + appId: id, + note, + by: interaction.user.id, + at: Date.now(), + }); + client.database.set('application_notes', notes); + } + + // Notify applicant + await notifyApplicant(client, application.userId, 'accepted'); + + // Add guild member role + try { + const applicantMember = await interaction.guild?.members.fetch(application.userId); + if (applicantMember && client.roles.guildMember) { + await applicantMember.roles.add(client.roles.guildMember); + } + } catch { + // Member might have left + } + + // Update original message if exists + if (application.messageId && application.channelId) { + try { + const channel = await client.channels.fetch(application.channelId); + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(application.messageId); + const embed = EmbedBuilder.from(message.embeds[0]) + .setColor(0x57f287) + .setTitle('โœ… Application Accepted') + .addFields({ name: '๐Ÿ‘ค Reviewed By', value: interaction.user.tag }); + await message.edit({ embeds: [embed], components: [] }); + } + } catch { + // Message might be deleted + } + } + + const embed = new EmbedBuilder() + .setColor(0x57f287) + .setTitle('โœ… Application Accepted') + .addFields( + { name: 'Application ID', value: `\`${id}\``, inline: true }, + { name: 'Applicant', value: `<@${application.userId}>`, inline: true }, + { name: 'MC Username', value: application.minecraftUsername, inline: true } + ) + .setFooter({ text: `Accepted by ${interaction.user.tag}` }) + .setTimestamp(); + + if (note) { + embed.addFields({ name: 'Note', value: note }); + } + + await interaction.reply({ embeds: [embed] }); + + // Log + const logChannel = client.channels_cache.applicationLogs; + if (logChannel) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0x57f287) + .setTitle('โœ… Application Accepted') + .addFields( + { name: 'Applicant', value: `<@${application.userId}>`, inline: true }, + { name: 'MC Username', value: application.minecraftUsername, inline: true }, + { name: 'Reviewed By', value: interaction.user.tag, inline: true } + ) + .setTimestamp(), + ], + }); + } +} + +/** + * Handle denying an application via command + */ +export async function handleDeny( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + // Check permission + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to deny applications.', + ephemeral: true, + }); + return; + } + + const id = interaction.options.getString('id', true); + const reason = interaction.options.getString('reason', true); + + const application = await repo.getById(id); + if (!application) { + await interaction.reply({ + content: 'โŒ Application not found.', + ephemeral: true, + }); + return; + } + + if (application.status !== 'pending') { + await interaction.reply({ + content: `โŒ This application has already been ${application.status}.`, + ephemeral: true, + }); + return; + } + + // Update status + const updated = await repo.updateStatus(id, 'denied', interaction.user.id); + if (!updated) { + await interaction.reply({ + content: 'โŒ Failed to update application.', + ephemeral: true, + }); + return; + } + + // Store denial reason + const notes = client.database.get>('application_notes') ?? []; + notes.push({ + appId: id, + note: `[DENIAL REASON] ${reason}`, + by: interaction.user.id, + at: Date.now(), + }); + client.database.set('application_notes', notes); + + // Notify applicant + await notifyApplicant(client, application.userId, 'denied', reason); + + // Update original message if exists + if (application.messageId && application.channelId) { + try { + const channel = await client.channels.fetch(application.channelId); + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(application.messageId); + const embed = EmbedBuilder.from(message.embeds[0]) + .setColor(0xed4245) + .setTitle('โŒ Application Denied') + .addFields( + { name: '๐Ÿ‘ค Reviewed By', value: interaction.user.tag }, + { name: '๐Ÿ“ Reason', value: reason } + ); + await message.edit({ embeds: [embed], components: [] }); + } + } catch { + // Message might be deleted + } + } + + const embed = new EmbedBuilder() + .setColor(0xed4245) + .setTitle('โŒ Application Denied') + .addFields( + { name: 'Application ID', value: `\`${id}\``, inline: true }, + { name: 'Applicant', value: `<@${application.userId}>`, inline: true }, + { name: 'Reason', value: reason } + ) + .setFooter({ text: `Denied by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); + + // Log + const logChannel = client.channels_cache.applicationLogs; + if (logChannel) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(0xed4245) + .setTitle('โŒ Application Denied') + .addFields( + { name: 'Applicant', value: `<@${application.userId}>`, inline: true }, + { name: 'Reviewed By', value: interaction.user.tag, inline: true }, + { name: 'Reason', value: reason } + ) + .setTimestamp(), + ], + }); + } +} + +/** + * Notify applicant of decision + */ +async function notifyApplicant( + client: EllyClient, + userId: string, + status: 'accepted' | 'denied', + reason?: string +): Promise { + try { + const user = await client.users.fetch(userId); + + const embed = new EmbedBuilder() + .setColor(status === 'accepted' ? 0x57f287 : 0xed4245) + .setTitle(status === 'accepted' ? '๐ŸŽ‰ Application Accepted!' : 'โŒ Application Denied') + .setDescription( + status === 'accepted' + ? `Congratulations! Your guild application has been accepted!\n\nWelcome to **${client.config.guild.name}**!` + : `Unfortunately, your guild application has been denied.\n\n**Reason:** ${reason ?? 'No reason provided'}\n\nYou may reapply in 7 days.` + ) + .setTimestamp(); + + await user.send({ embeds: [embed] }); + } catch { + // User might have DMs disabled + } +} diff --git a/src/commands/applications/handlers/settings.ts b/src/commands/applications/handlers/settings.ts new file mode 100644 index 0000000..cf701c0 --- /dev/null +++ b/src/commands/applications/handlers/settings.ts @@ -0,0 +1,89 @@ +/** + * Application Settings Handlers (Placeholder for future features) + */ + +import { + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { EllyClient } from '../../../client/EllyClient.ts'; +import { ApplicationRepository } from '../../../database/repositories/ApplicationRepository.ts'; +import { PermissionLevel } from '../../../types/index.ts'; + +/** + * Handle application settings (placeholder) + */ +export async function handleSettings( + interaction: ChatInputCommandInteraction, + client: EllyClient, + _repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) { + await interaction.reply({ + content: 'โŒ You need Admin permission to manage settings.', + ephemeral: true, + }); + return; + } + + // Get current settings + const settings = client.database.get<{ + cooldownDays: number; + autoClose: boolean; + autoCloseHours: number; + requireVerification: boolean; + notifyOnSubmit: boolean; + }>('application_settings') ?? { + cooldownDays: 7, + autoClose: false, + autoCloseHours: 168, + requireVerification: false, + notifyOnSubmit: true, + }; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('โš™๏ธ Application Settings') + .addFields( + { name: 'โฑ๏ธ Reapply Cooldown', value: `${settings.cooldownDays} days`, inline: true }, + { name: '๐Ÿ”’ Auto-Close', value: settings.autoClose ? `After ${settings.autoCloseHours}h` : 'Disabled', inline: true }, + { name: 'โœ… Require Verification', value: settings.requireVerification ? 'Yes' : 'No', inline: true }, + { name: '๐Ÿ”” Notify on Submit', value: settings.notifyOnSubmit ? 'Yes' : 'No', inline: true } + ) + .setFooter({ text: 'Use /applications settings set to modify' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +/** + * Handle application templates (placeholder) + */ +export async function handleTemplates( + interaction: ChatInputCommandInteraction, + client: EllyClient, + _repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) { + await interaction.reply({ + content: 'โŒ You need Admin permission to manage templates.', + ephemeral: true, + }); + return; + } + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“ Application Templates') + .setDescription('Application templates allow you to customize the questions asked during the application process.') + .addFields( + { name: 'Current Template', value: 'Default (5 questions)', inline: true }, + { name: 'Custom Templates', value: '0', inline: true } + ) + .setFooter({ text: 'Template customization coming soon!' }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} diff --git a/src/commands/applications/handlers/stats.ts b/src/commands/applications/handlers/stats.ts new file mode 100644 index 0000000..3394d19 --- /dev/null +++ b/src/commands/applications/handlers/stats.ts @@ -0,0 +1,219 @@ +/** + * Application Statistics Handlers + */ + +import { + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { EllyClient } from '../../../client/EllyClient.ts'; +import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts'; +import { PermissionLevel } from '../../../types/index.ts'; + +/** + * Handle viewing detailed statistics + */ +export async function handleStats( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to view statistics.', + ephemeral: true, + }); + return; + } + + const period = interaction.options.getString('period') ?? 'all'; + + await interaction.deferReply({ ephemeral: true }); + + const allApplications = await repo.getAll(); + + // Filter by period + const now = Date.now(); + const periodMs: Record = { + today: 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + month: 30 * 24 * 60 * 60 * 1000, + all: Infinity, + }; + + const cutoff = now - (periodMs[period] ?? Infinity); + const applications = allApplications.filter((a) => a.createdAt >= cutoff); + + // Calculate stats + const total = applications.length; + const pending = applications.filter((a) => a.status === 'pending').length; + const accepted = applications.filter((a) => a.status === 'accepted').length; + const denied = applications.filter((a) => a.status === 'denied').length; + const reviewed = accepted + denied; + + const acceptRate = reviewed > 0 ? Math.round((accepted / reviewed) * 100) : 0; + const denyRate = reviewed > 0 ? Math.round((denied / reviewed) * 100) : 0; + + // Calculate average review time + const reviewedApps = applications.filter((a) => a.reviewedAt && a.status !== 'pending'); + let avgReviewTime = 0; + if (reviewedApps.length > 0) { + const totalReviewTime = reviewedApps.reduce((sum, a) => sum + ((a.reviewedAt ?? 0) - a.createdAt), 0); + avgReviewTime = totalReviewTime / reviewedApps.length; + } + + // Get top reviewers + const reviewerCounts: Record = {}; + for (const app of applications) { + if (app.reviewedBy) { + reviewerCounts[app.reviewedBy] = (reviewerCounts[app.reviewedBy] ?? 0) + 1; + } + } + const topReviewers = Object.entries(reviewerCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3); + + // Get applications by day (last 7 days) + const dailyStats: Record = {}; + const last7Days = applications.filter((a) => a.createdAt >= now - 7 * 24 * 60 * 60 * 1000); + for (const app of last7Days) { + const day = new Date(app.createdAt).toLocaleDateString('en-US', { weekday: 'short' }); + dailyStats[day] = (dailyStats[day] ?? 0) + 1; + } + + const periodLabel = { + today: 'Today', + week: 'This Week', + month: 'This Month', + all: 'All Time', + }[period] ?? 'All Time'; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ“Š Application Statistics - ${periodLabel}`) + .addFields( + { name: '๐Ÿ“ฅ Total Received', value: String(total), inline: true }, + { name: 'โณ Pending', value: String(pending), inline: true }, + { name: 'โœ… Accepted', value: String(accepted), inline: true }, + { name: 'โŒ Denied', value: String(denied), inline: true }, + { name: '๐Ÿ“ˆ Accept Rate', value: `${acceptRate}%`, inline: true }, + { name: '๐Ÿ“‰ Deny Rate', value: `${denyRate}%`, inline: true }, + { name: 'โฑ๏ธ Avg Review Time', value: formatDuration(avgReviewTime), inline: true }, + { name: '๐Ÿ“‹ Reviewed', value: String(reviewed), inline: true }, + { name: '๐Ÿ“Š Pending Rate', value: total > 0 ? `${Math.round((pending / total) * 100)}%` : '0%', inline: true } + ); + + // Add top reviewers + if (topReviewers.length > 0) { + const reviewerList = topReviewers + .map(([id, count], i) => `${['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰'][i]} <@${id}> - ${count} reviews`) + .join('\n'); + embed.addFields({ name: '๐Ÿ† Top Reviewers', value: reviewerList }); + } + + // Add daily breakdown + if (Object.keys(dailyStats).length > 0) { + const dailyList = Object.entries(dailyStats) + .map(([day, count]) => `${day}: ${'โ–ˆ'.repeat(Math.min(count, 10))} ${count}`) + .join('\n'); + embed.addFields({ name: '๐Ÿ“… Last 7 Days', value: '```\n' + dailyList + '\n```' }); + } + + embed.setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +/** + * Handle viewing reviewer leaderboard + */ +export async function handleLeaderboard( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to view the leaderboard.', + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + const allApplications = await repo.getAll(); + + // Calculate reviewer stats + const reviewerStats: Record = {}; + + for (const app of allApplications) { + if (app.reviewedBy) { + if (!reviewerStats[app.reviewedBy]) { + reviewerStats[app.reviewedBy] = { total: 0, accepted: 0, denied: 0 }; + } + reviewerStats[app.reviewedBy].total++; + if (app.status === 'accepted') reviewerStats[app.reviewedBy].accepted++; + if (app.status === 'denied') reviewerStats[app.reviewedBy].denied++; + } + } + + const leaderboard = Object.entries(reviewerStats) + .sort((a, b) => b[1].total - a[1].total) + .slice(0, 10); + + if (leaderboard.length === 0) { + await interaction.editReply({ + content: '๐Ÿ“ญ No reviews have been made yet.', + }); + return; + } + + const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; + const list = leaderboard.map(([userId, stats], i) => { + const medal = medals[i] ?? `**${i + 1}.**`; + const acceptRate = stats.total > 0 ? Math.round((stats.accepted / stats.total) * 100) : 0; + return `${medal} <@${userId}> - **${stats.total}** reviews (โœ… ${stats.accepted} | โŒ ${stats.denied} | ${acceptRate}% accept)`; + }); + + // Find user's rank + const userRank = leaderboard.findIndex(([id]) => id === interaction.user.id); + const userStats = reviewerStats[interaction.user.id]; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ† Application Reviewer Leaderboard') + .setDescription(list.join('\n')) + .setTimestamp(); + + if (userStats) { + embed.setFooter({ + text: `Your rank: #${userRank + 1} with ${userStats.total} reviews`, + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +/** + * Format duration in human readable format + */ +function formatDuration(ms: number): string { + if (ms === 0 || !isFinite(ms)) return 'N/A'; + + const hours = Math.floor(ms / (60 * 60 * 1000)); + const minutes = Math.floor((ms % (60 * 60 * 1000)) / (60 * 1000)); + + if (hours > 24) { + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; + } + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + + return `${minutes}m`; +} diff --git a/src/commands/applications/handlers/view.ts b/src/commands/applications/handlers/view.ts new file mode 100644 index 0000000..9b8c0e9 --- /dev/null +++ b/src/commands/applications/handlers/view.ts @@ -0,0 +1,159 @@ +/** + * Application View Handler + */ + +import { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { EllyClient } from '../../../client/EllyClient.ts'; +import { ApplicationRepository, type Application } from '../../../database/repositories/ApplicationRepository.ts'; + +/** + * Handle viewing an application + */ +export async function handleView( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const id = interaction.options.getString('id', true); + const application = await repo.getById(id); + + if (!application) { + await interaction.reply({ + content: 'โŒ Application not found.', + ephemeral: true, + }); + return; + } + + // Get applicant info + let applicant; + try { + applicant = await client.users.fetch(application.userId); + } catch { + applicant = null; + } + + // Get notes + const notes = client.database.get>('application_notes') ?? []; + const appNotes = notes.filter((n) => n.appId === id); + + const embed = createDetailedApplicationEmbed(application, applicant, client); + + // Add notes if any + if (appNotes.length > 0) { + const notesList = appNotes.slice(-3).map((n) => { + const date = new Date(n.at).toLocaleDateString(); + return `โ€ข <@${n.by}> (${date}): ${n.note.substring(0, 100)}`; + }); + embed.addFields({ + name: `๐Ÿ“ Staff Notes (${appNotes.length})`, + value: notesList.join('\n'), + }); + } + + // Create action buttons if pending + const components: ActionRowBuilder[] = []; + if (application.status === 'pending') { + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app:accept:${id}`) + .setLabel('Accept') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`app:deny:${id}`) + .setLabel('Deny') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`app:interview:${id}`) + .setLabel('Request Interview') + .setStyle(ButtonStyle.Primary) + ) + ); + } + + await interaction.reply({ + embeds: [embed], + components, + ephemeral: true, + }); +} + +/** + * Create detailed application embed + */ +function createDetailedApplicationEmbed( + application: Application, + applicant: import('discord.js').User | null, + client: EllyClient +): EmbedBuilder { + const statusConfig = { + pending: { color: 0xfee75c, emoji: 'โณ', label: 'Pending Review' }, + accepted: { color: 0x57f287, emoji: 'โœ…', label: 'Accepted' }, + denied: { color: 0xed4245, emoji: 'โŒ', label: 'Denied' }, + }; + + const status = statusConfig[application.status]; + const submittedAt = Math.floor(application.createdAt / 1000); + const reviewedAt = application.reviewedAt ? Math.floor(application.reviewedAt / 1000) : null; + + const embed = new EmbedBuilder() + .setColor(status.color) + .setTitle(`${status.emoji} Application Details`) + .setDescription(`**Status:** ${status.label}`) + .addFields( + { name: '๐Ÿ†” Application ID', value: `\`${application.id}\``, inline: true }, + { name: '๐Ÿ‘ค Applicant', value: applicant ? `${applicant.tag}\n<@${application.userId}>` : `<@${application.userId}>`, inline: true }, + { name: '๐ŸŽฎ MC Username', value: `\`${application.minecraftUsername}\``, inline: true }, + { name: '๐Ÿ“… Discord Age', value: application.discordAge ?? 'Unknown', inline: true }, + { name: '๐ŸŒ Timezone', value: application.timezone ?? 'Unknown', inline: true }, + { name: 'โฐ Activity', value: application.activity ?? 'Unknown', inline: true }, + { name: 'โ“ Why Join', value: application.whyJoin?.substring(0, 1024) ?? 'Not provided' }, + { name: '๐ŸŽฏ Experience', value: application.experience?.substring(0, 1024) ?? 'Not provided' }, + { name: '๐Ÿ“† Submitted', value: ` ()`, inline: true } + ); + + if (applicant) { + embed.setThumbnail(applicant.displayAvatarURL({ size: 256 })); + } + + if (application.reviewedBy) { + embed.addFields({ + name: '๐Ÿ‘ค Reviewed By', + value: `<@${application.reviewedBy}>`, + inline: true, + }); + } + + if (reviewedAt) { + embed.addFields({ + name: '๐Ÿ“† Reviewed At', + value: ``, + inline: true, + }); + } + + // Parse extra data if available + if (application.extra) { + try { + const extra = JSON.parse(application.extra); + if (extra.exists !== undefined) { + embed.addFields({ + name: '๐Ÿ” PikaNetwork Verified', + value: extra.exists ? 'โœ… Yes' : 'โŒ No', + inline: true, + }); + } + } catch { + // Invalid JSON + } + } + + return embed; +} diff --git a/src/commands/applications/index.ts b/src/commands/applications/index.ts new file mode 100644 index 0000000..a825e9c --- /dev/null +++ b/src/commands/applications/index.ts @@ -0,0 +1,271 @@ +/** + * Applications Command Module + * Advanced guild application management system + */ + +import { + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { ApplicationRepository } from '../../database/repositories/ApplicationRepository.ts'; + +// Import handlers +import { handleApply } from './handlers/apply.ts'; +import { handleView } from './handlers/view.ts'; +import { handleAccept, handleDeny } from './handlers/review.ts'; +import { handleList, handleSearch, handleHistory } from './handlers/list.ts'; +import { handleStats, handleLeaderboard } from './handlers/stats.ts'; +import { handleSettings, handleTemplates } from './handlers/settings.ts'; +import { handleExport, handlePurge } from './handlers/admin.ts'; + +export const applicationsCommand: Command = { + data: new SlashCommandBuilder() + .setName('applications') + .setDescription('Advanced guild application management') + // User commands + .addSubcommand((sub) => + sub + .setName('apply') + .setDescription('Apply to join the guild') + ) + .addSubcommand((sub) => + sub + .setName('status') + .setDescription('Check your application status') + ) + // Staff commands + .addSubcommand((sub) => + sub + .setName('view') + .setDescription('View an application in detail') + .addStringOption((opt) => + opt.setName('id').setDescription('Application ID').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('accept') + .setDescription('Accept an application') + .addStringOption((opt) => + opt.setName('id').setDescription('Application ID').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('note').setDescription('Internal note').setRequired(false) + ) + ) + .addSubcommand((sub) => + sub + .setName('deny') + .setDescription('Deny an application') + .addStringOption((opt) => + opt.setName('id').setDescription('Application ID').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for denial').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List applications with filters') + .addStringOption((opt) => + opt + .setName('status') + .setDescription('Filter by status') + .addChoices( + { name: 'All', value: 'all' }, + { name: 'Pending', value: 'pending' }, + { name: 'Accepted', value: 'accepted' }, + { name: 'Denied', value: 'denied' } + ) + ) + .addStringOption((opt) => + opt + .setName('sort') + .setDescription('Sort order') + .addChoices( + { name: 'Newest First', value: 'newest' }, + { name: 'Oldest First', value: 'oldest' } + ) + ) + .addIntegerOption((opt) => + opt.setName('page').setDescription('Page number').setMinValue(1) + ) + ) + .addSubcommand((sub) => + sub + .setName('search') + .setDescription('Search applications') + .addStringOption((opt) => + opt.setName('query').setDescription('Search by username or ID').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('history') + .setDescription('View application history for a user') + .addUserOption((opt) => + opt.setName('user').setDescription('User to check').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('stats') + .setDescription('View detailed application statistics') + .addStringOption((opt) => + opt + .setName('period') + .setDescription('Time period') + .addChoices( + { name: 'Today', value: 'today' }, + { name: 'This Week', value: 'week' }, + { name: 'This Month', value: 'month' }, + { name: 'All Time', value: 'all' } + ) + ) + ) + .addSubcommand((sub) => + sub + .setName('leaderboard') + .setDescription('View reviewer leaderboard') + ) + // Admin commands + .addSubcommand((sub) => + sub + .setName('export') + .setDescription('Export applications to CSV') + .addStringOption((opt) => + opt + .setName('status') + .setDescription('Filter by status') + .addChoices( + { name: 'All', value: 'all' }, + { name: 'Accepted', value: 'accepted' }, + { name: 'Denied', value: 'denied' } + ) + ) + ) + .addSubcommand((sub) => + sub + .setName('purge') + .setDescription('Purge old applications') + .addIntegerOption((opt) => + opt + .setName('days') + .setDescription('Delete applications older than X days') + .setRequired(true) + .setMinValue(30) + ) + .addStringOption((opt) => + opt + .setName('status') + .setDescription('Only purge specific status') + .addChoices( + { name: 'Denied Only', value: 'denied' }, + { name: 'All Reviewed', value: 'reviewed' } + ) + ) + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + const repo = new ApplicationRepository(client.database); + + switch (subcommand) { + case 'apply': + await handleApply(interaction, client, repo); + break; + case 'status': + await handleUserStatus(interaction, client, repo); + break; + case 'view': + await handleView(interaction, client, repo); + break; + case 'accept': + await handleAccept(interaction, client, repo); + break; + case 'deny': + await handleDeny(interaction, client, repo); + break; + case 'list': + await handleList(interaction, client, repo); + break; + case 'search': + await handleSearch(interaction, client, repo); + break; + case 'history': + await handleHistory(interaction, client, repo); + break; + case 'stats': + await handleStats(interaction, client, repo); + break; + case 'leaderboard': + await handleLeaderboard(interaction, client, repo); + break; + case 'export': + await handleExport(interaction, client, repo); + break; + case 'purge': + await handlePurge(interaction, client, repo); + break; + } + }, +}; + +/** + * Handle user checking their own application status + */ +async function handleUserStatus( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ApplicationRepository +): Promise { + const applications = await repo.getByUserId(interaction.user.id); + + if (applications.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ You have no applications on record.', + ephemeral: true, + }); + return; + } + + const { EmbedBuilder } = await import('discord.js'); + + const latest = applications[0]; + const statusEmoji = { + pending: 'โณ', + accepted: 'โœ…', + denied: 'โŒ', + }; + + const embed = new EmbedBuilder() + .setColor( + latest.status === 'pending' ? 0xfee75c : + latest.status === 'accepted' ? 0x57f287 : 0xed4245 + ) + .setTitle('๐Ÿ“‹ Your Application Status') + .addFields( + { name: 'Latest Application', value: `\`${latest.id}\``, inline: true }, + { name: 'Status', value: `${statusEmoji[latest.status]} ${latest.status.charAt(0).toUpperCase() + latest.status.slice(1)}`, inline: true }, + { name: 'Submitted', value: ``, inline: true }, + { name: 'Total Applications', value: String(applications.length), inline: true } + ); + + if (latest.reviewedBy) { + embed.addFields({ + name: 'Reviewed By', + value: `<@${latest.reviewedBy}>`, + inline: true, + }); + } + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} diff --git a/src/commands/developer/blacklist.ts b/src/commands/developer/blacklist.ts new file mode 100644 index 0000000..6a76daa --- /dev/null +++ b/src/commands/developer/blacklist.ts @@ -0,0 +1,292 @@ +/** + * Blacklist Command + * Manage user blacklists for various features + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +interface BlacklistEntry { + userId: string; + type: string; + reason?: string; + createdBy: string; + createdAt: number; +} + +export const blacklistCommand: Command = { + data: new SlashCommandBuilder() + .setName('blacklist') + .setDescription('Manage user blacklists') + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a user to a blacklist') + .addUserOption((opt) => + opt.setName('user').setDescription('The user to blacklist').setRequired(true) + ) + .addStringOption((opt) => + opt + .setName('type') + .setDescription('The blacklist type') + .setRequired(true) + .addChoices( + { name: 'Bot (all features)', value: 'bot' }, + { name: 'Applications', value: 'applications' }, + { name: 'Suggestions', value: 'suggestions' }, + { name: 'Commands', value: 'commands' } + ) + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for blacklisting').setRequired(false) + ) + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a user from a blacklist') + .addUserOption((opt) => + opt.setName('user').setDescription('The user to unblacklist').setRequired(true) + ) + .addStringOption((opt) => + opt + .setName('type') + .setDescription('The blacklist type') + .setRequired(true) + .addChoices( + { name: 'Bot (all features)', value: 'bot' }, + { name: 'Applications', value: 'applications' }, + { name: 'Suggestions', value: 'suggestions' }, + { name: 'Commands', value: 'commands' } + ) + ) + ) + .addSubcommand((sub) => + sub + .setName('check') + .setDescription('Check if a user is blacklisted') + .addUserOption((opt) => + opt.setName('user').setDescription('The user to check').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List all blacklisted users') + .addStringOption((opt) => + opt + .setName('type') + .setDescription('Filter by blacklist type') + .setRequired(false) + .addChoices( + { name: 'Bot (all features)', value: 'bot' }, + { name: 'Applications', value: 'applications' }, + { name: 'Suggestions', value: 'suggestions' }, + { name: 'Commands', value: 'commands' } + ) + ) + ), + + permission: PermissionLevel.Admin, + cooldown: 3, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'add': + await handleAdd(interaction, client); + break; + case 'remove': + await handleRemove(interaction, client); + break; + case 'check': + await handleCheck(interaction, client); + break; + case 'list': + await handleList(interaction, client); + break; + } + }, +}; + +async function handleAdd( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const user = interaction.options.getUser('user', true); + const type = interaction.options.getString('type', true); + const reason = interaction.options.getString('reason'); + + // Get existing blacklists + const blacklists = client.database.get('blacklists') ?? []; + + // Check if already blacklisted + const existing = blacklists.find((b) => b.userId === user.id && b.type === type); + if (existing) { + await interaction.reply({ + content: `โŒ ${user.tag} is already blacklisted from \`${type}\`.`, + ephemeral: true, + }); + return; + } + + // Add to blacklist + blacklists.push({ + userId: user.id, + type, + reason: reason ?? undefined, + createdBy: interaction.user.id, + createdAt: Date.now(), + }); + + client.database.set('blacklists', blacklists); + + const embed = new EmbedBuilder() + .setColor(0xED4245) + .setTitle('๐Ÿšซ User Blacklisted') + .addFields( + { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, + { name: 'Type', value: type, inline: true }, + { name: 'Reason', value: reason ?? 'No reason provided' } + ) + .setFooter({ text: `Blacklisted by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); +} + +async function handleRemove( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const user = interaction.options.getUser('user', true); + const type = interaction.options.getString('type', true); + + // Get existing blacklists + const blacklists = client.database.get('blacklists') ?? []; + + // Find and remove + const index = blacklists.findIndex((b) => b.userId === user.id && b.type === type); + if (index === -1) { + await interaction.reply({ + content: `โŒ ${user.tag} is not blacklisted from \`${type}\`.`, + ephemeral: true, + }); + return; + } + + blacklists.splice(index, 1); + client.database.set('blacklists', blacklists); + + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('โœ… User Unblacklisted') + .addFields( + { name: 'User', value: `${user.tag} (${user.id})`, inline: true }, + { name: 'Type', value: type, inline: true } + ) + .setFooter({ text: `Removed by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); +} + +async function handleCheck( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const user = interaction.options.getUser('user', true); + + // Get existing blacklists + const blacklists = client.database.get('blacklists') ?? []; + + // Find all blacklists for user + const userBlacklists = blacklists.filter((b) => b.userId === user.id); + + if (userBlacklists.length === 0) { + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('โœ… User Not Blacklisted') + .setDescription(`${user.tag} is not on any blacklists.`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + + const blacklistInfo = userBlacklists.map((b) => { + const date = new Date(b.createdAt).toLocaleDateString(); + return `โ€ข **${b.type}** - ${b.reason ?? 'No reason'} (${date})`; + }); + + const embed = new EmbedBuilder() + .setColor(0xED4245) + .setTitle('๐Ÿšซ User Blacklisted') + .setDescription(`${user.tag} is on the following blacklists:\n\n${blacklistInfo.join('\n')}`) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const typeFilter = interaction.options.getString('type'); + + // Get existing blacklists + let blacklists = client.database.get('blacklists') ?? []; + + // Filter by type if specified + if (typeFilter) { + blacklists = blacklists.filter((b) => b.type === typeFilter); + } + + if (blacklists.length === 0) { + await interaction.reply({ + content: typeFilter + ? `๐Ÿ“ญ No users blacklisted from \`${typeFilter}\`.` + : '๐Ÿ“ญ No users blacklisted.', + ephemeral: true, + }); + return; + } + + // Group by type + const grouped = new Map(); + for (const entry of blacklists) { + const list = grouped.get(entry.type) ?? []; + list.push(entry); + grouped.set(entry.type, list); + } + + const fields = []; + for (const [type, entries] of grouped) { + const userList = entries + .slice(0, 10) + .map((e) => `<@${e.userId}>`) + .join(', '); + const extra = entries.length > 10 ? ` (+${entries.length - 10} more)` : ''; + fields.push({ + name: `${type} (${entries.length})`, + value: userList + extra, + }); + } + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿšซ Blacklisted Users') + .addFields(fields) + .setFooter({ text: `Total: ${blacklists.length} entries` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} diff --git a/src/commands/developer/database.ts b/src/commands/developer/database.ts new file mode 100644 index 0000000..8ae0ffb --- /dev/null +++ b/src/commands/developer/database.ts @@ -0,0 +1,315 @@ +/** + * Database Command + * Manage and inspect the database + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, + codeBlock, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const databaseCommand: Command = { + data: new SlashCommandBuilder() + .setName('database') + .setDescription('Database management commands') + .addSubcommand((sub) => + sub + .setName('stats') + .setDescription('View database statistics') + ) + .addSubcommand((sub) => + sub + .setName('backup') + .setDescription('Create a database backup') + ) + .addSubcommand((sub) => + sub + .setName('query') + .setDescription('Execute a read-only SQL query') + .addStringOption((opt) => + opt + .setName('sql') + .setDescription('The SQL query to execute') + .setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('tables') + .setDescription('List all database tables') + ) + .addSubcommand((sub) => + sub + .setName('vacuum') + .setDescription('Optimize the database') + ), + + permission: PermissionLevel.Developer, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + + // Check if SQLite database is available + if (!client.dbManager) { + await interaction.reply({ + content: 'โŒ SQLite database is not initialized. Using legacy JSON database.', + ephemeral: true, + }); + return; + } + + switch (subcommand) { + case 'stats': + await handleStats(interaction, client); + break; + case 'backup': + await handleBackup(interaction, client); + break; + case 'query': + await handleQuery(interaction, client); + break; + case 'tables': + await handleTables(interaction, client); + break; + case 'vacuum': + await handleVacuum(interaction, client); + break; + } + }, +}; + +async function handleStats( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + await interaction.deferReply({ ephemeral: true }); + + try { + const stats = await client.dbManager!.getStats(); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“Š Database Statistics') + .addFields( + { name: '๐Ÿ“ Path', value: `\`${stats.path}\``, inline: false }, + { name: '๐Ÿ’พ Size', value: formatBytes(stats.size), inline: true }, + { name: '๐Ÿ”Œ Connected', value: stats.connected ? 'โœ… Yes' : 'โŒ No', inline: true }, + { name: '๐Ÿ“‹ Tables', value: String(stats.tables.length), inline: true }, + { name: '๐Ÿ“ Table List', value: stats.tables.join(', ') || 'None' } + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply({ + content: `โŒ Failed to get database stats: ${error}`, + }); + } +} + +async function handleBackup( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + await interaction.deferReply({ ephemeral: true }); + + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `./data/backups/elly_${timestamp}.sqlite`; + + // Ensure backup directory exists + await Deno.mkdir('./data/backups', { recursive: true }); + + await client.dbManager!.backup(backupPath); + + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('โœ… Backup Created') + .addFields( + { name: '๐Ÿ“ Backup Path', value: `\`${backupPath}\`` }, + { name: 'โฐ Created At', value: new Date().toISOString() } + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply({ + content: `โŒ Failed to create backup: ${error}`, + }); + } +} + +async function handleQuery( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const sql = interaction.options.getString('sql', true); + + // Security: Only allow SELECT queries + const normalizedSql = sql.trim().toLowerCase(); + if (!normalizedSql.startsWith('select') && !normalizedSql.startsWith('pragma')) { + await interaction.reply({ + content: 'โŒ Only SELECT and PRAGMA queries are allowed.', + ephemeral: true, + }); + return; + } + + // Block dangerous patterns + const dangerousPatterns = [ + /drop\s+table/i, + /delete\s+from/i, + /truncate/i, + /insert\s+into/i, + /update\s+\w+\s+set/i, + /alter\s+table/i, + /create\s+table/i, + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(sql)) { + await interaction.reply({ + content: 'โŒ Query contains forbidden patterns.', + ephemeral: true, + }); + return; + } + } + + await interaction.deferReply({ ephemeral: true }); + + try { + const startTime = performance.now(); + const result = client.dbManager!.connection.query(sql); + const endTime = performance.now(); + + if (!result.success) { + await interaction.editReply({ + content: `โŒ Query failed: ${result.error?.message}`, + }); + return; + } + + const rows = result.data ?? []; + let output = JSON.stringify(rows, null, 2); + + if (output.length > 1800) { + output = output.substring(0, 1800) + '\n... (truncated)'; + } + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“Š Query Result') + .addFields( + { name: '๐Ÿ“ Query', value: codeBlock('sql', sql.substring(0, 500)) }, + { name: '๐Ÿ“ค Result', value: codeBlock('json', output) }, + { name: '๐Ÿ“Š Rows', value: String(rows.length), inline: true }, + { name: 'โฑ๏ธ Time', value: `${(endTime - startTime).toFixed(2)}ms`, inline: true } + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply({ + content: `โŒ Query error: ${error}`, + }); + } +} + +async function handleTables( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + await interaction.deferReply({ ephemeral: true }); + + try { + const result = client.dbManager!.connection.query<{ name: string; sql: string }>( + "SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ); + + if (!result.success || !result.data) { + await interaction.editReply({ + content: `โŒ Failed to list tables: ${result.error?.message}`, + }); + return; + } + + const tables = result.data; + const tableInfo = tables.map((t) => { + // Get row count + const countResult = client.dbManager!.connection.queryOne<{ count: number }>( + `SELECT COUNT(*) as count FROM ${t.name}` + ); + const count = countResult.success ? countResult.data?.count ?? 0 : 0; + return `**${t.name}** - ${count} rows`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“‹ Database Tables') + .setDescription(tableInfo.join('\n') || 'No tables found') + .addFields({ + name: 'Total Tables', + value: String(tables.length), + inline: true, + }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply({ + content: `โŒ Failed to list tables: ${error}`, + }); + } +} + +async function handleVacuum( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + await interaction.deferReply({ ephemeral: true }); + + try { + const sizeBefore = await client.dbManager!.connection.getSize(); + await client.dbManager!.vacuum(); + const sizeAfter = await client.dbManager!.connection.getSize(); + + const saved = sizeBefore - sizeAfter; + + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('โœ… Database Optimized') + .addFields( + { name: 'Size Before', value: formatBytes(sizeBefore), inline: true }, + { name: 'Size After', value: formatBytes(sizeAfter), inline: true }, + { name: 'Space Saved', value: formatBytes(saved), inline: true } + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply({ + content: `โŒ Failed to vacuum database: ${error}`, + }); + } +} + +function formatBytes(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let unitIndex = 0; + let size = bytes; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} diff --git a/src/commands/developer/debug.ts b/src/commands/developer/debug.ts new file mode 100644 index 0000000..41d3a5e --- /dev/null +++ b/src/commands/developer/debug.ts @@ -0,0 +1,240 @@ +/** + * Debug Command + * View debug information about the bot + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, + version as djsVersion, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const debugCommand: Command = { + data: new SlashCommandBuilder() + .setName('debug') + .setDescription('View debug information') + .addSubcommand((sub) => + sub + .setName('info') + .setDescription('View general debug information') + ) + .addSubcommand((sub) => + sub + .setName('cache') + .setDescription('View cache statistics') + ) + .addSubcommand((sub) => + sub + .setName('memory') + .setDescription('View memory usage') + ) + .addSubcommand((sub) => + sub + .setName('errors') + .setDescription('View recent errors') + ) + .addSubcommand((sub) => + sub + .setName('config') + .setDescription('View configuration (sanitized)') + ), + + permission: PermissionLevel.Developer, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'info': + await handleInfo(interaction, client); + break; + case 'cache': + await handleCache(interaction, client); + break; + case 'memory': + await handleMemory(interaction, client); + break; + case 'errors': + await handleErrors(interaction, client); + break; + case 'config': + await handleConfig(interaction, client); + break; + } + }, +}; + +async function handleInfo( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const uptime = formatUptime(client.uptime ?? 0); + const memoryUsage = Deno.memoryUsage(); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ”ง Debug Information') + .addFields( + { name: '๐Ÿค– Bot', value: client.user?.tag ?? 'Unknown', inline: true }, + { name: '๐Ÿ†” Client ID', value: client.user?.id ?? 'Unknown', inline: true }, + { name: 'โฐ Uptime', value: uptime, inline: true }, + { name: '๐Ÿฆ• Deno Version', value: Deno.version.deno, inline: true }, + { name: '๐Ÿ“ฆ Discord.js', value: djsVersion, inline: true }, + { name: '๐Ÿ”ง TypeScript', value: Deno.version.typescript, inline: true }, + { name: '๐Ÿ’พ Heap Used', value: formatBytes(memoryUsage.heapUsed), inline: true }, + { name: '๐Ÿ’พ Heap Total', value: formatBytes(memoryUsage.heapTotal), inline: true }, + { name: '๐Ÿ’พ RSS', value: formatBytes(memoryUsage.rss), inline: true }, + { name: '๐Ÿ“Š Guilds', value: String(client.guilds.cache.size), inline: true }, + { name: '๐Ÿ‘ฅ Users', value: String(client.users.cache.size), inline: true }, + { name: '๐Ÿ“ Commands', value: String(client.commands.size), inline: true } + ) + .setFooter({ text: `PID: ${Deno.pid}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleCache( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“Š Cache Statistics') + .addFields( + { name: '๐Ÿ  Guilds', value: String(client.guilds.cache.size), inline: true }, + { name: '๐Ÿ“บ Channels', value: String(client.channels.cache.size), inline: true }, + { name: '๐Ÿ‘ฅ Users', value: String(client.users.cache.size), inline: true }, + { name: '๐Ÿ˜€ Emojis', value: String(client.emojis.cache.size), inline: true }, + { name: '๐ŸŽญ Roles', value: String(client.mainGuild?.roles.cache.size ?? 0), inline: true }, + { name: '๐Ÿ‘ค Members', value: String(client.mainGuild?.members.cache.size ?? 0), inline: true }, + { name: '๐Ÿ“ Commands', value: String(client.commands.size), inline: true }, + { name: 'โฑ๏ธ Cooldowns', value: String(client.cooldowns.size), inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleMemory( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const memory = Deno.memoryUsage(); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ’พ Memory Usage') + .addFields( + { name: 'RSS (Resident Set Size)', value: formatBytes(memory.rss), inline: true }, + { name: 'Heap Total', value: formatBytes(memory.heapTotal), inline: true }, + { name: 'Heap Used', value: formatBytes(memory.heapUsed), inline: true }, + { name: 'External', value: formatBytes(memory.external), inline: true }, + { + name: 'Heap Usage', + value: `${((memory.heapUsed / memory.heapTotal) * 100).toFixed(1)}%`, + inline: true + } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleErrors( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const stats = client.errorHandler.getStats(); + const recentErrors = client.errorHandler.getRecentErrors(5); + + let errorList = 'No recent errors'; + if (recentErrors.length > 0) { + errorList = recentErrors + .map((e, i) => `**${i + 1}.** \`${e.code}\` - ${e.message.substring(0, 50)}...`) + .join('\n'); + } + + const errorsByCode = Object.entries(stats.errorsByCode) + .map(([code, count]) => `\`${code}\`: ${count}`) + .join(', ') || 'None'; + + const embed = new EmbedBuilder() + .setColor(stats.totalErrors > 0 ? 0xED4245 : 0x57F287) + .setTitle('โŒ Error Statistics') + .addFields( + { name: 'Total Errors', value: String(stats.totalErrors), inline: true }, + { name: 'Recent Errors', value: String(stats.recentErrors), inline: true }, + { name: 'Errors by Code', value: errorsByCode }, + { name: 'Last 5 Errors', value: errorList } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleConfig( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + // Sanitize config - remove sensitive data + const config = { + bot: { + name: client.config.bot.name, + prefix: client.config.bot.prefix, + status: client.config.bot.status, + activity_type: client.config.bot.activity_type, + }, + guild: { + id: client.config.guild.id, + name: client.config.guild.name, + }, + api: { + pika_cache_ttl: client.config.api.pika_cache_ttl, + pika_request_timeout: client.config.api.pika_request_timeout, + }, + logging: client.config.logging, + }; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('โš™๏ธ Configuration (Sanitized)') + .setDescription('```json\n' + JSON.stringify(config, null, 2).substring(0, 4000) + '\n```') + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +function formatUptime(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours % 24 > 0) parts.push(`${hours % 24}h`); + if (minutes % 60 > 0) parts.push(`${minutes % 60}m`); + if (seconds % 60 > 0) parts.push(`${seconds % 60}s`); + + return parts.join(' ') || '0s'; +} + +function formatBytes(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let unitIndex = 0; + let size = bytes; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} diff --git a/src/commands/developer/emit.ts b/src/commands/developer/emit.ts new file mode 100644 index 0000000..2f01c80 --- /dev/null +++ b/src/commands/developer/emit.ts @@ -0,0 +1,101 @@ +/** + * Emit Command + * Emit Discord events for testing + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const emitCommand: Command = { + data: new SlashCommandBuilder() + .setName('emit') + .setDescription('Emit Discord events for testing') + .addStringOption((opt) => + opt + .setName('event') + .setDescription('The event to emit') + .setRequired(true) + .addChoices( + { name: 'guildMemberAdd', value: 'guildMemberAdd' }, + { name: 'guildMemberRemove', value: 'guildMemberRemove' }, + { name: 'ready', value: 'ready' } + ) + ) + .addUserOption((opt) => + opt + .setName('target') + .setDescription('Target user for member events') + .setRequired(false) + ), + + permission: PermissionLevel.Developer, + cooldown: 10, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const event = interaction.options.getString('event', true); + const targetUser = interaction.options.getUser('target'); + + await interaction.deferReply({ ephemeral: true }); + + try { + switch (event) { + case 'guildMemberAdd': { + const member = targetUser + ? await interaction.guild?.members.fetch(targetUser.id) + : interaction.member; + + if (!member) { + await interaction.editReply('โŒ Could not find member.'); + return; + } + + client.emit('guildMemberAdd', member); + break; + } + + case 'guildMemberRemove': { + const member = targetUser + ? await interaction.guild?.members.fetch(targetUser.id) + : interaction.member; + + if (!member) { + await interaction.editReply('โŒ Could not find member.'); + return; + } + + client.emit('guildMemberRemove', member); + break; + } + + case 'ready': { + client.emit('ready', client); + break; + } + + default: + await interaction.editReply(`โŒ Unknown event: ${event}`); + return; + } + + const embed = new EmbedBuilder() + .setColor(0x57F287) + .setTitle('โœ… Event Emitted') + .addFields( + { name: 'Event', value: `\`${event}\``, inline: true }, + { name: 'Target', value: targetUser?.tag ?? 'Self', inline: true } + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + await interaction.editReply(`โŒ Failed to emit event: ${error}`); + } + }, +}; diff --git a/src/commands/developer/eval.ts b/src/commands/developer/eval.ts new file mode 100644 index 0000000..8b161ca --- /dev/null +++ b/src/commands/developer/eval.ts @@ -0,0 +1,134 @@ +/** + * Eval Command + * Execute JavaScript code (Owner only) + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, + codeBlock, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +// Tokens and sensitive patterns to filter +const SENSITIVE_PATTERNS = [ + /token/gi, + /secret/gi, + /password/gi, + /api[_-]?key/gi, + /auth/gi, + /credential/gi, +]; + +export const evalCommand: Command = { + data: new SlashCommandBuilder() + .setName('eval') + .setDescription('Execute JavaScript code (Owner only)') + .addStringOption((opt) => + opt + .setName('code') + .setDescription('The code to execute') + .setRequired(true) + ) + .addBooleanOption((opt) => + opt + .setName('silent') + .setDescription('Hide the output') + .setRequired(false) + ) + .addBooleanOption((opt) => + opt + .setName('async') + .setDescription('Wrap code in async function') + .setRequired(false) + ), + + permission: PermissionLevel.Owner, + cooldown: 0, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const code = interaction.options.getString('code', true); + const silent = interaction.options.getBoolean('silent') ?? false; + const isAsync = interaction.options.getBoolean('async') ?? false; + + // Check for sensitive patterns in code + for (const pattern of SENSITIVE_PATTERNS) { + if (pattern.test(code)) { + await interaction.reply({ + content: 'โŒ Code contains potentially sensitive patterns.', + ephemeral: true, + }); + return; + } + } + + await interaction.deferReply({ ephemeral: silent }); + + const startTime = performance.now(); + let result: unknown; + let error: Error | null = null; + + try { + // Create evaluation context + const context = { + client, + interaction, + guild: interaction.guild, + channel: interaction.channel, + user: interaction.user, + member: interaction.member, + }; + + // Execute code + const codeToRun = isAsync ? `(async () => { ${code} })()` : code; + + // Using Function constructor for evaluation + const fn = new Function(...Object.keys(context), `return ${codeToRun}`); + result = await fn(...Object.values(context)); + } catch (e) { + error = e instanceof Error ? e : new Error(String(e)); + } + + const endTime = performance.now(); + const executionTime = (endTime - startTime).toFixed(2); + + // Format result + let output: string; + if (error) { + output = `${error.name}: ${error.message}`; + } else { + output = typeof result === 'string' ? result : Deno.inspect(result, { + depth: 2, + colors: false, + }); + } + + // Truncate if too long + if (output.length > 1900) { + output = output.substring(0, 1900) + '\n... (truncated)'; + } + + // Filter sensitive data from output + for (const pattern of SENSITIVE_PATTERNS) { + output = output.replace(pattern, '[REDACTED]'); + } + + const embed = new EmbedBuilder() + .setColor(error ? 0xED4245 : 0x57F287) + .setTitle(error ? 'โŒ Evaluation Error' : 'โœ… Evaluation Result') + .addFields( + { name: '๐Ÿ“ฅ Input', value: codeBlock('js', code.substring(0, 1000)) }, + { name: '๐Ÿ“ค Output', value: codeBlock('js', output) }, + { name: 'โฑ๏ธ Execution Time', value: `${executionTime}ms`, inline: true }, + { name: '๐Ÿ“Š Type', value: `\`${typeof result}\``, inline: true } + ) + .setFooter({ text: `Executed by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + }, +}; diff --git a/src/commands/developer/reload.ts b/src/commands/developer/reload.ts new file mode 100644 index 0000000..e0470d1 --- /dev/null +++ b/src/commands/developer/reload.ts @@ -0,0 +1,119 @@ +/** + * Reload Command + * Reload bot commands (owner only) + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + REST, + Routes, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const reloadCommand: Command = { + data: new SlashCommandBuilder() + .setName('reload') + .setDescription('Reload bot commands') + .addStringOption((option) => + option + .setName('scope') + .setDescription('Where to reload commands') + .setRequired(false) + .addChoices( + { name: 'Guild', value: 'guild' }, + { name: 'Global', value: 'global' } + ) + ), + + permission: PermissionLevel.Owner, + cooldown: 30, + ownerOnly: true, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const scope = interaction.options.getString('scope') ?? 'guild'; + + await interaction.deferReply({ ephemeral: true }); + + const token = Deno.env.get('DISCORD_TOKEN'); + if (!token || !client.user) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription('Missing token or client user.'), + ], + }); + return; + } + + const rest = new REST({ version: '10' }).setToken(token); + const commands = client.commands.map((cmd) => cmd.data.toJSON()); + + try { + if (scope === 'global') { + await rest.put(Routes.applicationCommands(client.user.id), { + body: commands, + }); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Commands Reloaded') + .setDescription( + `Successfully reloaded **${commands.length}** commands globally.\n\n` + + '**Note:** Global commands may take up to 1 hour to update.' + ), + ], + }); + } else { + if (!client.config.guild.id) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription('No guild ID configured.'), + ], + }); + return; + } + + await rest.put( + Routes.applicationGuildCommands(client.user.id, client.config.guild.id), + { body: commands } + ); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Commands Reloaded') + .setDescription( + `Successfully reloaded **${commands.length}** commands to guild.` + ), + ], + }); + } + + client.logger.info(`Commands reloaded (${scope}) by ${interaction.user.tag}`); + } catch (error) { + client.logger.error('Failed to reload commands', error); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription('Failed to reload commands. Check the logs for details.'), + ], + }); + } + }, +}; diff --git a/src/commands/developer/shell.ts b/src/commands/developer/shell.ts new file mode 100644 index 0000000..7800226 --- /dev/null +++ b/src/commands/developer/shell.ts @@ -0,0 +1,167 @@ +/** + * Shell Command + * Execute shell commands (Owner only, with restrictions) + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, + codeBlock, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +// Allowed commands whitelist +const ALLOWED_COMMANDS = [ + 'ls', + 'pwd', + 'whoami', + 'date', + 'uptime', + 'df', + 'free', + 'cat', + 'head', + 'tail', + 'wc', + 'echo', + 'deno', +]; + +// Blocked patterns +const BLOCKED_PATTERNS = [ + /rm\s/i, + /sudo/i, + /chmod/i, + /chown/i, + /mv\s/i, + /cp\s/i, + /wget/i, + /curl/i, + /apt/i, + /yum/i, + /dnf/i, + /pacman/i, + /pip/i, + /npm\s+install/i, + /yarn\s+add/i, + />\s*\//, // Redirect to root + /\|\s*sh/i, + /\|\s*bash/i, + /eval/i, + /exec/i, + /\$\(/, // Command substitution + /`/, // Backtick substitution +]; + +export const shellCommand: Command = { + data: new SlashCommandBuilder() + .setName('shell') + .setDescription('Execute shell commands (Owner only)') + .addStringOption((opt) => + opt + .setName('command') + .setDescription('The command to execute') + .setRequired(true) + ) + .addIntegerOption((opt) => + opt + .setName('timeout') + .setDescription('Timeout in seconds (default: 10)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(30) + ), + + permission: PermissionLevel.Owner, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const command = interaction.options.getString('command', true); + const timeout = interaction.options.getInteger('timeout') ?? 10; + + // Extract base command + const baseCommand = command.split(/\s+/)[0].toLowerCase(); + + // Check if command is allowed + if (!ALLOWED_COMMANDS.includes(baseCommand)) { + await interaction.reply({ + content: `โŒ Command \`${baseCommand}\` is not in the whitelist.\n**Allowed:** ${ALLOWED_COMMANDS.join(', ')}`, + ephemeral: true, + }); + return; + } + + // Check for blocked patterns + for (const pattern of BLOCKED_PATTERNS) { + if (pattern.test(command)) { + await interaction.reply({ + content: 'โŒ Command contains blocked patterns.', + ephemeral: true, + }); + return; + } + } + + await interaction.deferReply({ ephemeral: true }); + + const startTime = performance.now(); + + try { + // Create command with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout * 1000); + + const process = new Deno.Command('sh', { + args: ['-c', command], + stdout: 'piped', + stderr: 'piped', + signal: controller.signal, + }); + + const { code, stdout, stderr } = await process.output(); + clearTimeout(timeoutId); + + const endTime = performance.now(); + const executionTime = (endTime - startTime).toFixed(2); + + const stdoutText = new TextDecoder().decode(stdout); + const stderrText = new TextDecoder().decode(stderr); + + let output = stdoutText || stderrText || '(no output)'; + if (output.length > 1800) { + output = output.substring(0, 1800) + '\n... (truncated)'; + } + + const embed = new EmbedBuilder() + .setColor(code === 0 ? 0x57F287 : 0xED4245) + .setTitle(code === 0 ? 'โœ… Command Executed' : 'โŒ Command Failed') + .addFields( + { name: '๐Ÿ“ฅ Command', value: codeBlock('bash', command.substring(0, 500)) }, + { name: '๐Ÿ“ค Output', value: codeBlock(output) }, + { name: '๐Ÿ”ข Exit Code', value: String(code), inline: true }, + { name: 'โฑ๏ธ Time', value: `${executionTime}ms`, inline: true } + ) + .setFooter({ text: `Executed by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + const embed = new EmbedBuilder() + .setColor(0xED4245) + .setTitle('โŒ Execution Error') + .addFields( + { name: '๐Ÿ“ฅ Command', value: codeBlock('bash', command.substring(0, 500)) }, + { name: 'โŒ Error', value: codeBlock(errorMessage.substring(0, 1000)) } + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } + }, +}; diff --git a/src/commands/developer/sync.ts b/src/commands/developer/sync.ts new file mode 100644 index 0000000..a812287 --- /dev/null +++ b/src/commands/developer/sync.ts @@ -0,0 +1,174 @@ +/** + * Sync Command + * Sync slash commands to Discord + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + REST, + Routes, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const syncCommand: Command = { + data: new SlashCommandBuilder() + .setName('sync') + .setDescription('Sync slash commands to Discord') + .addStringOption((option) => + option + .setName('action') + .setDescription('Sync action to perform') + .setRequired(true) + .addChoices( + { name: 'Sync to Guild', value: 'guild' }, + { name: 'Sync Globally', value: 'global' }, + { name: 'Clear Guild Commands', value: 'clear_guild' }, + { name: 'Clear Global Commands', value: 'clear_global' } + ) + ), + + permission: PermissionLevel.Admin, + cooldown: 60, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const action = interaction.options.getString('action', true); + + await interaction.deferReply({ ephemeral: true }); + + const token = Deno.env.get('DISCORD_TOKEN'); + if (!token || !client.user) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription('Missing token or client user.'), + ], + }); + return; + } + + const rest = new REST({ version: '10' }).setToken(token); + const commands = client.commands.map((cmd) => cmd.data.toJSON()); + + try { + switch (action) { + case 'guild': { + if (!client.config.guild.id) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription('No guild ID configured.'), + ], + }); + return; + } + + await rest.put( + Routes.applicationGuildCommands(client.user.id, client.config.guild.id), + { body: commands } + ); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Commands Synced') + .setDescription( + `Successfully synced **${commands.length}** commands to guild.` + ), + ], + }); + break; + } + + case 'global': { + await rest.put(Routes.applicationCommands(client.user.id), { + body: commands, + }); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Commands Synced') + .setDescription( + `Successfully synced **${commands.length}** commands globally.\n\n` + + '**Note:** Global commands may take up to 1 hour to update.' + ), + ], + }); + break; + } + + case 'clear_guild': { + if (!client.config.guild.id) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription('No guild ID configured.'), + ], + }); + return; + } + + await rest.put( + Routes.applicationGuildCommands(client.user.id, client.config.guild.id), + { body: [] } + ); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Commands Cleared') + .setDescription('Successfully cleared all guild commands.'), + ], + }); + break; + } + + case 'clear_global': { + await rest.put(Routes.applicationCommands(client.user.id), { + body: [], + }); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Commands Cleared') + .setDescription( + 'Successfully cleared all global commands.\n\n' + + '**Note:** Changes may take up to 1 hour to propagate.' + ), + ], + }); + break; + } + } + + client.logger.info(`Commands ${action} by ${interaction.user.tag}`); + } catch (error) { + client.logger.error(`Failed to ${action} commands`, error); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription(`Failed to ${action} commands. Check the logs for details.`), + ], + }); + } + }, +}; diff --git a/src/commands/family/adopt.ts b/src/commands/family/adopt.ts new file mode 100644 index 0000000..30cebad --- /dev/null +++ b/src/commands/family/adopt.ts @@ -0,0 +1,192 @@ +/** + * Adopt Command + * Adopt another user as your child in the family system + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + ComponentType, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { FamilyRepository } from '../../database/index.ts'; + +export const adoptCommand: Command = { + data: new SlashCommandBuilder() + .setName('adopt') + .setDescription('Adopt another user as your child') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user you want to adopt') + .setRequired(true) + ), + + permission: PermissionLevel.User, + cooldown: 30, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const targetUser = interaction.options.getUser('user', true); + const repo = new FamilyRepository(client.database); + + // Validation checks + if (targetUser.id === interaction.user.id) { + await interaction.reply({ + content: "โŒ You can't adopt yourself!", + ephemeral: true, + }); + return; + } + + if (targetUser.bot) { + await interaction.reply({ + content: "โŒ You can't adopt a bot!", + ephemeral: true, + }); + return; + } + + // Check if target already has a parent + const targetFamily = await repo.getFamily(targetUser.id); + if (targetFamily?.parentId) { + const parent = await client.users.fetch(targetFamily.parentId).catch(() => null); + await interaction.reply({ + content: `โŒ ${targetUser.tag} already has a parent (${parent?.tag ?? 'Unknown'}).`, + ephemeral: true, + }); + return; + } + + // Check if user is trying to adopt their parent + const userFamily = await repo.getFamily(interaction.user.id); + if (userFamily?.parentId === targetUser.id) { + await interaction.reply({ + content: "โŒ You can't adopt your own parent!", + ephemeral: true, + }); + return; + } + + // Check if user is trying to adopt their spouse + if (userFamily?.partnerId === targetUser.id) { + await interaction.reply({ + content: "โŒ You can't adopt your spouse!", + ephemeral: true, + }); + return; + } + + // Check if target is user's child already + if (userFamily?.children?.includes(targetUser.id)) { + await interaction.reply({ + content: `โŒ ${targetUser.tag} is already your child!`, + ephemeral: true, + }); + return; + } + + // Check max children limit (optional, set to 5) + const maxChildren = 5; + if (userFamily?.children && userFamily.children.length >= maxChildren) { + await interaction.reply({ + content: `โŒ You can't have more than ${maxChildren} children!`, + ephemeral: true, + }); + return; + } + + // Create adoption request + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('adopt:accept') + .setLabel('Accept') + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + new ButtonBuilder() + .setCustomId('adopt:decline') + .setLabel('Decline') + .setStyle(ButtonStyle.Danger) + .setEmoji('โŒ') + ); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ‘จโ€๐Ÿ‘ง Adoption Request') + .setDescription( + `${interaction.user} wants to adopt ${targetUser}!\n\n` + + `${targetUser}, do you accept?` + ) + .setThumbnail(interaction.user.displayAvatarURL()) + .setFooter({ text: 'This request expires in 60 seconds' }) + .setTimestamp(); + + const response = await interaction.reply({ + content: `${targetUser}`, + embeds: [embed], + components: [row], + fetchReply: true, + }); + + // Wait for response + try { + const buttonInteraction = await response.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === targetUser.id, + time: 60000, + }); + + if (buttonInteraction.customId === 'adopt:accept') { + // Process adoption + await repo.setParent(targetUser.id, interaction.user.id); + + const successEmbed = new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('๐Ÿ‘จโ€๐Ÿ‘ง Adoption Complete!') + .setDescription( + `๐ŸŽ‰ Congratulations! ${interaction.user} has adopted ${targetUser}!\n\n` + + `Welcome to the family!` + ) + .setThumbnail(targetUser.displayAvatarURL()) + .setTimestamp(); + + await buttonInteraction.update({ + content: null, + embeds: [successEmbed], + components: [], + }); + } else { + const declineEmbed = new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('๐Ÿ‘จโ€๐Ÿ‘ง Adoption Declined') + .setDescription(`${targetUser} declined the adoption request.`) + .setTimestamp(); + + await buttonInteraction.update({ + content: null, + embeds: [declineEmbed], + components: [], + }); + } + } catch { + // Timeout + const timeoutEmbed = new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('๐Ÿ‘จโ€๐Ÿ‘ง Adoption Request Expired') + .setDescription('The adoption request was not answered in time.') + .setTimestamp(); + + await interaction.editReply({ + content: null, + embeds: [timeoutEmbed], + components: [], + }); + } + }, +}; diff --git a/src/commands/family/divorce.ts b/src/commands/family/divorce.ts new file mode 100644 index 0000000..6f8edb8 --- /dev/null +++ b/src/commands/family/divorce.ts @@ -0,0 +1,125 @@ +/** + * Divorce Command + * End your marriage + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { FamilyRepository } from '../../database/repositories/FamilyRepository.ts'; + +export const divorceCommand: Command = { + data: new SlashCommandBuilder() + .setName('divorce') + .setDescription('End your marriage'), + + permission: PermissionLevel.User, + cooldown: 30, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const familyRepo = new FamilyRepository(client.database); + + // Check if user is married + const partnerId = familyRepo.getPartner(interaction.user.id); + if (!partnerId) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Not Married') + .setDescription("You're not married to anyone!"), + ], + ephemeral: true, + }); + return; + } + + // Confirmation buttons + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('divorce:confirm') + .setLabel('Yes, divorce') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('divorce:cancel') + .setLabel('No, stay married') + .setStyle(ButtonStyle.Secondary) + ); + + const confirmEmbed = new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('๐Ÿ’” Divorce Confirmation') + .setDescription( + `Are you sure you want to divorce <@${partnerId}>?\n\n` + + `This action cannot be undone.` + ) + .setFooter({ text: 'This confirmation expires in 30 seconds' }) + .setTimestamp(); + + const response = await interaction.reply({ + embeds: [confirmEmbed], + components: [row], + fetchReply: true, + }); + + try { + const buttonInteraction = await response.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === interaction.user.id, + time: 30000, + }); + + if (buttonInteraction.customId === 'divorce:confirm') { + // Perform the divorce + familyRepo.removePartner(interaction.user.id); + + const successEmbed = new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('๐Ÿ’” Divorced') + .setDescription( + `${interaction.user} and <@${partnerId}> are no longer married.\n\n` + + `Sometimes things just don't work out... ๐Ÿ˜ข` + ) + .setTimestamp(); + + await buttonInteraction.update({ + embeds: [successEmbed], + components: [], + }); + } else { + const cancelEmbed = new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('๐Ÿ’• Marriage Saved') + .setDescription(`You decided to stay married to <@${partnerId}>. Love wins! ๐Ÿ’–`) + .setTimestamp(); + + await buttonInteraction.update({ + embeds: [cancelEmbed], + components: [], + }); + } + } catch { + // Timeout + const timeoutEmbed = new EmbedBuilder() + .setColor(client.config.colors.info) + .setTitle('โฐ Confirmation Expired') + .setDescription('The divorce confirmation has expired. Your marriage remains intact.') + .setTimestamp(); + + await interaction.editReply({ + embeds: [timeoutEmbed], + components: [], + }); + } + }, +}; diff --git a/src/commands/family/marry.ts b/src/commands/family/marry.ts new file mode 100644 index 0000000..135e3c1 --- /dev/null +++ b/src/commands/family/marry.ts @@ -0,0 +1,196 @@ +/** + * Marry Command + * Propose marriage to another user + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { FamilyRepository } from '../../database/repositories/FamilyRepository.ts'; + +export const marryCommand: Command = { + data: new SlashCommandBuilder() + .setName('marry') + .setDescription('Propose marriage to another user') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user you want to marry') + .setRequired(true) + ), + + permission: PermissionLevel.User, + cooldown: 30, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const familyRepo = new FamilyRepository(client.database); + const targetUser = interaction.options.getUser('user', true); + + // Validation checks + if (targetUser.id === interaction.user.id) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Invalid Target') + .setDescription("You can't marry yourself!"), + ], + ephemeral: true, + }); + return; + } + + if (targetUser.bot) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Invalid Target') + .setDescription("You can't marry a bot!"), + ], + ephemeral: true, + }); + return; + } + + // Check if proposer is already married + if (familyRepo.hasPartner(interaction.user.id)) { + const partnerId = familyRepo.getPartner(interaction.user.id); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Already Married') + .setDescription(`You're already married to <@${partnerId}>! Divorce first if you want to marry someone else.`), + ], + ephemeral: true, + }); + return; + } + + // Check if target is already married + if (familyRepo.hasPartner(targetUser.id)) { + const partnerId = familyRepo.getPartner(targetUser.id); + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Already Married') + .setDescription(`${targetUser} is already married to <@${partnerId}>!`), + ], + ephemeral: true, + }); + return; + } + + // Check if they are parent/child + if (familyRepo.isChildOf(interaction.user.id, targetUser.id) || + familyRepo.isChildOf(targetUser.id, interaction.user.id)) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Invalid Relationship') + .setDescription("You can't marry your parent or child!"), + ], + ephemeral: true, + }); + return; + } + + // Create proposal buttons + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('marry:accept') + .setLabel('Accept ๐Ÿ’') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId('marry:deny') + .setLabel('Decline ๐Ÿ’”') + .setStyle(ButtonStyle.Danger) + ); + + const proposalEmbed = new EmbedBuilder() + .setColor(0xff69b4) // Pink + .setTitle('๐Ÿ’ Marriage Proposal') + .setDescription( + `${interaction.user} has proposed to ${targetUser}!\n\n` + + `${targetUser}, do you accept this proposal?` + ) + .setThumbnail(interaction.user.displayAvatarURL()) + .setFooter({ text: 'This proposal expires in 60 seconds' }) + .setTimestamp(); + + const response = await interaction.reply({ + content: `${targetUser}`, + embeds: [proposalEmbed], + components: [row], + fetchReply: true, + }); + + // Wait for response + try { + const buttonInteraction = await response.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === targetUser.id, + time: 60000, + }); + + if (buttonInteraction.customId === 'marry:accept') { + // Perform the marriage + familyRepo.setPartner(interaction.user.id, targetUser.id); + + const successEmbed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('๐Ÿ’’ Married!') + .setDescription( + `๐ŸŽ‰ Congratulations! ${interaction.user} and ${targetUser} are now married! ๐ŸŽ‰\n\n` + + `May your love last forever! ๐Ÿ’•` + ) + .setImage('https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif') + .setTimestamp(); + + await buttonInteraction.update({ + content: null, + embeds: [successEmbed], + components: [], + }); + } else { + const rejectEmbed = new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('๐Ÿ’” Proposal Declined') + .setDescription(`${targetUser} has declined the proposal from ${interaction.user}.`) + .setTimestamp(); + + await buttonInteraction.update({ + content: null, + embeds: [rejectEmbed], + components: [], + }); + } + } catch { + // Timeout + const timeoutEmbed = new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('โฐ Proposal Expired') + .setDescription(`${targetUser} didn't respond in time. The proposal has expired.`) + .setTimestamp(); + + await interaction.editReply({ + content: null, + embeds: [timeoutEmbed], + components: [], + }); + } + }, +}; diff --git a/src/commands/family/relationship.ts b/src/commands/family/relationship.ts new file mode 100644 index 0000000..569d9f8 --- /dev/null +++ b/src/commands/family/relationship.ts @@ -0,0 +1,114 @@ +/** + * Relationship Command + * View family relationships + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { FamilyRepository } from '../../database/repositories/FamilyRepository.ts'; + +export const relationshipCommand: Command = { + data: new SlashCommandBuilder() + .setName('relationship') + .setDescription('View family relationships') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to view relationships for (defaults to yourself)') + .setRequired(false) + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const familyRepo = new FamilyRepository(client.database); + const targetUser = interaction.options.getUser('user') ?? interaction.user; + + const family = familyRepo.getOrCreate(targetUser.id); + + // Format partner + const partnerDisplay = family.partnerId + ? `<@${family.partnerId}> ๐Ÿ’` + : '*Nobody*'; + + // Format parent + const parentDisplay = family.parentId + ? `<@${family.parentId}>` + : '*Nobody*'; + + // Format children + let childrenDisplay = '*None*'; + if (family.children.length > 0) { + childrenDisplay = family.children + .map((id) => `<@${id}>`) + .join('\n'); + } + + // Build embed + const embed = new EmbedBuilder() + .setColor(0x1ab968) // Green + .setTitle(`๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ${targetUser.displayName}'s Family`) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { + name: '๐Ÿ’‘ Partner', + value: partnerDisplay, + inline: false, + }, + { + name: '๐Ÿ‘ช Parent', + value: parentDisplay, + inline: false, + }, + { + name: `๐Ÿ‘ถ Children (${family.children.length})`, + value: childrenDisplay, + inline: false, + } + ) + .setFooter({ + text: `Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + // Add family tree visualization if they have relationships + if (family.partnerId || family.parentId || family.children.length > 0) { + let treeVisualization = ''; + + if (family.parentId) { + treeVisualization += `๐Ÿ‘ค Parent\n โ”‚\n`; + } + + treeVisualization += `๐Ÿ‘ค ${targetUser.displayName}`; + + if (family.partnerId) { + treeVisualization += ` โ”€โ”€ ๐Ÿ’ โ”€โ”€ ๐Ÿ‘ค Partner`; + } + + if (family.children.length > 0) { + treeVisualization += `\n โ”‚`; + for (let i = 0; i < family.children.length; i++) { + const isLast = i === family.children.length - 1; + treeVisualization += `\n ${isLast ? 'โ””' : 'โ”œ'}โ”€โ”€ ๐Ÿ‘ถ Child ${i + 1}`; + } + } + + embed.addFields({ + name: '๐ŸŒณ Family Tree', + value: `\`\`\`\n${treeVisualization}\n\`\`\``, + inline: false, + }); + } + + await interaction.reply({ embeds: [embed] }); + }, +}; diff --git a/src/commands/moderation/filter.ts b/src/commands/moderation/filter.ts new file mode 100644 index 0000000..5a0bfc6 --- /dev/null +++ b/src/commands/moderation/filter.ts @@ -0,0 +1,360 @@ +/** + * Filter Command + * Manage channel message filters + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + ChannelType, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { FilterRepository, type FilterType } from '../../database/repositories/FilterRepository.ts'; + +export const filterCommand: Command = { + data: new SlashCommandBuilder() + .setName('filter') + .setDescription('Manage channel message filters') + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a filter to a channel') + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to filter') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true) + ) + .addStringOption((opt) => + opt + .setName('type') + .setDescription('Type of filter') + .setRequired(true) + .addChoices( + { name: 'Links', value: 'links' }, + { name: 'Images', value: 'images' }, + { name: 'Attachments', value: 'attachments' }, + { name: 'Discord Invites', value: 'invites' }, + { name: 'Custom Pattern', value: 'custom' } + ) + ) + .addStringOption((opt) => + opt + .setName('pattern') + .setDescription('Regex pattern (for custom type)') + .setRequired(false) + ) + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a filter') + .addStringOption((opt) => + opt.setName('id').setDescription('Filter ID').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List filters') + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to list filters for (optional)') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false) + ) + ) + .addSubcommand((sub) => + sub + .setName('toggle') + .setDescription('Enable/disable a filter') + .addStringOption((opt) => + opt.setName('id').setDescription('Filter ID').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('bypass') + .setDescription('Add a role that bypasses a filter') + .addStringOption((opt) => + opt.setName('id').setDescription('Filter ID').setRequired(true) + ) + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to add').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('unbypass') + .setDescription('Remove a bypass role from a filter') + .addStringOption((opt) => + opt.setName('id').setDescription('Filter ID').setRequired(true) + ) + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to remove').setRequired(true) + ) + ), + + permission: PermissionLevel.Admin, + cooldown: 3, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + const repo = new FilterRepository(client.database); + + switch (subcommand) { + case 'add': + await handleAdd(interaction, client, repo); + break; + case 'remove': + await handleRemove(interaction, client, repo); + break; + case 'list': + await handleList(interaction, client, repo); + break; + case 'toggle': + await handleToggle(interaction, client, repo); + break; + case 'bypass': + await handleBypass(interaction, client, repo); + break; + case 'unbypass': + await handleUnbypass(interaction, client, repo); + break; + } + }, +}; + +/** + * Handle adding a filter + */ +async function handleAdd( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: FilterRepository +): Promise { + const channel = interaction.options.getChannel('channel', true); + const filterType = interaction.options.getString('type', true) as FilterType; + const pattern = interaction.options.getString('pattern'); + + // Validate custom pattern + if (filterType === 'custom') { + if (!pattern) { + await interaction.reply({ + content: 'โŒ Custom filter type requires a regex pattern.', + ephemeral: true, + }); + return; + } + + // Test if pattern is valid regex + try { + new RegExp(pattern); + } catch { + await interaction.reply({ + content: 'โŒ Invalid regex pattern.', + ephemeral: true, + }); + return; + } + } + + const filter = await repo.createFilter({ + channelId: channel.id, + filterType, + pattern: pattern ?? undefined, + allowedRoles: [], + isEnabled: true, + createdBy: interaction.user.id, + }); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Filter Created') + .addFields( + { name: 'Channel', value: `<#${channel.id}>`, inline: true }, + { name: 'Type', value: filterType, inline: true }, + { name: 'ID', value: `\`${filter.id}\``, inline: true } + ) + .setFooter({ text: `Created by ${interaction.user.tag}` }) + .setTimestamp(), + ], + }); +} + +/** + * Handle removing a filter + */ +async function handleRemove( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: FilterRepository +): Promise { + const id = interaction.options.getString('id', true); + const deleted = await repo.deleteFilter(id); + + if (!deleted) { + await interaction.reply({ + content: 'โŒ Filter not found.', + ephemeral: true, + }); + return; + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Filter Removed') + .setDescription(`Filter \`${id}\` has been deleted.`) + .setTimestamp(), + ], + }); +} + +/** + * Handle listing filters + */ +async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: FilterRepository +): Promise { + const channel = interaction.options.getChannel('channel'); + + let filters; + if (channel) { + filters = await repo.getChannelFilters(channel.id); + } else { + filters = await repo.getAllEnabled(); + } + + if (filters.length === 0) { + await interaction.reply({ + content: `๐Ÿ“ญ No filters found${channel ? ` for ${channel}` : ''}.`, + ephemeral: true, + }); + return; + } + + const list = filters.map((f) => { + const status = f.isEnabled ? '๐ŸŸข' : '๐Ÿ”ด'; + const bypasses = f.allowedRoles.length > 0 + ? ` (${f.allowedRoles.length} bypass roles)` + : ''; + return `${status} \`${f.id}\` - <#${f.channelId}> - **${f.filterType}**${bypasses}`; + }).join('\n'); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ›ก๏ธ ${channel ? `Filters for #${channel.name}` : 'All Filters'}`) + .setDescription(list) + .setFooter({ text: `${filters.length} filter(s)` }) + .setTimestamp(), + ], + }); +} + +/** + * Handle toggling a filter + */ +async function handleToggle( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: FilterRepository +): Promise { + const id = interaction.options.getString('id', true); + const filter = await repo.toggleFilter(id); + + if (!filter) { + await interaction.reply({ + content: 'โŒ Filter not found.', + ephemeral: true, + }); + return; + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(filter.isEnabled ? client.config.colors.success : client.config.colors.warning) + .setTitle(`${filter.isEnabled ? '๐ŸŸข' : '๐Ÿ”ด'} Filter ${filter.isEnabled ? 'Enabled' : 'Disabled'}`) + .setDescription(`Filter \`${id}\` has been ${filter.isEnabled ? 'enabled' : 'disabled'}.`) + .setTimestamp(), + ], + }); +} + +/** + * Handle adding a bypass role + */ +async function handleBypass( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: FilterRepository +): Promise { + const id = interaction.options.getString('id', true); + const role = interaction.options.getRole('role', true); + + const filter = await repo.addAllowedRole(id, role.id); + + if (!filter) { + await interaction.reply({ + content: 'โŒ Filter not found.', + ephemeral: true, + }); + return; + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Bypass Role Added') + .setDescription(`${role} can now bypass filter \`${id}\`.`) + .setTimestamp(), + ], + }); +} + +/** + * Handle removing a bypass role + */ +async function handleUnbypass( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: FilterRepository +): Promise { + const id = interaction.options.getString('id', true); + const role = interaction.options.getRole('role', true); + + const filter = await repo.removeAllowedRole(id, role.id); + + if (!filter) { + await interaction.reply({ + content: 'โŒ Filter not found.', + ephemeral: true, + }); + return; + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Bypass Role Removed') + .setDescription(`${role} can no longer bypass filter \`${id}\`.`) + .setTimestamp(), + ], + }); +} diff --git a/src/commands/moderation/purge.ts b/src/commands/moderation/purge.ts new file mode 100644 index 0000000..434ae6c --- /dev/null +++ b/src/commands/moderation/purge.ts @@ -0,0 +1,180 @@ +/** + * Purge Command + * Delete multiple messages at once + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionFlagsBits, + type ChatInputCommandInteraction, + type TextChannel, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const purgeCommand: Command = { + data: new SlashCommandBuilder() + .setName('purge') + .setDescription('Delete multiple messages at once') + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('Number of messages to delete (1-100)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100) + ) + .addUserOption((option) => + option + .setName('user') + .setDescription('Only delete messages from this user') + .setRequired(false) + ) + .addStringOption((option) => + option + .setName('contains') + .setDescription('Only delete messages containing this text') + .setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + + permission: PermissionLevel.Officer, + cooldown: 5, + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + + if (!interaction.guild || !interaction.channel) { + await interaction.reply({ + content: 'This command can only be used in a server.', + ephemeral: true, + }); + return; + } + + const channel = interaction.channel as TextChannel; + const amount = interaction.options.getInteger('amount', true); + const targetUser = interaction.options.getUser('user'); + const containsText = interaction.options.getString('contains'); + + // Check bot permissions + const botMember = interaction.guild.members.me; + if (!botMember?.permissions.has(PermissionFlagsBits.ManageMessages)) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Missing Permissions') + .setDescription('I need the **Manage Messages** permission to delete messages.'), + ], + ephemeral: true, + }); + return; + } + + // Defer reply since this might take a moment + await interaction.deferReply({ ephemeral: true }); + + try { + // Fetch messages + const messages = await channel.messages.fetch({ limit: 100 }); + + // Filter messages + let filtered = messages.filter((msg) => { + // Can't delete messages older than 14 days + const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; + if (msg.createdTimestamp < twoWeeksAgo) return false; + + // Filter by user if specified + if (targetUser && msg.author.id !== targetUser.id) return false; + + // Filter by content if specified + if (containsText && !msg.content.toLowerCase().includes(containsText.toLowerCase())) { + return false; + } + + return true; + }); + + // Limit to requested amount + filtered = filtered.first(amount); + + if (filtered.length === 0) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('โš ๏ธ No Messages Found') + .setDescription( + 'No messages matching your criteria were found.\n\n' + + '**Note:** Messages older than 14 days cannot be bulk deleted.' + ), + ], + }); + return; + } + + // Delete messages + const deleted = await channel.bulkDelete(filtered, true); + + // Build result embed + const embed = new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('๐Ÿ—‘๏ธ Messages Purged') + .setDescription(`Successfully deleted **${deleted.size}** message${deleted.size !== 1 ? 's' : ''}.`) + .addFields( + { name: 'Channel', value: `${channel}`, inline: true }, + { name: 'Requested', value: String(amount), inline: true }, + { name: 'Deleted', value: String(deleted.size), inline: true } + ) + .setFooter({ + text: `Purged by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + if (targetUser) { + embed.addFields({ name: 'User Filter', value: `${targetUser}`, inline: true }); + } + + if (containsText) { + embed.addFields({ name: 'Content Filter', value: `"${containsText}"`, inline: true }); + } + + await interaction.editReply({ embeds: [embed] }); + + // Log to development channel if configured + if (client.channels_cache.developmentLogs) { + const logEmbed = new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('๐Ÿ“‹ Purge Log') + .addFields( + { name: 'Moderator', value: `${interaction.user} (${interaction.user.id})`, inline: true }, + { name: 'Channel', value: `${channel} (${channel.id})`, inline: true }, + { name: 'Messages Deleted', value: String(deleted.size), inline: true } + ) + .setTimestamp(); + + if (targetUser) { + logEmbed.addFields({ name: 'Target User', value: `${targetUser} (${targetUser.id})`, inline: true }); + } + + await client.channels_cache.developmentLogs.send({ embeds: [logEmbed] }); + } + } catch (error) { + client.logger.error('Error purging messages', error); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Error') + .setDescription('An error occurred while trying to delete messages.'), + ], + }); + } + }, +}; diff --git a/src/commands/qotd/index.ts b/src/commands/qotd/index.ts new file mode 100644 index 0000000..0fff1d0 --- /dev/null +++ b/src/commands/qotd/index.ts @@ -0,0 +1,611 @@ +/** + * QOTD Command Module + * Advanced Question of the Day management system + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + type ChatInputCommandInteraction, + ComponentType, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { QOTDRepository } from '../../database/repositories/QOTDRepository.ts'; + +export const qotdCommand: Command = { + data: new SlashCommandBuilder() + .setName('qotd') + .setDescription('Question of the Day management') + // User commands + .addSubcommand((sub) => + sub + .setName('answer') + .setDescription('Answer the current question of the day') + ) + .addSubcommand((sub) => + sub + .setName('suggest') + .setDescription('Suggest a question for QOTD') + .addStringOption((opt) => + opt.setName('question').setDescription('Your question suggestion').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('streak') + .setDescription('View your QOTD answer streak') + .addUserOption((opt) => + opt.setName('user').setDescription('User to check').setRequired(false) + ) + ) + // Staff commands + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a question to the queue') + .addStringOption((opt) => + opt.setName('question').setDescription('The question to add').setRequired(true) + ) + .addStringOption((opt) => + opt + .setName('category') + .setDescription('Question category') + .addChoices( + { name: 'General', value: 'general' }, + { name: 'Gaming', value: 'gaming' }, + { name: 'Fun', value: 'fun' }, + { name: 'Deep', value: 'deep' }, + { name: 'Would You Rather', value: 'wyr' } + ) + ) + ) + .addSubcommand((sub) => + sub + .setName('queue') + .setDescription('View the question queue') + .addIntegerOption((opt) => + opt.setName('page').setDescription('Page number').setMinValue(1) + ) + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a question from the queue') + .addStringOption((opt) => + opt.setName('id').setDescription('Question ID').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('send') + .setDescription('Send the QOTD now') + .addStringOption((opt) => + opt.setName('id').setDescription('Specific question ID (optional)') + ) + ) + .addSubcommand((sub) => + sub + .setName('stats') + .setDescription('View QOTD statistics') + ) + .addSubcommand((sub) => + sub + .setName('leaderboard') + .setDescription('View QOTD participation leaderboard') + ) + // Admin commands + .addSubcommand((sub) => + sub + .setName('config') + .setDescription('Configure QOTD settings') + .addChannelOption((opt) => + opt.setName('channel').setDescription('QOTD channel') + ) + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to ping') + ) + .addStringOption((opt) => + opt.setName('time').setDescription('Time to send (HH:MM format)') + ) + .addBooleanOption((opt) => + opt.setName('enabled').setDescription('Enable/disable QOTD') + ) + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + const repo = new QOTDRepository(client.database); + + switch (subcommand) { + case 'answer': + await handleAnswer(interaction, client, repo); + break; + case 'suggest': + await handleSuggest(interaction, client, repo); + break; + case 'streak': + await handleStreak(interaction, client, repo); + break; + case 'add': + await handleAdd(interaction, client, repo); + break; + case 'queue': + await handleQueue(interaction, client, repo); + break; + case 'remove': + await handleRemove(interaction, client, repo); + break; + case 'send': + await handleSend(interaction, client, repo); + break; + case 'stats': + await handleStats(interaction, client, repo); + break; + case 'leaderboard': + await handleLeaderboard(interaction, client, repo); + break; + case 'config': + await handleConfig(interaction, client, repo); + break; + } + }, +}; + +async function handleAnswer( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const currentQuestion = await repo.getCurrentQuestion(); + + if (!currentQuestion) { + await interaction.reply({ + content: 'โŒ There is no active question of the day right now.', + ephemeral: true, + }); + return; + } + + // Show answer modal + const modal = new ModalBuilder() + .setCustomId('qotd:answer') + .setTitle('Answer QOTD') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('answer') + .setLabel(currentQuestion.question.substring(0, 45)) + .setPlaceholder('Type your answer here...') + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(1000) + .setRequired(true) + ) + ); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ + time: 300000, + filter: (i) => i.customId === 'qotd:answer' && i.user.id === interaction.user.id, + }); + + const answer = modalInteraction.fields.getTextInputValue('answer'); + + // Record answer + await repo.recordAnswer(interaction.user.id, currentQuestion.id, answer); + + // Update streak + await updateStreak(interaction.user.id, client); + + await modalInteraction.reply({ + content: 'โœ… Your answer has been recorded! Keep up your streak by answering daily.', + ephemeral: true, + }); + } catch { + // Modal timed out + } +} + +async function handleSuggest( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const question = interaction.options.getString('question', true); + + // Store suggestion + const suggestions = client.database.get>('qotd_suggestions') ?? []; + + const suggestion = { + id: `qs_${Date.now()}`, + question, + userId: interaction.user.id, + createdAt: Date.now(), + status: 'pending' as const, + }; + + suggestions.push(suggestion); + client.database.set('qotd_suggestions', suggestions); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x57f287) + .setTitle('โœ… Question Suggested') + .setDescription(`Your question has been submitted for review!\n\n**Question:** ${question}`) + .setFooter({ text: `Suggestion ID: ${suggestion.id}` }) + .setTimestamp(), + ], + ephemeral: true, + }); +} + +async function handleStreak( + interaction: ChatInputCommandInteraction, + client: EllyClient, + _repo: QOTDRepository +): Promise { + const targetUser = interaction.options.getUser('user') ?? interaction.user; + + const streaks = client.database.get>('qotd_streaks') ?? {}; + const userStreak = streaks[targetUser.id] ?? { current: 0, best: 0, lastAnswer: 0 }; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ”ฅ ${targetUser.username}'s QOTD Streak`) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: '๐Ÿ”ฅ Current Streak', value: `${userStreak.current} days`, inline: true }, + { name: '๐Ÿ† Best Streak', value: `${userStreak.best} days`, inline: true }, + { name: '๐Ÿ“… Last Answer', value: userStreak.lastAnswer ? `` : 'Never', inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleAdd( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to add questions.', + ephemeral: true, + }); + return; + } + + const question = interaction.options.getString('question', true); + const category = interaction.options.getString('category') ?? 'general'; + + const added = await repo.addQuestion(question, interaction.user.id, category); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x57f287) + .setTitle('โœ… Question Added') + .addFields( + { name: 'Question', value: question }, + { name: 'Category', value: category, inline: true }, + { name: 'ID', value: `\`${added.id}\``, inline: true } + ) + .setTimestamp(), + ], + ephemeral: true, + }); +} + +async function handleQueue( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to view the queue.', + ephemeral: true, + }); + return; + } + + const page = interaction.options.getInteger('page') ?? 1; + const questions = await repo.getUnusedQuestions(); + + if (questions.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ The question queue is empty.', + ephemeral: true, + }); + return; + } + + const perPage = 10; + const totalPages = Math.ceil(questions.length / perPage); + const currentPage = Math.min(page, totalPages); + const startIndex = (currentPage - 1) * perPage; + const pageQuestions = questions.slice(startIndex, startIndex + perPage); + + const list = pageQuestions.map((q, i) => { + const num = startIndex + i + 1; + return `**${num}.** \`${q.id}\` - ${q.question.substring(0, 50)}...`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“‹ QOTD Queue') + .setDescription(list.join('\n')) + .setFooter({ text: `Page ${currentPage}/${totalPages} โ€ข ${questions.length} questions` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleRemove( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to remove questions.', + ephemeral: true, + }); + return; + } + + const id = interaction.options.getString('id', true); + const success = await repo.deleteQuestion(id); + + if (!success) { + await interaction.reply({ + content: 'โŒ Question not found.', + ephemeral: true, + }); + return; + } + + await interaction.reply({ + content: `โœ… Question \`${id}\` has been removed.`, + ephemeral: true, + }); +} + +async function handleSend( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to send QOTD.', + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + const specificId = interaction.options.getString('id'); + let question; + + if (specificId) { + question = await repo.getQuestionById(specificId); + } else { + question = await repo.getRandomUnusedQuestion(); + } + + if (!question) { + await interaction.editReply({ + content: 'โŒ No questions available to send.', + }); + return; + } + + // Get QOTD config + const config = await repo.getConfig(); + const channel = config?.channelId ? await client.channels.fetch(config.channelId) : null; + + if (!channel?.isTextBased()) { + await interaction.editReply({ + content: 'โŒ QOTD channel not configured. Use `/qotd config` to set it up.', + }); + return; + } + + // Mark as used + await repo.markAsUsed(question.id); + + // Send QOTD + const embed = new EmbedBuilder() + .setColor(0xf1c40f) + .setTitle('โ“ Question of the Day') + .setDescription(question.question) + .addFields( + { name: 'Category', value: question.category ?? 'General', inline: true }, + { name: 'Added By', value: `<@${question.addedBy}>`, inline: true } + ) + .setFooter({ text: 'Use /qotd answer to submit your answer!' }) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('qotd:quick_answer') + .setLabel('Answer') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐Ÿ’ฌ') + ); + + const rolePing = config?.roleId ? `<@&${config.roleId}>` : ''; + await channel.send({ + content: rolePing, + embeds: [embed], + components: [row], + }); + + await interaction.editReply({ + content: 'โœ… QOTD has been sent!', + }); +} + +async function handleStats( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const questions = await repo.getUnusedQuestions(); + const usedQuestions = await repo.getUsedQuestions(); + const config = await repo.getConfig(); + + const answers = client.database.get>('qotd_answers') ?? []; + const uniqueParticipants = new Set(answers.map((a) => a.oderId)).size; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“Š QOTD Statistics') + .addFields( + { name: '๐Ÿ“‹ Questions in Queue', value: String(questions.length), inline: true }, + { name: 'โœ… Questions Used', value: String(usedQuestions.length), inline: true }, + { name: '๐Ÿ‘ฅ Unique Participants', value: String(uniqueParticipants), inline: true }, + { name: '๐Ÿ’ฌ Total Answers', value: String(answers.length), inline: true }, + { name: '๐Ÿ”” Status', value: config?.isEnabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: 'โฐ Scheduled Time', value: config?.scheduledTime ?? 'Not set', inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleLeaderboard( + interaction: ChatInputCommandInteraction, + client: EllyClient, + _repo: QOTDRepository +): Promise { + const streaks = client.database.get>('qotd_streaks') ?? {}; + + const leaderboard = Object.entries(streaks) + .sort((a, b) => b[1].current - a[1].current) + .slice(0, 10); + + if (leaderboard.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ No one has answered any questions yet!', + ephemeral: true, + }); + return; + } + + const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; + const list = leaderboard.map(([oderId, data], i) => { + const medal = medals[i] ?? `**${i + 1}.**`; + return `${medal} <@${oderId}> - ๐Ÿ”ฅ ${data.current} day streak (Best: ${data.best})`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ† QOTD Leaderboard') + .setDescription(list.join('\n')) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleConfig( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: QOTDRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Admin)) { + await interaction.reply({ + content: 'โŒ You need Admin permission to configure QOTD.', + ephemeral: true, + }); + return; + } + + const channel = interaction.options.getChannel('channel'); + const role = interaction.options.getRole('role'); + const time = interaction.options.getString('time'); + const enabled = interaction.options.getBoolean('enabled'); + + const config = await repo.getConfig() ?? { + channelId: null, + roleId: null, + scheduledTime: '12:00', + isEnabled: false, + }; + + if (channel) config.channelId = channel.id; + if (role) config.roleId = role.id; + if (time) config.scheduledTime = time; + if (enabled !== null) config.isEnabled = enabled; + + await repo.updateConfig(config); + + const embed = new EmbedBuilder() + .setColor(0x57f287) + .setTitle('โš™๏ธ QOTD Configuration Updated') + .addFields( + { name: 'Channel', value: config.channelId ? `<#${config.channelId}>` : 'Not set', inline: true }, + { name: 'Ping Role', value: config.roleId ? `<@&${config.roleId}>` : 'None', inline: true }, + { name: 'Scheduled Time', value: config.scheduledTime ?? '12:00', inline: true }, + { name: 'Status', value: config.isEnabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function updateStreak(userId: string, client: EllyClient): Promise { + const streaks = client.database.get>('qotd_streaks') ?? {}; + + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + const userStreak = streaks[userId] ?? { current: 0, best: 0, lastAnswer: 0 }; + + // Check if answered within last 48 hours (to maintain streak) + if (now - userStreak.lastAnswer < 2 * oneDayMs) { + // Check if it's a new day + const lastDate = new Date(userStreak.lastAnswer).toDateString(); + const todayDate = new Date(now).toDateString(); + + if (lastDate !== todayDate) { + userStreak.current++; + } + } else { + // Streak broken, start new + userStreak.current = 1; + } + + userStreak.lastAnswer = now; + userStreak.best = Math.max(userStreak.best, userStreak.current); + + streaks[userId] = userStreak; + client.database.set('qotd_streaks', streaks); +} diff --git a/src/commands/statistics/bedwars.ts b/src/commands/statistics/bedwars.ts new file mode 100644 index 0000000..f3ead31 --- /dev/null +++ b/src/commands/statistics/bedwars.ts @@ -0,0 +1,173 @@ +/** + * BedWars Statistics Command + * Displays BedWars stats for a PikaNetwork player + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import type { Interval } from '../../api/pika/types.ts'; + +export const bedwarsCommand: Command = { + data: new SlashCommandBuilder() + .setName('bedwars') + .setDescription('Get BedWars statistics for a PikaNetwork player') + .addStringOption((option) => + option + .setName('username') + .setDescription('Minecraft username') + .setRequired(true) + .setMinLength(3) + .setMaxLength(16) + ) + .addStringOption((option) => + option + .setName('mode') + .setDescription('Game mode') + .setRequired(false) + .addChoices( + { name: 'All Modes', value: 'all_modes' }, + { name: 'Solo', value: 'solo' }, + { name: 'Doubles', value: 'doubles' }, + { name: 'Triples', value: 'triples' }, + { name: 'Quads', value: 'quad' } + ) + ) + .addStringOption((option) => + option + .setName('interval') + .setDescription('Time interval') + .setRequired(false) + .addChoices( + { name: 'Weekly', value: 'weekly' }, + { name: 'Monthly', value: 'monthly' }, + { name: 'Yearly', value: 'yearly' }, + { name: 'Lifetime', value: 'lifetime' } + ) + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const client = interaction.client as EllyClient; + const username = interaction.options.getString('username', true); + const mode = interaction.options.getString('mode') ?? 'all_modes'; + const interval = (interaction.options.getString('interval') ?? 'lifetime') as Interval; + + // Fetch profile and stats + const [profile, stats] = await Promise.all([ + client.pikaAPI.getProfile(username), + client.pikaAPI.getBedWarsStats(username, interval, mode), + ]); + + if (!profile) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Player Not Found') + .setDescription(`Could not find player **${username}** on PikaNetwork.`) + .setFooter({ text: 'Make sure the username is correct.' }), + ], + }); + return; + } + + if (!stats) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Stats Not Found') + .setDescription(`Could not fetch BedWars statistics for **${profile.username}**.`) + .setFooter({ text: 'The player may not have played BedWars.' }), + ], + }); + return; + } + + // Format mode name + const modeNames: Record = { + all_modes: 'All Modes', + solo: 'Solo', + doubles: 'Doubles', + triples: 'Triples', + quad: 'Quads', + }; + + // Get rank color + const rankColors: Record = { + owner: 0xaa0000, + manager: 0xaa0000, + developer: 0xff5555, + admin: 0xff5555, + srmod: 0x00aaaa, + moderator: 0x00aa00, + helper: 0x5555ff, + trial: 0x55ffff, + champion: 0xaa0000, + titan: 0xffaa00, + elite: 0x55ffff, + vip: 0x55ff55, + }; + + let embedColor = client.config.colors.primary; + let rankDisplay = 'Unranked'; + + for (const rank of profile.ranks) { + const rankName = rank.displayName.toLowerCase(); + if (rankColors[rankName]) { + embedColor = rankColors[rankName]; + rankDisplay = rank.displayName; + break; + } + } + + // Build embed + const embed = new EmbedBuilder() + .setColor(embedColor) + .setTitle(`๐Ÿ›๏ธ ${profile.username}'s BedWars Stats`) + .setThumbnail(`https://mc-heads.net/head/${profile.username}/right`) + .setDescription( + `**Rank:** ${rankDisplay}\n` + + `**Level:** ${profile.rank.level}\n` + + `**Clan:** ${profile.clan?.name ?? 'None'}` + ) + .addFields( + // Combat stats + { name: 'โš”๏ธ Kills', value: stats.kills.toLocaleString(), inline: true }, + { name: '๐Ÿ’€ Deaths', value: stats.deaths.toLocaleString(), inline: true }, + { name: '๐Ÿ“Š K/D', value: stats.kdr.toString(), inline: true }, + + // Final stats + { name: '๐Ÿ—ก๏ธ Final Kills', value: stats.finalKills.toLocaleString(), inline: true }, + { name: 'โ˜ ๏ธ Final Deaths', value: stats.finalDeaths.toLocaleString(), inline: true }, + { name: '๐Ÿ“ˆ FKDR', value: stats.fkdr.toString(), inline: true }, + + // Game stats + { name: '๐Ÿ† Wins', value: stats.wins.toLocaleString(), inline: true }, + { name: 'โŒ Losses', value: stats.losses.toLocaleString(), inline: true }, + { name: '๐Ÿ“‰ W/L', value: stats.wlr.toString(), inline: true }, + + // Other stats + { name: '๐Ÿ›๏ธ Beds Destroyed', value: stats.bedsDestroyed.toLocaleString(), inline: true }, + { name: '๐ŸŽฎ Games Played', value: stats.gamesPlayed.toLocaleString(), inline: true }, + { name: '๐Ÿ”ฅ Best Winstreak', value: stats.highestWinstreak.toLocaleString(), inline: true } + ) + .setFooter({ + text: `Mode: ${modeNames[mode]} | Interval: ${interval.charAt(0).toUpperCase() + interval.slice(1)} | Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + }, +}; diff --git a/src/commands/statistics/guild.ts b/src/commands/statistics/guild.ts new file mode 100644 index 0000000..d44eca4 --- /dev/null +++ b/src/commands/statistics/guild.ts @@ -0,0 +1,128 @@ +/** + * Guild Statistics Command + * View PikaNetwork guild information and member stats + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { createPaginator, createPaginatedEmbeds } from '../../utils/pagination.ts'; + +export const guildCommand: Command = { + data: new SlashCommandBuilder() + .setName('guild') + .setDescription('View PikaNetwork guild information') + .addStringOption((option) => + option + .setName('name') + .setDescription('Guild name (defaults to configured guild)') + .setRequired(false) + ), + + permission: PermissionLevel.User, + cooldown: 10, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const guildName = interaction.options.getString('name') ?? client.config.guild.name; + + await interaction.deferReply(); + + // Fetch guild data + const clan = await client.pikaAPI.getClan(guildName); + + if (!clan) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Guild Not Found') + .setDescription(`Could not find guild **${guildName}** on PikaNetwork.`) + .setFooter({ text: 'Make sure the guild name is correct.' }), + ], + }); + return; + } + + // Build main info embed + const mainEmbed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ“œ ${clan.name} | Guild Information`) + .setThumbnail(`https://mc-heads.net/head/${clan.owner.username}/right`) + .addFields( + { name: '๐Ÿ‘‘ Owner', value: clan.owner.username, inline: true }, + { name: '๐Ÿ‘ฅ Members', value: String(clan.members.length), inline: true }, + { name: '๐Ÿ† Trophies', value: clan.currentTrophies.toLocaleString(), inline: true }, + { name: '๐Ÿ“Š Level', value: String(clan.leveling.level), inline: true }, + { name: 'โœจ Experience', value: clan.leveling.exp.toLocaleString(), inline: true }, + { name: '๐Ÿ“… Created', value: formatDate(clan.creationTime), inline: true } + ) + .setFooter({ + text: `Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + // Filter out members with missing user data + const validMembers = clan.members.filter((m) => m?.user?.username); + + // If few members, show them in the main embed + if (validMembers.length <= 10) { + const memberList = validMembers + .map((m) => `โ€ข ${m.user.username}`) + .join('\n'); + + mainEmbed.addFields({ + name: '๐Ÿ“‹ Members', + value: memberList || 'No members', + inline: false, + }); + + await interaction.editReply({ embeds: [mainEmbed] }); + return; + } + + // For larger guilds, create paginated member list + const memberEmbeds = createPaginatedEmbeds( + validMembers, + 15, + (member, index) => `**${index + 1}.** ${member.user.username}`, + { + title: `๐Ÿ“‹ ${clan.name} Members`, + color: client.config.colors.primary, + description: `Total members: ${validMembers.length}`, + } + ); + + // Add main embed as first page + const allEmbeds = [mainEmbed, ...memberEmbeds]; + + const paginator = createPaginator(allEmbeds, { + authorId: interaction.user.id, + timeout: 120000, + }); + + await paginator.start(interaction); + }, +}; + +/** + * Format a date string + */ +function formatDate(dateStr: string): string { + try { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return 'Unknown'; + } +} diff --git a/src/commands/statistics/server.ts b/src/commands/statistics/server.ts new file mode 100644 index 0000000..839b62a --- /dev/null +++ b/src/commands/statistics/server.ts @@ -0,0 +1,96 @@ +/** + * Server Status Command + * View PikaNetwork server status + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const serverCommand: Command = { + data: new SlashCommandBuilder() + .setName('server') + .setDescription('View PikaNetwork server status') + .addStringOption((option) => + option + .setName('ip') + .setDescription('Server IP (defaults to play.pika-network.net)') + .setRequired(false) + ), + + permission: PermissionLevel.User, + cooldown: 15, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const serverIP = interaction.options.getString('ip') ?? 'play.pika-network.net'; + + await interaction.deferReply(); + + const status = await client.pikaAPI.getServerStatus(serverIP); + + if (!status) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Server Offline or Unreachable') + .setDescription(`Could not fetch status for **${serverIP}**.`) + .setFooter({ text: 'The server may be offline or the IP is incorrect.' }), + ], + }); + return; + } + + const statusEmoji = status.online ? '๐ŸŸข' : '๐Ÿ”ด'; + const statusText = status.online ? 'Online' : 'Offline'; + + const embed = new EmbedBuilder() + .setColor(status.online ? 0x00ff00 : 0xff0000) + .setTitle(`${statusEmoji} ${status.host}`) + .setThumbnail(status.icon) + .addFields( + { name: '๐Ÿ“Š Status', value: statusText, inline: true }, + { name: '๐Ÿ‘ฅ Players', value: `${status.playersOnline.toLocaleString()} / ${status.playersMax.toLocaleString()}`, inline: true }, + { name: '๐Ÿ”ง Version', value: status.software, inline: true }, + { name: '๐ŸŒ IP', value: `\`${status.host}\``, inline: true }, + { name: '๐Ÿ”Œ Port', value: String(status.port), inline: true }, + { name: '๐Ÿ“ก Protocol', value: String(status.protocol), inline: true } + ) + .setImage(status.banner) + .setFooter({ + text: `Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + // Add MOTD if available + if (status.motd.length > 0) { + embed.addFields({ + name: '๐Ÿ“ MOTD', + value: status.motd.join('\n') || 'No MOTD', + inline: false, + }); + } + + // Add website and discord if available + if (status.website || status.discord) { + const links: string[] = []; + if (status.website) links.push(`[Website](${status.website})`); + if (status.discord) links.push(`[Discord](${status.discord})`); + + embed.addFields({ + name: '๐Ÿ”— Links', + value: links.join(' โ€ข '), + inline: false, + }); + } + + await interaction.editReply({ embeds: [embed] }); + }, +}; diff --git a/src/commands/statistics/skywars.ts b/src/commands/statistics/skywars.ts new file mode 100644 index 0000000..e21b004 --- /dev/null +++ b/src/commands/statistics/skywars.ts @@ -0,0 +1,176 @@ +/** + * SkyWars Statistics Command + * Displays SkyWars stats for a PikaNetwork player + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import type { Interval } from '../../api/pika/types.ts'; + +export const skywarsCommand: Command = { + data: new SlashCommandBuilder() + .setName('skywars') + .setDescription('Get SkyWars statistics for a PikaNetwork player') + .addStringOption((option) => + option + .setName('username') + .setDescription('Minecraft username') + .setRequired(true) + .setMinLength(3) + .setMaxLength(16) + ) + .addStringOption((option) => + option + .setName('mode') + .setDescription('Game mode') + .setRequired(false) + .addChoices( + { name: 'All Modes', value: 'all_modes' }, + { name: 'Solo', value: 'solo' }, + { name: 'Doubles', value: 'doubles' } + ) + ) + .addStringOption((option) => + option + .setName('interval') + .setDescription('Time interval') + .setRequired(false) + .addChoices( + { name: 'Weekly', value: 'weekly' }, + { name: 'Monthly', value: 'monthly' }, + { name: 'Yearly', value: 'yearly' }, + { name: 'Lifetime', value: 'lifetime' } + ) + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + await interaction.deferReply(); + + const client = interaction.client as EllyClient; + const username = interaction.options.getString('username', true); + const mode = interaction.options.getString('mode') ?? 'all_modes'; + const interval = (interaction.options.getString('interval') ?? 'lifetime') as Interval; + + // Fetch profile and stats + const [profile, stats] = await Promise.all([ + client.pikaAPI.getProfile(username), + client.pikaAPI.getSkyWarsStats(username, interval, mode), + ]); + + if (!profile) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Player Not Found') + .setDescription(`Could not find player **${username}** on PikaNetwork.`) + .setFooter({ text: 'Make sure the username is correct.' }), + ], + }); + return; + } + + if (!stats) { + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Stats Not Found') + .setDescription(`Could not fetch SkyWars statistics for **${profile.username}**.`) + .setFooter({ text: 'The player may not have played SkyWars.' }), + ], + }); + return; + } + + // Format mode name + const modeNames: Record = { + all_modes: 'All Modes', + solo: 'Solo', + doubles: 'Doubles', + }; + + // Get rank color + const rankColors: Record = { + owner: 0xaa0000, + manager: 0xaa0000, + developer: 0xff5555, + admin: 0xff5555, + srmod: 0x00aaaa, + moderator: 0x00aa00, + helper: 0x5555ff, + trial: 0x55ffff, + champion: 0xaa0000, + titan: 0xffaa00, + elite: 0x55ffff, + vip: 0x55ff55, + }; + + let embedColor = client.config.colors.primary; + let rankDisplay = 'Unranked'; + + for (const rank of profile.ranks) { + const rankName = rank.displayName.toLowerCase(); + if (rankColors[rankName]) { + embedColor = rankColors[rankName]; + rankDisplay = rank.displayName; + break; + } + } + + // Build embed + const embed = new EmbedBuilder() + .setColor(embedColor) + .setTitle(`โš”๏ธ ${profile.username}'s SkyWars Stats`) + .setThumbnail(`https://mc-heads.net/head/${profile.username}/right`) + .setDescription( + `**Rank:** ${rankDisplay}\n` + + `**Level:** ${profile.rank.level}\n` + + `**Clan:** ${profile.clan?.name ?? 'None'}` + ) + .addFields( + // Combat stats + { name: 'โš”๏ธ Kills', value: stats.kills.toLocaleString(), inline: true }, + { name: '๐Ÿ’€ Deaths', value: stats.deaths.toLocaleString(), inline: true }, + { name: '๐Ÿ“Š K/D', value: stats.kdr.toString(), inline: true }, + + // Game stats + { name: '๐Ÿ† Wins', value: stats.wins.toLocaleString(), inline: true }, + { name: 'โŒ Losses', value: stats.losses.toLocaleString(), inline: true }, + { name: '๐Ÿ“‰ W/L', value: stats.wlr.toString(), inline: true }, + + // Other stats + { name: '๐ŸŽฎ Games Played', value: stats.gamesPlayed.toLocaleString(), inline: true }, + { name: '๐Ÿ”ฅ Best Winstreak', value: stats.highestWinstreak.toLocaleString(), inline: true }, + { name: '๐Ÿน Bow Kills', value: stats.bowKills.toLocaleString(), inline: true }, + + // Additional stats + { name: '๐Ÿ—ก๏ธ Melee Kills', value: stats.meleeKills.toLocaleString(), inline: true }, + { name: '๐Ÿ•ณ๏ธ Void Kills', value: stats.voidKills.toLocaleString(), inline: true }, + { + name: '๐ŸŽฏ Arrow Accuracy', + value: + stats.arrowsShot > 0 + ? `${((stats.arrowsHit / stats.arrowsShot) * 100).toFixed(1)}%` + : 'N/A', + inline: true, + } + ) + .setFooter({ + text: `Mode: ${modeNames[mode]} | Interval: ${interval.charAt(0).toUpperCase() + interval.slice(1)} | Requested by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + }, +}; diff --git a/src/commands/suggestions/index.ts b/src/commands/suggestions/index.ts new file mode 100644 index 0000000..1f87299 --- /dev/null +++ b/src/commands/suggestions/index.ts @@ -0,0 +1,988 @@ +/** + * Suggestions Command Module + * Advanced suggestion management system with voting + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + type ChatInputCommandInteraction, + ComponentType, + type ButtonInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { SuggestionRepository, type Suggestion } from '../../database/repositories/SuggestionRepository.ts'; + +export const suggestionsCommand: Command = { + data: new SlashCommandBuilder() + .setName('suggestions') + .setDescription('Advanced suggestion management') + // User commands + .addSubcommand((sub) => + sub + .setName('create') + .setDescription('Create a new suggestion') + ) + .addSubcommand((sub) => + sub + .setName('view') + .setDescription('View a suggestion') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Suggestion number').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('edit') + .setDescription('Edit your suggestion') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Suggestion number').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('delete') + .setDescription('Delete your suggestion') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Suggestion number').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('my') + .setDescription('View your suggestions') + ) + // Staff commands + .addSubcommand((sub) => + sub + .setName('approve') + .setDescription('Approve a suggestion') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Suggestion number').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('response').setDescription('Staff response') + ) + ) + .addSubcommand((sub) => + sub + .setName('deny') + .setDescription('Deny a suggestion') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Suggestion number').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for denial').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('consider') + .setDescription('Mark suggestion as under consideration') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Suggestion number').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('note').setDescription('Staff note') + ) + ) + .addSubcommand((sub) => + sub + .setName('implement') + .setDescription('Mark suggestion as implemented') + .addIntegerOption((opt) => + opt.setName('id').setDescription('Suggestion number').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('note').setDescription('Implementation note') + ) + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List suggestions') + .addStringOption((opt) => + opt + .setName('status') + .setDescription('Filter by status') + .addChoices( + { name: 'All', value: 'all' }, + { name: 'Pending', value: 'pending' }, + { name: 'Approved', value: 'approved' }, + { name: 'Denied', value: 'denied' }, + { name: 'Considering', value: 'considering' }, + { name: 'Implemented', value: 'implemented' } + ) + ) + .addStringOption((opt) => + opt + .setName('sort') + .setDescription('Sort order') + .addChoices( + { name: 'Newest', value: 'newest' }, + { name: 'Oldest', value: 'oldest' }, + { name: 'Most Votes', value: 'votes' }, + { name: 'Most Controversial', value: 'controversial' } + ) + ) + ) + .addSubcommand((sub) => + sub + .setName('stats') + .setDescription('View suggestion statistics') + ) + .addSubcommand((sub) => + sub + .setName('top') + .setDescription('View top voted suggestions') + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + const repo = new SuggestionRepository(client.database); + + switch (subcommand) { + case 'create': + await handleCreate(interaction, client, repo); + break; + case 'view': + await handleView(interaction, client, repo); + break; + case 'edit': + await handleEdit(interaction, client, repo); + break; + case 'delete': + await handleDelete(interaction, client, repo); + break; + case 'my': + await handleMy(interaction, client, repo); + break; + case 'approve': + await handleApprove(interaction, client, repo); + break; + case 'deny': + await handleDeny(interaction, client, repo); + break; + case 'consider': + await handleConsider(interaction, client, repo); + break; + case 'implement': + await handleImplement(interaction, client, repo); + break; + case 'list': + await handleList(interaction, client, repo); + break; + case 'stats': + await handleStats(interaction, client, repo); + break; + case 'top': + await handleTop(interaction, client, repo); + break; + } + }, +}; + +async function handleCreate( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + // Check blacklist + const blacklists = client.database.get>('blacklists') ?? []; + const isBlacklisted = blacklists.some( + (b) => b.userId === interaction.user.id && (b.type === 'suggestions' || b.type === 'bot') + ); + + if (isBlacklisted) { + await interaction.reply({ + content: 'โŒ You are blacklisted from creating suggestions.', + ephemeral: true, + }); + return; + } + + // Show modal + const modal = new ModalBuilder() + .setCustomId('suggestion:create') + .setTitle('Create Suggestion') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('title') + .setLabel('Title') + .setPlaceholder('Brief title for your suggestion') + .setStyle(TextInputStyle.Short) + .setMaxLength(100) + .setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('description') + .setLabel('Description') + .setPlaceholder('Describe your suggestion in detail...') + .setStyle(TextInputStyle.Paragraph) + .setMinLength(20) + .setMaxLength(2000) + .setRequired(true) + ) + ); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ + time: 600000, + filter: (i) => i.customId === 'suggestion:create' && i.user.id === interaction.user.id, + }); + + await modalInteraction.deferReply({ ephemeral: true }); + + const title = modalInteraction.fields.getTextInputValue('title'); + const description = modalInteraction.fields.getTextInputValue('description'); + + // Create suggestion + const suggestion = await repo.create({ + userId: interaction.user.id, + title, + description, + }); + + // Post to suggestions channel + const channel = client.channels_cache.suggestions; + if (!channel) { + await modalInteraction.editReply({ + content: 'โŒ Suggestions channel not found.', + }); + return; + } + + const embed = createSuggestionEmbed(suggestion, interaction.user, client); + const row = createVoteButtons(suggestion.id, 0, 0); + + const message = await channel.send({ + embeds: [embed], + components: [row], + }); + + // Update with message ID + await repo.update(suggestion.id, { messageId: message.id, channelId: channel.id }); + + // Set up vote collector + setupVoteCollector(message, suggestion.id, client, repo); + + await modalInteraction.editReply({ + content: `โœ… Your suggestion has been posted!\n\n**Suggestion #${suggestion.orderNum}**\n${message.url}`, + }); + } catch { + // Modal timed out + } +} + +async function handleView( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const orderNum = interaction.options.getInteger('id', true); + const suggestion = await repo.getByOrderNum(orderNum); + + if (!suggestion) { + await interaction.reply({ + content: `โŒ Suggestion #${orderNum} not found.`, + ephemeral: true, + }); + return; + } + + let author; + try { + author = await client.users.fetch(suggestion.userId); + } catch { + author = null; + } + + const embed = createDetailedSuggestionEmbed(suggestion, author, client); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleEdit( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const orderNum = interaction.options.getInteger('id', true); + const suggestion = await repo.getByOrderNum(orderNum); + + if (!suggestion) { + await interaction.reply({ + content: `โŒ Suggestion #${orderNum} not found.`, + ephemeral: true, + }); + return; + } + + if (suggestion.userId !== interaction.user.id) { + await interaction.reply({ + content: 'โŒ You can only edit your own suggestions.', + ephemeral: true, + }); + return; + } + + if (suggestion.status !== 'pending') { + await interaction.reply({ + content: 'โŒ You can only edit pending suggestions.', + ephemeral: true, + }); + return; + } + + // Show edit modal + const modal = new ModalBuilder() + .setCustomId(`suggestion:edit:${suggestion.id}`) + .setTitle('Edit Suggestion') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('title') + .setLabel('Title') + .setValue(suggestion.title) + .setStyle(TextInputStyle.Short) + .setMaxLength(100) + .setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('description') + .setLabel('Description') + .setValue(suggestion.description) + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(2000) + .setRequired(true) + ) + ); + + await interaction.showModal(modal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ + time: 600000, + filter: (i) => i.customId === `suggestion:edit:${suggestion.id}`, + }); + + const title = modalInteraction.fields.getTextInputValue('title'); + const description = modalInteraction.fields.getTextInputValue('description'); + + await repo.update(suggestion.id, { title, description }); + + // Update message if exists + if (suggestion.messageId && suggestion.channelId) { + try { + const channel = await client.channels.fetch(suggestion.channelId); + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(suggestion.messageId); + const embed = EmbedBuilder.from(message.embeds[0]) + .setTitle(`๐Ÿ’ก Suggestion #${suggestion.orderNum}`) + .setDescription(`**${title}**\n\n${description}`) + .setFooter({ text: `Edited โ€ข ID: ${suggestion.id}` }); + await message.edit({ embeds: [embed] }); + } + } catch { + // Message might be deleted + } + } + + await modalInteraction.reply({ + content: 'โœ… Suggestion updated!', + ephemeral: true, + }); + } catch { + // Modal timed out + } +} + +async function handleDelete( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const orderNum = interaction.options.getInteger('id', true); + const suggestion = await repo.getByOrderNum(orderNum); + + if (!suggestion) { + await interaction.reply({ + content: `โŒ Suggestion #${orderNum} not found.`, + ephemeral: true, + }); + return; + } + + const member = interaction.guild?.members.cache.get(interaction.user.id); + const isStaff = member && client.permissions.hasPermission(member, PermissionLevel.Officer); + + if (suggestion.userId !== interaction.user.id && !isStaff) { + await interaction.reply({ + content: 'โŒ You can only delete your own suggestions.', + ephemeral: true, + }); + return; + } + + // Delete message if exists + if (suggestion.messageId && suggestion.channelId) { + try { + const channel = await client.channels.fetch(suggestion.channelId); + if (channel?.isTextBased()) { + const message = await channel.messages.fetch(suggestion.messageId); + await message.delete(); + } + } catch { + // Message might already be deleted + } + } + + await repo.delete(suggestion.id); + + await interaction.reply({ + content: `โœ… Suggestion #${orderNum} has been deleted.`, + ephemeral: true, + }); +} + +async function handleMy( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const suggestions = await repo.getByUserId(interaction.user.id); + + if (suggestions.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ You have no suggestions.', + ephemeral: true, + }); + return; + } + + const statusEmoji: Record = { + pending: 'โณ', + approved: 'โœ…', + denied: 'โŒ', + considering: '๐Ÿค”', + implemented: '๐ŸŽ‰', + }; + + const list = suggestions.slice(0, 10).map((s) => { + const emoji = statusEmoji[s.status] ?? 'โ“'; + const votes = (s.upvotes ?? 0) - (s.downvotes ?? 0); + const voteStr = votes >= 0 ? `+${votes}` : String(votes); + return `${emoji} **#${s.orderNum}** - ${s.title.substring(0, 40)}... (${voteStr} votes)`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“‹ Your Suggestions') + .setDescription(list.join('\n')) + .setFooter({ text: `Total: ${suggestions.length} suggestions` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleApprove( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to approve suggestions.', + ephemeral: true, + }); + return; + } + + const orderNum = interaction.options.getInteger('id', true); + const response = interaction.options.getString('response'); + + const suggestion = await repo.getByOrderNum(orderNum); + if (!suggestion) { + await interaction.reply({ + content: `โŒ Suggestion #${orderNum} not found.`, + ephemeral: true, + }); + return; + } + + await repo.updateStatus(suggestion.id, 'approved', interaction.user.id, response ?? undefined); + await updateSuggestionMessage(suggestion, 'approved', client, repo, interaction.user.tag, response ?? undefined); + + // Notify author + await notifyAuthor(client, suggestion.userId, suggestion, 'approved', response); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x57f287) + .setTitle('โœ… Suggestion Approved') + .setDescription(`Suggestion #${orderNum} has been approved.`) + .setTimestamp(), + ], + }); +} + +async function handleDeny( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to deny suggestions.', + ephemeral: true, + }); + return; + } + + const orderNum = interaction.options.getInteger('id', true); + const reason = interaction.options.getString('reason', true); + + const suggestion = await repo.getByOrderNum(orderNum); + if (!suggestion) { + await interaction.reply({ + content: `โŒ Suggestion #${orderNum} not found.`, + ephemeral: true, + }); + return; + } + + await repo.updateStatus(suggestion.id, 'denied', interaction.user.id, reason); + await updateSuggestionMessage(suggestion, 'denied', client, repo, interaction.user.tag, reason); + + // Notify author + await notifyAuthor(client, suggestion.userId, suggestion, 'denied', reason); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xed4245) + .setTitle('โŒ Suggestion Denied') + .setDescription(`Suggestion #${orderNum} has been denied.`) + .addFields({ name: 'Reason', value: reason }) + .setTimestamp(), + ], + }); +} + +async function handleConsider( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission.', + ephemeral: true, + }); + return; + } + + const orderNum = interaction.options.getInteger('id', true); + const note = interaction.options.getString('note'); + + const suggestion = await repo.getByOrderNum(orderNum); + if (!suggestion) { + await interaction.reply({ + content: `โŒ Suggestion #${orderNum} not found.`, + ephemeral: true, + }); + return; + } + + await repo.updateStatus(suggestion.id, 'considering', interaction.user.id, note ?? undefined); + await updateSuggestionMessage(suggestion, 'considering', client, repo, interaction.user.tag, note ?? undefined); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0xf39c12) + .setTitle('๐Ÿค” Suggestion Under Consideration') + .setDescription(`Suggestion #${orderNum} is now under consideration.`) + .setTimestamp(), + ], + }); +} + +async function handleImplement( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission.', + ephemeral: true, + }); + return; + } + + const orderNum = interaction.options.getInteger('id', true); + const note = interaction.options.getString('note'); + + const suggestion = await repo.getByOrderNum(orderNum); + if (!suggestion) { + await interaction.reply({ + content: `โŒ Suggestion #${orderNum} not found.`, + ephemeral: true, + }); + return; + } + + await repo.updateStatus(suggestion.id, 'implemented', interaction.user.id, note ?? undefined); + await updateSuggestionMessage(suggestion, 'implemented', client, repo, interaction.user.tag, note ?? undefined); + + // Notify author + await notifyAuthor(client, suggestion.userId, suggestion, 'implemented', note); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(0x9b59b6) + .setTitle('๐ŸŽ‰ Suggestion Implemented') + .setDescription(`Suggestion #${orderNum} has been implemented!`) + .setTimestamp(), + ], + }); +} + +async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const status = interaction.options.getString('status') ?? 'pending'; + const sort = interaction.options.getString('sort') ?? 'newest'; + + let suggestions = await repo.getAll(); + + // Filter + if (status !== 'all') { + suggestions = suggestions.filter((s) => s.status === status); + } + + // Sort + switch (sort) { + case 'oldest': + suggestions.sort((a, b) => a.createdAt - b.createdAt); + break; + case 'votes': + suggestions.sort((a, b) => ((b.upvotes ?? 0) - (b.downvotes ?? 0)) - ((a.upvotes ?? 0) - (a.downvotes ?? 0))); + break; + case 'controversial': + suggestions.sort((a, b) => Math.min(b.upvotes ?? 0, b.downvotes ?? 0) - Math.min(a.upvotes ?? 0, a.downvotes ?? 0)); + break; + default: + suggestions.sort((a, b) => b.createdAt - a.createdAt); + } + + if (suggestions.length === 0) { + await interaction.reply({ + content: `๐Ÿ“ญ No ${status === 'all' ? '' : status} suggestions found.`, + ephemeral: true, + }); + return; + } + + const statusEmoji: Record = { + pending: 'โณ', + approved: 'โœ…', + denied: 'โŒ', + considering: '๐Ÿค”', + implemented: '๐ŸŽ‰', + }; + + const list = suggestions.slice(0, 15).map((s) => { + const emoji = statusEmoji[s.status] ?? 'โ“'; + const votes = (s.upvotes ?? 0) - (s.downvotes ?? 0); + const voteStr = votes >= 0 ? `+${votes}` : String(votes); + return `${emoji} **#${s.orderNum}** - ${s.title.substring(0, 35)}... (${voteStr})`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ“‹ ${status === 'all' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)} Suggestions`) + .setDescription(list.join('\n')) + .setFooter({ text: `Showing ${Math.min(15, suggestions.length)} of ${suggestions.length}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleStats( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const suggestions = await repo.getAll(); + + const stats = { + total: suggestions.length, + pending: suggestions.filter((s) => s.status === 'pending').length, + approved: suggestions.filter((s) => s.status === 'approved').length, + denied: suggestions.filter((s) => s.status === 'denied').length, + considering: suggestions.filter((s) => s.status === 'considering').length, + implemented: suggestions.filter((s) => s.status === 'implemented').length, + }; + + const totalVotes = suggestions.reduce((sum, s) => sum + (s.upvotes ?? 0) + (s.downvotes ?? 0), 0); + const avgVotes = suggestions.length > 0 ? Math.round(totalVotes / suggestions.length) : 0; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“Š Suggestion Statistics') + .addFields( + { name: '๐Ÿ“‹ Total', value: String(stats.total), inline: true }, + { name: 'โณ Pending', value: String(stats.pending), inline: true }, + { name: 'โœ… Approved', value: String(stats.approved), inline: true }, + { name: 'โŒ Denied', value: String(stats.denied), inline: true }, + { name: '๐Ÿค” Considering', value: String(stats.considering), inline: true }, + { name: '๐ŸŽ‰ Implemented', value: String(stats.implemented), inline: true }, + { name: '๐Ÿ‘ Total Votes', value: String(totalVotes), inline: true }, + { name: '๐Ÿ“ˆ Avg Votes/Suggestion', value: String(avgVotes), inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +async function handleTop( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: SuggestionRepository +): Promise { + const suggestions = await repo.getAll(); + + const sorted = suggestions + .map((s) => ({ ...s, score: (s.upvotes ?? 0) - (s.downvotes ?? 0) })) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + + if (sorted.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ No suggestions yet.', + ephemeral: true, + }); + return; + } + + const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; + const list = sorted.map((s, i) => { + const medal = medals[i] ?? `**${i + 1}.**`; + const scoreStr = s.score >= 0 ? `+${s.score}` : String(s.score); + return `${medal} **#${s.orderNum}** - ${s.title.substring(0, 35)}... (${scoreStr} votes)`; + }); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ† Top Voted Suggestions') + .setDescription(list.join('\n')) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); +} + +// Helper functions + +function createSuggestionEmbed( + suggestion: Suggestion, + author: import('discord.js').User, + client: EllyClient +): EmbedBuilder { + return new EmbedBuilder() + .setColor(0x3498db) + .setTitle(`๐Ÿ’ก Suggestion #${suggestion.orderNum}`) + .setDescription(`**${suggestion.title}**\n\n${suggestion.description}`) + .setAuthor({ name: author.tag, iconURL: author.displayAvatarURL() }) + .setFooter({ text: `ID: ${suggestion.id} โ€ข Vote using the buttons below!` }) + .setTimestamp(); +} + +function createDetailedSuggestionEmbed( + suggestion: Suggestion, + author: import('discord.js').User | null, + client: EllyClient +): EmbedBuilder { + const statusConfig: Record = { + pending: { color: 0x3498db, emoji: 'โณ' }, + approved: { color: 0x57f287, emoji: 'โœ…' }, + denied: { color: 0xed4245, emoji: 'โŒ' }, + considering: { color: 0xf39c12, emoji: '๐Ÿค”' }, + implemented: { color: 0x9b59b6, emoji: '๐ŸŽ‰' }, + }; + + const config = statusConfig[suggestion.status] ?? statusConfig.pending; + const votes = (suggestion.upvotes ?? 0) - (suggestion.downvotes ?? 0); + + const embed = new EmbedBuilder() + .setColor(config.color) + .setTitle(`${config.emoji} Suggestion #${suggestion.orderNum}`) + .setDescription(`**${suggestion.title}**\n\n${suggestion.description}`) + .addFields( + { name: 'Status', value: suggestion.status.charAt(0).toUpperCase() + suggestion.status.slice(1), inline: true }, + { name: 'Author', value: author ? author.tag : `<@${suggestion.userId}>`, inline: true }, + { name: 'Votes', value: `๐Ÿ‘ ${suggestion.upvotes ?? 0} | ๐Ÿ‘Ž ${suggestion.downvotes ?? 0} (${votes >= 0 ? '+' : ''}${votes})`, inline: true }, + { name: 'Created', value: ``, inline: true } + ); + + if (suggestion.reviewedBy) { + embed.addFields({ name: 'Reviewed By', value: `<@${suggestion.reviewedBy}>`, inline: true }); + } + + if (suggestion.reviewReason) { + embed.addFields({ name: 'Staff Response', value: suggestion.reviewReason }); + } + + return embed; +} + +function createVoteButtons(suggestionId: string, upvotes: number, downvotes: number): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`suggestion:upvote:${suggestionId}`) + .setLabel(`${upvotes}`) + .setStyle(ButtonStyle.Success) + .setEmoji('๐Ÿ‘'), + new ButtonBuilder() + .setCustomId(`suggestion:downvote:${suggestionId}`) + .setLabel(`${downvotes}`) + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ‘Ž') + ); +} + +function setupVoteCollector( + message: import('discord.js').Message, + suggestionId: string, + client: EllyClient, + repo: SuggestionRepository +): void { + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 30 * 24 * 60 * 60 * 1000, // 30 days + }); + + collector.on('collect', async (i: ButtonInteraction) => { + const [, action] = i.customId.split(':'); + + if (action === 'upvote' || action === 'downvote') { + const voteType = action === 'upvote' ? 'up' : 'down'; + const result = await repo.vote(suggestionId, i.user.id, voteType); + + if (!result) { + await i.reply({ content: 'โŒ Failed to record vote.', ephemeral: true }); + return; + } + + // Update buttons + const row = createVoteButtons(suggestionId, result.upvotes, result.downvotes); + await message.edit({ components: [row] }); + + await i.reply({ + content: `โœ… Vote recorded! (${voteType === 'up' ? '๐Ÿ‘' : '๐Ÿ‘Ž'})`, + ephemeral: true, + }); + } + }); +} + +async function updateSuggestionMessage( + suggestion: Suggestion, + status: string, + client: EllyClient, + repo: SuggestionRepository, + reviewerTag: string, + response?: string +): Promise { + if (!suggestion.messageId || !suggestion.channelId) return; + + try { + const channel = await client.channels.fetch(suggestion.channelId); + if (!channel?.isTextBased()) return; + + const message = await channel.messages.fetch(suggestion.messageId); + + const statusConfig: Record = { + approved: { color: 0x57f287, emoji: 'โœ…' }, + denied: { color: 0xed4245, emoji: 'โŒ' }, + considering: { color: 0xf39c12, emoji: '๐Ÿค”' }, + implemented: { color: 0x9b59b6, emoji: '๐ŸŽ‰' }, + }; + + const config = statusConfig[status] ?? { color: 0x3498db, emoji: '๐Ÿ’ก' }; + + const embed = EmbedBuilder.from(message.embeds[0]) + .setColor(config.color) + .setTitle(`${config.emoji} Suggestion #${suggestion.orderNum} - ${status.charAt(0).toUpperCase() + status.slice(1)}`) + .addFields({ name: 'Reviewed By', value: reviewerTag, inline: true }); + + if (response) { + embed.addFields({ name: 'Staff Response', value: response }); + } + + await message.edit({ embeds: [embed] }); + } catch { + // Message might be deleted + } +} + +async function notifyAuthor( + client: EllyClient, + userId: string, + suggestion: Suggestion, + status: string, + response?: string | null +): Promise { + try { + const user = await client.users.fetch(userId); + + const statusConfig: Record = { + approved: { color: 0x57f287, title: 'โœ… Suggestion Approved!' }, + denied: { color: 0xed4245, title: 'โŒ Suggestion Denied' }, + implemented: { color: 0x9b59b6, title: '๐ŸŽ‰ Suggestion Implemented!' }, + }; + + const config = statusConfig[status]; + if (!config) return; + + const embed = new EmbedBuilder() + .setColor(config.color) + .setTitle(config.title) + .setDescription(`Your suggestion **#${suggestion.orderNum}** has been ${status}!`) + .addFields({ name: 'Suggestion', value: suggestion.title }); + + if (response) { + embed.addFields({ name: 'Staff Response', value: response }); + } + + await user.send({ embeds: [embed] }); + } catch { + // User might have DMs disabled + } +} diff --git a/src/commands/utility/away.ts b/src/commands/utility/away.ts new file mode 100644 index 0000000..b7f597f --- /dev/null +++ b/src/commands/utility/away.ts @@ -0,0 +1,354 @@ +/** + * Away Command + * Manage away status for guild members + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, + type GuildMember, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { AwayRepository } from '../../database/repositories/AwayRepository.ts'; +import { parseTime, formatDuration, discordTimestamp } from '../../utils/time.ts'; + +export const awayCommand: Command = { + data: new SlashCommandBuilder() + .setName('away') + .setDescription('Manage away status') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Set a member as away') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to set as away') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('duration') + .setDescription('How long they will be away (e.g., 7d, 2w)') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('Reason for being away') + .setRequired(true) + .setMaxLength(500) + ) + .addStringOption((option) => + option + .setName('minecraft') + .setDescription('Their Minecraft username') + .setRequired(false) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove away status from a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to remove away status from') + .setRequired(true) + ) + ) + .addSubcommand((subcommand) => + subcommand + .setName('list') + .setDescription('List all members currently away') + ) + .addSubcommand((subcommand) => + subcommand + .setName('check') + .setDescription('Check away status of a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to check') + .setRequired(true) + ) + ), + + permission: PermissionLevel.Officer, + cooldown: 5, + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const awayRepo = new AwayRepository(client.database); + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'add': + await handleAdd(interaction, client, awayRepo); + break; + case 'remove': + await handleRemove(interaction, client, awayRepo); + break; + case 'list': + await handleList(interaction, client, awayRepo); + break; + case 'check': + await handleCheck(interaction, client, awayRepo); + break; + } + }, +}; + +/** + * Handle adding away status + */ +async function handleAdd( + interaction: ChatInputCommandInteraction, + client: EllyClient, + awayRepo: AwayRepository +): Promise { + const targetUser = interaction.options.getUser('user', true); + const durationStr = interaction.options.getString('duration', true); + const reason = interaction.options.getString('reason', true); + const minecraft = interaction.options.getString('minecraft'); + + // Parse duration + const durationMs = parseTime(durationStr); + if (!durationMs) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Invalid Duration') + .setDescription('Could not parse the duration. Use formats like `7d`, `2w`, `1mo`.'), + ], + ephemeral: true, + }); + return; + } + + // Check max duration + const maxDurationMs = client.config.limits.away_max_days * 24 * 60 * 60 * 1000; + if (durationMs > maxDurationMs) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Duration Too Long') + .setDescription( + `Away status cannot exceed ${client.config.limits.away_max_days} days.` + ), + ], + ephemeral: true, + }); + return; + } + + // Check if already away + if (awayRepo.isAway(targetUser.id)) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('โš ๏ธ Already Away') + .setDescription(`${targetUser} is already marked as away. Remove their status first.`), + ], + ephemeral: true, + }); + return; + } + + // Create away status + const expiresAt = new Date(Date.now() + durationMs); + awayRepo.create({ + userId: targetUser.id, + minecraftUsername: minecraft ?? undefined, + reason, + expiresAt: expiresAt.toISOString(), + }); + + // Try to add away role + if (interaction.guild && client.roles.away) { + try { + const member = await interaction.guild.members.fetch(targetUser.id); + await member.roles.add(client.roles.away); + } catch (error) { + client.logger.warn(`Could not add away role to ${targetUser.tag}`, error); + } + } + + // Send to inactivity channel if configured + if (client.channels_cache.inactivity) { + const noticeEmbed = new EmbedBuilder() + .setColor(0xffa500) + .setTitle('๐Ÿ“‹ Inactivity Notice') + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: 'Member', value: `${targetUser}`, inline: true }, + { name: 'Duration', value: formatDuration(durationMs), inline: true }, + { name: 'Returns', value: discordTimestamp(expiresAt, 'R'), inline: true }, + { name: 'Reason', value: reason, inline: false } + ) + .setFooter({ + text: `Set by ${interaction.user.tag}`, + iconURL: interaction.user.displayAvatarURL(), + }) + .setTimestamp(); + + if (minecraft) { + noticeEmbed.addFields({ name: 'Minecraft', value: minecraft, inline: true }); + } + + await client.channels_cache.inactivity.send({ embeds: [noticeEmbed] }); + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Away Status Set') + .setDescription(`${targetUser} has been marked as away.`) + .addFields( + { name: 'Duration', value: formatDuration(durationMs), inline: true }, + { name: 'Returns', value: discordTimestamp(expiresAt, 'R'), inline: true }, + { name: 'Reason', value: reason, inline: false } + ), + ], + }); +} + +/** + * Handle removing away status + */ +async function handleRemove( + interaction: ChatInputCommandInteraction, + client: EllyClient, + awayRepo: AwayRepository +): Promise { + const targetUser = interaction.options.getUser('user', true); + + if (!awayRepo.isAway(targetUser.id)) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Not Away') + .setDescription(`${targetUser} is not marked as away.`), + ], + ephemeral: true, + }); + return; + } + + awayRepo.delete(targetUser.id); + + // Try to remove away role + if (interaction.guild && client.roles.away) { + try { + const member = await interaction.guild.members.fetch(targetUser.id); + await member.roles.remove(client.roles.away); + } catch (error) { + client.logger.warn(`Could not remove away role from ${targetUser.tag}`, error); + } + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Away Status Removed') + .setDescription(`${targetUser} is no longer marked as away.`), + ], + }); +} + +/** + * Handle listing away members + */ +async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient, + awayRepo: AwayRepository +): Promise { + const allAway = awayRepo.getAll().filter((s) => new Date(s.expiresAt) > new Date()); + + if (allAway.length === 0) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.info) + .setTitle('๐Ÿ“‹ Away Members') + .setDescription('No members are currently away.'), + ], + }); + return; + } + + // Sort by expiry date + allAway.sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()); + + const awayList = allAway + .slice(0, 15) + .map((s) => { + const reason = s.reason.length > 30 ? s.reason.slice(0, 27) + '...' : s.reason; + return `<@${s.userId}> - Returns ${discordTimestamp(new Date(s.expiresAt), 'R')}\nโ”” ${reason}`; + }) + .join('\n\n'); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.info) + .setTitle('๐Ÿ“‹ Away Members') + .setDescription(awayList) + .setFooter({ + text: `Total: ${allAway.length} member${allAway.length !== 1 ? 's' : ''} away`, + }), + ], + }); +} + +/** + * Handle checking away status + */ +async function handleCheck( + interaction: ChatInputCommandInteraction, + client: EllyClient, + awayRepo: AwayRepository +): Promise { + const targetUser = interaction.options.getUser('user', true); + const status = awayRepo.getByUserId(targetUser.id); + + if (!status || new Date(status.expiresAt) <= new Date()) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.info) + .setTitle('โ„น๏ธ Away Status') + .setDescription(`${targetUser} is not currently away.`), + ], + }); + return; + } + + const embed = new EmbedBuilder() + .setColor(0xffa500) + .setTitle('๐Ÿ“‹ Away Status') + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: 'Member', value: `${targetUser}`, inline: true }, + { name: 'Returns', value: discordTimestamp(new Date(status.expiresAt), 'R'), inline: true }, + { name: 'Reason', value: status.reason, inline: false } + ) + .setTimestamp(new Date(status.createdAt)); + + if (status.minecraftUsername) { + embed.addFields({ name: 'Minecraft', value: status.minecraftUsername, inline: true }); + } + + await interaction.reply({ embeds: [embed] }); +} diff --git a/src/commands/utility/champion.ts b/src/commands/utility/champion.ts new file mode 100644 index 0000000..8b449b5 --- /dev/null +++ b/src/commands/utility/champion.ts @@ -0,0 +1,379 @@ +/** + * Champion Command + * Manage champion role assignments + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, + type GuildMember, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { ChampionRepository } from '../../database/repositories/ChampionRepository.ts'; +import { parseDuration, formatDuration } from '../../utils/time.ts'; + +export const championCommand: Command = { + data: new SlashCommandBuilder() + .setName('champion') + .setDescription('Manage champion role') + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Give champion role to a user') + .addUserOption((opt) => + opt.setName('user').setDescription('User to give champion to').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('duration').setDescription('Duration (e.g., 30d, 1w)').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for champion').setRequired(false) + ) + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove champion role from a user') + .addUserOption((opt) => + opt.setName('user').setDescription('User to remove champion from').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('extend') + .setDescription('Extend champion duration') + .addUserOption((opt) => + opt.setName('user').setDescription('User to extend champion for').setRequired(true) + ) + .addStringOption((opt) => + opt.setName('duration').setDescription('Additional duration (e.g., 7d)').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('check') + .setDescription('Check champion status') + .addUserOption((opt) => + opt.setName('user').setDescription('User to check').setRequired(false) + ) + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List all active champions') + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + const repo = new ChampionRepository(client.database); + + switch (subcommand) { + case 'add': + await handleAdd(interaction, client, repo); + break; + case 'remove': + await handleRemove(interaction, client, repo); + break; + case 'extend': + await handleExtend(interaction, client, repo); + break; + case 'check': + await handleCheck(interaction, client, repo); + break; + case 'list': + await handleList(interaction, client, repo); + break; + } + }, +}; + +/** + * Handle adding champion + */ +async function handleAdd( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ChampionRepository +): Promise { + // Check permission + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to add champions.', + ephemeral: true, + }); + return; + } + + const targetUser = interaction.options.getUser('user', true); + const durationStr = interaction.options.getString('duration', true); + const reason = interaction.options.getString('reason') ?? 'No reason provided'; + + // Parse duration + const durationMs = parseDuration(durationStr); + if (!durationMs || durationMs <= 0) { + await interaction.reply({ + content: 'โŒ Invalid duration. Use formats like `7d`, `2w`, `30d`.', + ephemeral: true, + }); + return; + } + + // Check max duration + const maxDays = client.config.limits.championMaxDays; + const maxMs = maxDays * 24 * 60 * 60 * 1000; + if (durationMs > maxMs) { + await interaction.reply({ + content: `โŒ Maximum champion duration is ${maxDays} days.`, + ephemeral: true, + }); + return; + } + + // Check if already champion + const existing = await repo.getByUserId(targetUser.id); + if (existing) { + await interaction.reply({ + content: `โŒ ${targetUser.tag} is already a champion. Use \`/champion extend\` to extend their duration.`, + ephemeral: true, + }); + return; + } + + await interaction.deferReply(); + + // Add to database + const champion = await repo.add({ + oderId: targetUser.id, + assignedBy: interaction.user.id, + reason, + startDate: Date.now(), + endDate: Date.now() + durationMs, + }); + + // Add role + const targetMember = interaction.guild?.members.cache.get(targetUser.id); + const championRole = interaction.guild?.roles.cache.find( + (r) => r.name === client.config.roles.champion + ); + + if (targetMember && championRole) { + try { + await targetMember.roles.add(championRole); + } catch (error) { + console.error('Failed to add champion role:', error); + } + } + + const embed = new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('๐Ÿ† Champion Added') + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: '๐Ÿ‘ค User', value: targetUser.tag, inline: true }, + { name: 'โฑ๏ธ Duration', value: formatDuration(durationMs), inline: true }, + { name: '๐Ÿ“… Expires', value: ``, inline: true }, + { name: '๐Ÿ“ Reason', value: reason } + ) + .setFooter({ text: `Added by ${interaction.user.tag}` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +/** + * Handle removing champion + */ +async function handleRemove( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ChampionRepository +): Promise { + // Check permission + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to remove champions.', + ephemeral: true, + }); + return; + } + + const targetUser = interaction.options.getUser('user', true); + + const removed = await repo.remove(targetUser.id); + if (!removed) { + await interaction.reply({ + content: `โŒ ${targetUser.tag} is not a champion.`, + ephemeral: true, + }); + return; + } + + // Remove role + const targetMember = interaction.guild?.members.cache.get(targetUser.id); + const championRole = interaction.guild?.roles.cache.find( + (r) => r.name === client.config.roles.champion + ); + + if (targetMember && championRole) { + try { + await targetMember.roles.remove(championRole); + } catch (error) { + console.error('Failed to remove champion role:', error); + } + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('๐Ÿ† Champion Removed') + .setDescription(`${targetUser.tag} is no longer a champion.`) + .setFooter({ text: `Removed by ${interaction.user.tag}` }) + .setTimestamp(), + ], + }); +} + +/** + * Handle extending champion + */ +async function handleExtend( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ChampionRepository +): Promise { + // Check permission + const member = interaction.guild?.members.cache.get(interaction.user.id); + if (!member || !client.permissions.hasPermission(member, PermissionLevel.Officer)) { + await interaction.reply({ + content: 'โŒ You need Officer permission to extend champions.', + ephemeral: true, + }); + return; + } + + const targetUser = interaction.options.getUser('user', true); + const durationStr = interaction.options.getString('duration', true); + + // Parse duration + const durationMs = parseDuration(durationStr); + if (!durationMs || durationMs <= 0) { + await interaction.reply({ + content: 'โŒ Invalid duration. Use formats like `7d`, `2w`, `30d`.', + ephemeral: true, + }); + return; + } + + const additionalDays = Math.ceil(durationMs / (24 * 60 * 60 * 1000)); + const champion = await repo.extend(targetUser.id, additionalDays); + + if (!champion) { + await interaction.reply({ + content: `โŒ ${targetUser.tag} is not a champion.`, + ephemeral: true, + }); + return; + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('๐Ÿ† Champion Extended') + .setDescription(`${targetUser.tag}'s champion status has been extended.`) + .addFields( + { name: 'โฑ๏ธ Added', value: formatDuration(durationMs), inline: true }, + { name: '๐Ÿ“… New Expiry', value: ``, inline: true } + ) + .setFooter({ text: `Extended by ${interaction.user.tag}` }) + .setTimestamp(), + ], + }); +} + +/** + * Handle checking champion status + */ +async function handleCheck( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ChampionRepository +): Promise { + const targetUser = interaction.options.getUser('user') ?? interaction.user; + const champion = await repo.getByUserId(targetUser.id); + + if (!champion) { + await interaction.reply({ + content: `${targetUser.id === interaction.user.id ? 'You are' : `${targetUser.tag} is`} not a champion.`, + ephemeral: true, + }); + return; + } + + const remainingDays = await repo.getRemainingDays(targetUser.id); + const assigner = await client.users.fetch(champion.assignedBy).catch(() => null); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ† Champion Status') + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: '๐Ÿ‘ค User', value: targetUser.tag, inline: true }, + { name: '๐Ÿ“… Remaining', value: `${remainingDays} days`, inline: true }, + { name: 'โฐ Expires', value: ``, inline: false }, + { name: '๐Ÿ‘‘ Assigned By', value: assigner?.tag ?? 'Unknown', inline: true }, + { name: '๐Ÿ“ Reason', value: champion.reason ?? 'No reason provided', inline: false } + ) + .setTimestamp(), + ], + }); +} + +/** + * Handle listing champions + */ +async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: ChampionRepository +): Promise { + const champions = await repo.getActive(); + + if (champions.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ No active champions.', + ephemeral: true, + }); + return; + } + + const lines: string[] = []; + for (const champ of champions) { + const user = await client.users.fetch(champ.userId).catch(() => null); + const remaining = Math.ceil((champ.endDate - Date.now()) / (24 * 60 * 60 * 1000)); + lines.push(`โ€ข **${user?.tag ?? 'Unknown'}** - ${remaining} days remaining`); + } + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ† Active Champions') + .setDescription(lines.join('\n')) + .setFooter({ text: `Total: ${champions.length} champions` }) + .setTimestamp(), + ], + }); +} diff --git a/src/commands/utility/remind.ts b/src/commands/utility/remind.ts new file mode 100644 index 0000000..b45c72a --- /dev/null +++ b/src/commands/utility/remind.ts @@ -0,0 +1,265 @@ +/** + * Remind Command + * Set personal reminders + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { ReminderRepository } from '../../database/repositories/ReminderRepository.ts'; +import { parseTime, formatDuration, discordTimestamp } from '../../utils/time.ts'; + +export const remindCommand: Command = { + data: new SlashCommandBuilder() + .setName('remind') + .setDescription('Set a personal reminder') + .addSubcommand((subcommand) => + subcommand + .setName('set') + .setDescription('Set a new reminder') + .addStringOption((option) => + option + .setName('duration') + .setDescription('When to remind you (e.g., 15m, 2h, 1d)') + .setRequired(true) + ) + .addStringOption((option) => + option + .setName('text') + .setDescription('What to remind you about') + .setRequired(true) + .setMaxLength(500) + ) + ) + .addSubcommand((subcommand) => + subcommand.setName('list').setDescription('List your active reminders') + ) + .addSubcommand((subcommand) => + subcommand + .setName('cancel') + .setDescription('Cancel a reminder') + .addStringOption((option) => + option + .setName('id') + .setDescription('The reminder ID to cancel') + .setRequired(true) + ) + ), + + permission: PermissionLevel.User, + cooldown: 3, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + const reminderRepo = new ReminderRepository(client.database); + + switch (subcommand) { + case 'set': + await handleSet(interaction, client, reminderRepo); + break; + case 'list': + await handleList(interaction, client, reminderRepo); + break; + case 'cancel': + await handleCancel(interaction, client, reminderRepo); + break; + } + }, +}; + +/** + * Handle setting a new reminder + */ +async function handleSet( + interaction: ChatInputCommandInteraction, + client: EllyClient, + reminderRepo: ReminderRepository +): Promise { + const durationStr = interaction.options.getString('duration', true); + const text = interaction.options.getString('text', true); + + // Parse duration + const durationMs = parseTime(durationStr); + if (!durationMs) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Invalid Duration') + .setDescription( + 'Could not parse the duration. Use formats like:\n' + + 'โ€ข `15m` - 15 minutes\n' + + 'โ€ข `2h` - 2 hours\n' + + 'โ€ข `1d` - 1 day\n' + + 'โ€ข `1d 2h 30m` - 1 day, 2 hours, 30 minutes' + ), + ], + ephemeral: true, + }); + return; + } + + // Check max duration + const maxDurationMs = client.config.limits.reminder_max_duration_days * 24 * 60 * 60 * 1000; + if (durationMs > maxDurationMs) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Duration Too Long') + .setDescription( + `Reminders cannot be set for more than ${client.config.limits.reminder_max_duration_days} days.` + ), + ], + ephemeral: true, + }); + return; + } + + // Check reminder limit + const existingReminders = reminderRepo.countByUserId(interaction.user.id); + if (existingReminders >= 25) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Too Many Reminders') + .setDescription('You can only have up to 25 active reminders. Cancel some first.'), + ], + ephemeral: true, + }); + return; + } + + // Create reminder + const remindAt = new Date(Date.now() + durationMs); + const reminder = reminderRepo.create({ + id: ReminderRepository.generateId(), + userId: interaction.user.id, + channelId: interaction.channelId, + reminderText: text, + remindAt: remindAt.toISOString(), + isRecurring: false, + }); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โฐ Reminder Set') + .setDescription(`I'll remind you about:\n\`\`\`${text}\`\`\``) + .addFields( + { name: 'When', value: discordTimestamp(remindAt, 'R'), inline: true }, + { name: 'Duration', value: formatDuration(durationMs), inline: true }, + { name: 'ID', value: `\`${reminder.id}\``, inline: true } + ) + .setFooter({ text: `Reminder ID: ${reminder.id}` }) + .setTimestamp(), + ], + }); +} + +/** + * Handle listing reminders + */ +async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient, + reminderRepo: ReminderRepository +): Promise { + const reminders = reminderRepo.getByUserId(interaction.user.id); + + if (reminders.length === 0) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.info) + .setTitle('๐Ÿ“‹ Your Reminders') + .setDescription("You don't have any active reminders."), + ], + ephemeral: true, + }); + return; + } + + // Sort by remind time + reminders.sort((a, b) => new Date(a.remindAt).getTime() - new Date(b.remindAt).getTime()); + + const reminderList = reminders + .slice(0, 10) + .map((r, i) => { + const remindAt = new Date(r.remindAt); + const text = r.reminderText.length > 50 ? r.reminderText.slice(0, 47) + '...' : r.reminderText; + return `**${i + 1}.** ${discordTimestamp(remindAt, 'R')}\nโ”” \`${r.id}\`: ${text}`; + }) + .join('\n\n'); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.info) + .setTitle('๐Ÿ“‹ Your Reminders') + .setDescription(reminderList) + .setFooter({ + text: `Showing ${Math.min(reminders.length, 10)} of ${reminders.length} reminders`, + }), + ], + ephemeral: true, + }); +} + +/** + * Handle canceling a reminder + */ +async function handleCancel( + interaction: ChatInputCommandInteraction, + client: EllyClient, + reminderRepo: ReminderRepository +): Promise { + const reminderId = interaction.options.getString('id', true); + + const reminder = reminderRepo.getById(reminderId); + if (!reminder) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Reminder Not Found') + .setDescription(`Could not find a reminder with ID \`${reminderId}\`.`), + ], + ephemeral: true, + }); + return; + } + + if (reminder.userId !== interaction.user.id) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Not Your Reminder') + .setDescription('You can only cancel your own reminders.'), + ], + ephemeral: true, + }); + return; + } + + reminderRepo.delete(reminderId); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Reminder Cancelled') + .setDescription(`Cancelled reminder:\n\`\`\`${reminder.reminderText}\`\`\``), + ], + ephemeral: true, + }); +} diff --git a/src/commands/utility/role.ts b/src/commands/utility/role.ts new file mode 100644 index 0000000..85fc87e --- /dev/null +++ b/src/commands/utility/role.ts @@ -0,0 +1,261 @@ +/** + * Role Command + * Manage user roles (for officers) + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + type ChatInputCommandInteraction, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; + +export const roleCommand: Command = { + data: new SlashCommandBuilder() + .setName('role') + .setDescription('Manage user roles') + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add a role to a user') + .addUserOption((opt) => + opt.setName('user').setDescription('User to add role to').setRequired(true) + ) + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to add').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove a role from a user') + .addUserOption((opt) => + opt.setName('user').setDescription('User to remove role from').setRequired(true) + ) + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to remove').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List manageable roles') + ), + + permission: PermissionLevel.Officer, + cooldown: 3, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'add': + await handleAdd(interaction, client); + break; + case 'remove': + await handleRemove(interaction, client); + break; + case 'list': + await handleList(interaction, client); + break; + } + }, +}; + +/** + * Handle adding a role + */ +async function handleAdd( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const targetUser = interaction.options.getUser('user', true); + const role = interaction.options.getRole('role', true); + + // Check if role is manageable + const manageableIds = client.config.roles.manageable?.ids ?? []; + if (!manageableIds.includes(role.id)) { + await interaction.reply({ + content: `โŒ The role **${role.name}** is not in the list of manageable roles.`, + ephemeral: true, + }); + return; + } + + const member = interaction.guild?.members.cache.get(targetUser.id); + if (!member) { + await interaction.reply({ + content: 'โŒ User not found in this server.', + ephemeral: true, + }); + return; + } + + // Check if user already has the role + if (member.roles.cache.has(role.id)) { + await interaction.reply({ + content: `โŒ ${targetUser.tag} already has the **${role.name}** role.`, + ephemeral: true, + }); + return; + } + + try { + await member.roles.add(role.id); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Role Added') + .setDescription(`Added **${role.name}** to ${targetUser.tag}`) + .setFooter({ text: `By ${interaction.user.tag}` }) + .setTimestamp(), + ], + }); + + // Log to development channel + await logRoleChange(client, interaction, targetUser.tag, role.name, 'added'); + } catch (error) { + await interaction.reply({ + content: `โŒ Failed to add role. Make sure I have the required permissions.`, + ephemeral: true, + }); + } +} + +/** + * Handle removing a role + */ +async function handleRemove( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const targetUser = interaction.options.getUser('user', true); + const role = interaction.options.getRole('role', true); + + // Check if role is manageable + const manageableIds = client.config.roles.manageable?.ids ?? []; + if (!manageableIds.includes(role.id)) { + await interaction.reply({ + content: `โŒ The role **${role.name}** is not in the list of manageable roles.`, + ephemeral: true, + }); + return; + } + + const member = interaction.guild?.members.cache.get(targetUser.id); + if (!member) { + await interaction.reply({ + content: 'โŒ User not found in this server.', + ephemeral: true, + }); + return; + } + + // Check if user has the role + if (!member.roles.cache.has(role.id)) { + await interaction.reply({ + content: `โŒ ${targetUser.tag} doesn't have the **${role.name}** role.`, + ephemeral: true, + }); + return; + } + + try { + await member.roles.remove(role.id); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('โœ… Role Removed') + .setDescription(`Removed **${role.name}** from ${targetUser.tag}`) + .setFooter({ text: `By ${interaction.user.tag}` }) + .setTimestamp(), + ], + }); + + // Log to development channel + await logRoleChange(client, interaction, targetUser.tag, role.name, 'removed'); + } catch (error) { + await interaction.reply({ + content: `โŒ Failed to remove role. Make sure I have the required permissions.`, + ephemeral: true, + }); + } +} + +/** + * Handle listing manageable roles + */ +async function handleList( + interaction: ChatInputCommandInteraction, + client: EllyClient +): Promise { + const manageableIds = client.config.roles.manageable?.ids ?? []; + + if (manageableIds.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ No manageable roles configured.', + ephemeral: true, + }); + return; + } + + const roles = manageableIds + .map((id) => interaction.guild?.roles.cache.get(id)) + .filter((r) => r !== undefined) + .map((r) => `โ€ข <@&${r!.id}> (\`${r!.id}\`)`); + + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ“‹ Manageable Roles') + .setDescription(roles.join('\n') || 'No roles found') + .setFooter({ text: `${roles.length} roles can be managed` }) + .setTimestamp(), + ], + ephemeral: true, + }); +} + +/** + * Log role change to development channel + */ +async function logRoleChange( + client: EllyClient, + interaction: ChatInputCommandInteraction, + targetTag: string, + roleName: string, + action: 'added' | 'removed' +): Promise { + try { + const logChannelName = client.config.channels.developmentLogs; + const logChannel = interaction.guild?.channels.cache.find( + (c) => c.name === logChannelName && c.isTextBased() + ); + + if (logChannel && logChannel.isTextBased()) { + await logChannel.send({ + embeds: [ + new EmbedBuilder() + .setColor(action === 'added' ? 0x57f287 : 0xfee75c) + .setTitle(`๐Ÿ“ Role ${action === 'added' ? 'Added' : 'Removed'}`) + .addFields( + { name: 'User', value: targetTag, inline: true }, + { name: 'Role', value: roleName, inline: true }, + { name: 'By', value: interaction.user.tag, inline: true } + ) + .setTimestamp(), + ], + }); + } + } catch { + // Ignore logging errors + } +} diff --git a/src/commands/utility/staff.ts b/src/commands/utility/staff.ts new file mode 100644 index 0000000..5d518fb --- /dev/null +++ b/src/commands/utility/staff.ts @@ -0,0 +1,402 @@ +/** + * Staff Simulator Command + * A fun game to simulate being a staff member + */ + +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + type ButtonInteraction, + ComponentType, +} from 'discord.js'; +import type { Command } from '../../types/index.ts'; +import { PermissionLevel } from '../../types/index.ts'; +import type { EllyClient } from '../../client/EllyClient.ts'; +import { StaffRepository } from '../../database/repositories/StaffRepository.ts'; + +// Scenarios for the staff simulator +const SCENARIOS = { + appeal: [ + { + title: 'Ban Appeal', + description: 'A player claims they were falsely banned for "hacking" but they say they were just using an FPS boost mod.', + correct: 'investigate', + options: ['Accept Appeal', 'Deny Appeal', 'Investigate Further'], + }, + { + title: 'Mute Appeal', + description: 'Player was muted for spam but claims they only sent 3 messages. Logs show 15 messages in 10 seconds.', + correct: 'deny', + options: ['Accept Appeal', 'Deny Appeal', 'Reduce Punishment'], + }, + { + title: 'Warning Appeal', + description: 'Player received a warning for advertising but says they were just sharing a YouTube video with friends.', + correct: 'accept', + options: ['Accept Appeal', 'Deny Appeal', 'Keep Warning'], + }, + ], + report: [ + { + title: 'Hacker Report', + description: 'A player reports another for "flying" in BedWars. The video shows suspicious movement but could be lag.', + correct: 'investigate', + options: ['Ban Player', 'Dismiss Report', 'Investigate Further'], + }, + { + title: 'Chat Report', + description: 'Report of a player using racial slurs in chat. Screenshots provided show clear evidence.', + correct: 'punish', + options: ['Mute Player', 'Warn Player', 'Dismiss Report'], + }, + { + title: 'Teaming Report', + description: 'Player reports teaming in Solo SkyWars. Video shows two players not attacking each other.', + correct: 'warn', + options: ['Ban Both', 'Warn Both', 'Dismiss Report'], + }, + ], + assist: [ + { + title: 'New Player Help', + description: 'A new player asks how to join a BedWars game. They seem confused about the lobby system.', + correct: 'guide', + options: ['Send Wiki Link', 'Guide Them Step-by-Step', 'Tell Them to Figure It Out'], + }, + { + title: 'Bug Report', + description: 'Player reports items disappearing from their inventory. Could be a bug or user error.', + correct: 'escalate', + options: ['Dismiss as User Error', 'Escalate to Developers', 'Give Replacement Items'], + }, + ], +}; + +export const staffCommand: Command = { + data: new SlashCommandBuilder() + .setName('staff') + .setDescription('Staff simulator game') + .addSubcommand((sub) => + sub + .setName('play') + .setDescription('Play a staff simulation scenario') + ) + .addSubcommand((sub) => + sub + .setName('stats') + .setDescription('View your staff simulator stats') + .addUserOption((opt) => + opt.setName('user').setDescription('User to view stats for').setRequired(false) + ) + ) + .addSubcommand((sub) => + sub + .setName('leaderboard') + .setDescription('View the staff simulator leaderboard') + ), + + permission: PermissionLevel.User, + cooldown: 5, + + async execute(interaction: ChatInputCommandInteraction): Promise { + const client = interaction.client as EllyClient; + const subcommand = interaction.options.getSubcommand(); + const repo = new StaffRepository(client.database); + + switch (subcommand) { + case 'play': + await handlePlay(interaction, client, repo); + break; + case 'stats': + await handleStats(interaction, client, repo); + break; + case 'leaderboard': + await handleLeaderboard(interaction, client, repo); + break; + } + }, +}; + +/** + * Handle playing a scenario + */ +async function handlePlay( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: StaffRepository +): Promise { + // Pick random scenario type and scenario + const types = Object.keys(SCENARIOS) as Array; + const type = types[Math.floor(Math.random() * types.length)]; + const scenarios = SCENARIOS[type]; + const scenario = scenarios[Math.floor(Math.random() * scenarios.length)]; + + // Create buttons for options + const row = new ActionRowBuilder().addComponents( + ...scenario.options.map((option, index) => + new ButtonBuilder() + .setCustomId(`staff:${index}:${scenario.correct}`) + .setLabel(option) + .setStyle(ButtonStyle.Secondary) + ) + ); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐ŸŽฎ Staff Simulator - ${scenario.title}`) + .setDescription(scenario.description) + .addFields({ name: '๐Ÿ“‹ Scenario Type', value: type.charAt(0).toUpperCase() + type.slice(1), inline: true }) + .setFooter({ text: 'Choose the best action!' }) + .setTimestamp(); + + const response = await interaction.reply({ + embeds: [embed], + components: [row], + fetchReply: true, + }); + + // Wait for response + try { + const buttonInteraction = await response.awaitMessageComponent({ + componentType: ComponentType.Button, + filter: (i) => i.user.id === interaction.user.id, + time: 30000, + }); + + await handleScenarioResponse(buttonInteraction, client, repo, type, scenario); + } catch { + // Timeout + const timeoutEmbed = new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('โฐ Time\'s Up!') + .setDescription('You took too long to respond. No points awarded.') + .setTimestamp(); + + await interaction.editReply({ + embeds: [timeoutEmbed], + components: [], + }); + } +} + +/** + * Handle scenario response + */ +async function handleScenarioResponse( + interaction: ButtonInteraction, + client: EllyClient, + repo: StaffRepository, + type: string, + scenario: { title: string; correct: string; options: string[] } +): Promise { + const [, choiceIndex, correctAnswer] = interaction.customId.split(':'); + const choice = parseInt(choiceIndex); + + // Determine if correct based on the option chosen + const isCorrect = determineCorrectness(choice, correctAnswer, scenario.options); + + // Map type to action type + const actionTypeMap: Record = { + appeal: 'appeal', + report: 'report', + assist: 'assist', + }; + + const actionType = actionTypeMap[type] ?? 'assist'; + + let resultEmbed: EmbedBuilder; + + if (isCorrect) { + // Award points + const result = await repo.addAction( + interaction.user.id, + interaction.user.tag, + actionType, + `Completed ${scenario.title} scenario correctly` + ); + + resultEmbed = new EmbedBuilder() + .setColor(client.config.colors.success) + .setTitle('โœ… Correct!') + .setDescription(`Great job! You made the right call on this ${type}.`) + .addFields( + { name: '๐ŸŽฏ Points Earned', value: `+${getPoints(actionType)}`, inline: true }, + { name: '๐Ÿ“Š Total Points', value: String(result.progress.totalPoints), inline: true }, + { name: '๐Ÿ† Level', value: `${result.progress.level} (${repo.getLevelTitle(result.progress.level)})`, inline: true } + ); + + if (result.leveledUp) { + resultEmbed.addFields({ + name: '๐ŸŽ‰ Level Up!', + value: `Congratulations! You reached level ${result.newLevel} - ${repo.getLevelTitle(result.newLevel)}!`, + }); + } + } else { + resultEmbed = new EmbedBuilder() + .setColor(client.config.colors.error) + .setTitle('โŒ Incorrect') + .setDescription(`That wasn't the best choice for this ${type}. Try again!`) + .addFields({ + name: '๐Ÿ’ก Tip', + value: getHint(correctAnswer), + }); + } + + resultEmbed.setTimestamp(); + + await interaction.update({ + embeds: [resultEmbed], + components: [], + }); +} + +/** + * Determine if the choice was correct + */ +function determineCorrectness(choice: number, correct: string, options: string[]): boolean { + const chosenOption = options[choice].toLowerCase(); + + switch (correct) { + case 'investigate': + return chosenOption.includes('investigate'); + case 'deny': + return chosenOption.includes('deny'); + case 'accept': + return chosenOption.includes('accept'); + case 'punish': + return chosenOption.includes('mute') || chosenOption.includes('ban'); + case 'warn': + return chosenOption.includes('warn'); + case 'guide': + return chosenOption.includes('guide') || chosenOption.includes('step'); + case 'escalate': + return chosenOption.includes('escalate') || chosenOption.includes('developer'); + default: + return false; + } +} + +/** + * Get points for action type + */ +function getPoints(actionType: string): number { + const points: Record = { + appeal: 10, + punishment: 5, + report: 8, + assist: 3, + }; + return points[actionType] ?? 5; +} + +/** + * Get hint for correct answer + */ +function getHint(correct: string): string { + const hints: Record = { + investigate: 'When evidence is unclear, it\'s best to investigate further before making a decision.', + deny: 'If the evidence clearly shows the player violated rules, the appeal should be denied.', + accept: 'If the punishment was unjustified or too harsh, consider accepting the appeal.', + punish: 'Clear rule violations with evidence should result in appropriate punishment.', + warn: 'For first-time or minor offenses, a warning is often the best approach.', + guide: 'New players benefit most from patient, step-by-step guidance.', + escalate: 'Technical issues should be escalated to the appropriate team.', + }; + return hints[correct] ?? 'Consider all the evidence before making a decision.'; +} + +/** + * Handle viewing stats + */ +async function handleStats( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: StaffRepository +): Promise { + const targetUser = interaction.options.getUser('user') ?? interaction.user; + const progress = await repo.getByUserId(targetUser.id); + + if (!progress) { + await interaction.reply({ + content: `${targetUser.id === interaction.user.id ? 'You haven\'t' : `${targetUser.tag} hasn't`} played the staff simulator yet!`, + ephemeral: true, + }); + return; + } + + const rank = await repo.getRank(targetUser.id); + const nextLevelPoints = repo.getPointsForNextLevel(progress.level); + const progressToNext = nextLevelPoints > 0 + ? Math.round((progress.totalPoints / nextLevelPoints) * 100) + : 100; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle(`๐Ÿ“Š ${targetUser.tag}'s Staff Stats`) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: '๐Ÿ† Level', value: `${progress.level} (${repo.getLevelTitle(progress.level)})`, inline: true }, + { name: 'โญ Total Points', value: String(progress.totalPoints), inline: true }, + { name: '๐Ÿ… Rank', value: `#${rank}`, inline: true }, + { name: '๐Ÿ“ Appeals Handled', value: String(progress.appealsHandled), inline: true }, + { name: 'โš–๏ธ Punishments', value: String(progress.punishmentsIssued), inline: true }, + { name: '๐Ÿ“‹ Reports', value: String(progress.reportsHandled), inline: true }, + { name: '๐Ÿค Assists', value: String(progress.assistsGiven), inline: true }, + { + name: '๐Ÿ“ˆ Progress to Next Level', + value: nextLevelPoints > 0 + ? `${progress.totalPoints}/${nextLevelPoints} (${progressToNext}%)` + : 'Max Level!', + inline: true + } + ) + .setFooter({ text: `Last active: ${new Date(progress.lastActive).toLocaleDateString()}` }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); +} + +/** + * Handle leaderboard + */ +async function handleLeaderboard( + interaction: ChatInputCommandInteraction, + client: EllyClient, + repo: StaffRepository +): Promise { + const leaderboard = await repo.getLeaderboard(10); + + if (leaderboard.length === 0) { + await interaction.reply({ + content: '๐Ÿ“ญ No one has played the staff simulator yet!', + ephemeral: true, + }); + return; + } + + const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; + const lines = leaderboard.map((entry, index) => { + const medal = medals[index] ?? `**${index + 1}.**`; + return `${medal} ${entry.username} - ${entry.totalPoints} pts (Lvl ${entry.level})`; + }); + + const userRank = await repo.getRank(interaction.user.id); + const userProgress = await repo.getByUserId(interaction.user.id); + + const embed = new EmbedBuilder() + .setColor(client.config.colors.primary) + .setTitle('๐Ÿ† Staff Simulator Leaderboard') + .setDescription(lines.join('\n')) + .setFooter({ + text: userProgress + ? `Your rank: #${userRank} with ${userProgress.totalPoints} points` + : 'Play /staff play to get on the leaderboard!' + }) + .setTimestamp(); + + await interaction.reply({ embeds: [embed] }); +} diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 0000000..e751662 --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,427 @@ +/** + * Configuration loader for Elly Discord Bot + * Parses TOML configuration file and provides typed access + */ + +import type { Config } from './types.ts'; + +// Validation error types +export interface ConfigValidationError { + field: string; + message: string; + severity: 'error' | 'warning'; +} + +export interface ConfigValidationResult { + valid: boolean; + errors: ConfigValidationError[]; + warnings: ConfigValidationError[]; +} + +/** + * Simple TOML parser for configuration files + * Handles basic TOML structures: strings, numbers, booleans, arrays, and tables + */ +function parseTOML(content: string): Record { + const result: Record = {}; + const lines = content.split('\n'); + let currentSection: string[] = []; + let multiLineArray: { key: string; content: string } | null = null; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i].trim(); + + // Skip empty lines and comments (unless in multi-line array) + if (!multiLineArray && (!line || line.startsWith('#'))) continue; + + // Handle multi-line array continuation + if (multiLineArray) { + // Remove comments from array lines + const commentIdx = line.indexOf('#'); + if (commentIdx > 0) { + line = line.substring(0, commentIdx).trim(); + } + + multiLineArray.content += ' ' + line; + + // Check if array is complete + if (line.includes(']')) { + const parsedValue = parseValue(multiLineArray.content.trim()); + + // Set the value in the correct location + let current = result; + for (const part of currentSection) { + current = current[part] as Record; + } + current[multiLineArray.key] = parsedValue; + multiLineArray = null; + } + continue; + } + + // Remove inline comments (but not inside strings) + const commentIndex = line.indexOf('#'); + if (commentIndex > 0) { + // Check if # is inside a string + const beforeComment = line.substring(0, commentIndex); + const quoteCount = (beforeComment.match(/"/g) || []).length; + if (quoteCount % 2 === 0) { + line = beforeComment.trim(); + } + } + + // Handle section headers [section] or [section.subsection] + if (line.startsWith('[') && line.endsWith(']')) { + const sectionPath = line.slice(1, -1).trim(); + currentSection = sectionPath.split('.'); + + // Ensure nested structure exists + let current = result; + for (const part of currentSection) { + if (!(part in current)) { + current[part] = {}; + } + current = current[part] as Record; + } + continue; + } + + // Handle key-value pairs + const equalsIndex = line.indexOf('='); + if (equalsIndex > 0) { + const key = line.substring(0, equalsIndex).trim(); + let value = line.substring(equalsIndex + 1).trim(); + + // Check for multi-line array + if (value.startsWith('[') && !value.includes(']')) { + multiLineArray = { key, content: value }; + continue; + } + + // Parse the value + const parsedValue = parseValue(value); + + // Set the value in the correct location + let current = result; + for (const part of currentSection) { + current = current[part] as Record; + } + current[key] = parsedValue; + } + } + + return result; +} + +/** + * Parse a TOML value string into its JavaScript equivalent + */ +function parseValue(value: string): unknown { + value = value.trim(); + + // String (double-quoted) + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\n/g, '\n'); + } + + // String (single-quoted - literal) + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + + // Array + if (value.startsWith('[') && value.endsWith(']')) { + const arrayContent = value.slice(1, -1).trim(); + if (!arrayContent) return []; + + const items: unknown[] = []; + let current = ''; + let depth = 0; + let inString = false; + let stringChar = ''; + + for (let i = 0; i < arrayContent.length; i++) { + const char = arrayContent[i]; + + if (!inString && (char === '"' || char === "'")) { + inString = true; + stringChar = char; + current += char; + } else if (inString && char === stringChar && arrayContent[i - 1] !== '\\') { + inString = false; + current += char; + } else if (!inString && char === '[') { + depth++; + current += char; + } else if (!inString && char === ']') { + depth--; + current += char; + } else if (!inString && char === ',' && depth === 0) { + items.push(parseValue(current.trim())); + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + items.push(parseValue(current.trim())); + } + + return items; + } + + // Boolean + if (value === 'true') return true; + if (value === 'false') return false; + + // Hexadecimal number + if (value.startsWith('0x') || value.startsWith('0X')) { + return parseInt(value, 16); + } + + // Number (integer or float) + if (/^-?\d+(\.\d+)?$/.test(value)) { + return value.includes('.') ? parseFloat(value) : parseInt(value, 10); + } + + // Return as string if nothing else matches + return value; +} + +/** + * Load and parse the configuration file + */ +export async function loadConfig(path: string = './config.toml'): Promise { + try { + const content = await Deno.readTextFile(path); + const parsed = parseTOML(content); + return parsed as unknown as Config; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new Error(`Configuration file not found: ${path}`); + } + throw new Error(`Failed to load configuration: ${error}`); + } +} + +/** + * Validate the configuration object with detailed error reporting + */ +export function validateConfig(config: Config): ConfigValidationResult { + const errors: ConfigValidationError[] = []; + const warnings: ConfigValidationError[] = []; + + // Helper to check if a value exists at a path + const getValue = (path: string): unknown => { + const parts = path.split('.'); + let current: unknown = config; + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[part]; + } + return current; + }; + + // Helper to add error + const addError = (field: string, message: string) => { + errors.push({ field, message, severity: 'error' }); + }; + + // Helper to add warning + const addWarning = (field: string, message: string) => { + warnings.push({ field, message, severity: 'warning' }); + }; + + // ===== Required Fields ===== + const requiredFields = [ + { path: 'bot.name', type: 'string', description: 'Bot name' }, + { path: 'bot.prefix', type: 'string', description: 'Command prefix' }, + { path: 'database.path', type: 'string', description: 'Database path' }, + { path: 'guild.id', type: 'string', description: 'Guild ID' }, + ]; + + for (const { path, type, description } of requiredFields) { + const value = getValue(path); + if (value === undefined || value === null) { + addError(path, `Missing required field: ${description}`); + } else if (typeof value !== type) { + addError(path, `Invalid type for ${description}: expected ${type}, got ${typeof value}`); + } + } + + // ===== Bot Configuration ===== + if (config.bot) { + // Validate bot name length + if (config.bot.name && config.bot.name.length > 32) { + addError('bot.name', 'Bot name must be 32 characters or less'); + } + + // Validate activity type + const validActivityTypes = ['playing', 'streaming', 'listening', 'watching', 'competing']; + if (config.bot.activity_type && !validActivityTypes.includes(config.bot.activity_type)) { + addError('bot.activity_type', `Invalid activity type. Must be one of: ${validActivityTypes.join(', ')}`); + } + + // Validate owners + if (!config.bot.owners || !config.bot.owners.ids || config.bot.owners.ids.length === 0) { + addWarning('bot.owners', 'No bot owners configured. Some commands may be inaccessible.'); + } else { + for (const ownerId of config.bot.owners.ids) { + if (!/^\d{17,19}$/.test(ownerId)) { + addError('bot.owners.ids', `Invalid Discord user ID format: ${ownerId}`); + } + } + } + } + + // ===== Guild Configuration ===== + if (config.guild) { + // Validate guild ID format + if (config.guild.id && !/^\d{17,19}$/.test(config.guild.id)) { + addError('guild.id', 'Invalid Discord guild ID format'); + } + } + + // ===== Database Configuration ===== + if (config.database) { + // Validate database path + if (config.database.path) { + if (!config.database.path.endsWith('.db') && !config.database.path.endsWith('.sqlite')) { + addWarning('database.path', 'Database path should end with .db or .sqlite'); + } + } + } + + // ===== API Configuration ===== + if (config.api) { + if (config.api.pika_cache_ttl !== undefined && config.api.pika_cache_ttl < 0) { + addError('api.pika_cache_ttl', 'Cache TTL must be a positive number'); + } + if (config.api.pika_request_timeout !== undefined && config.api.pika_request_timeout < 1000) { + addWarning('api.pika_request_timeout', 'Request timeout is very low (< 1000ms)'); + } + } + + // ===== Channels Configuration ===== + if (config.channels) { + const channelFields = [ + 'applications', 'application_logs', 'suggestions', 'suggestion_logs', + 'guild_updates', 'discord_changelog', 'inactivity', 'development_logs', + 'donations', 'reminders' + ]; + + for (const field of channelFields) { + const value = (config.channels as Record)[field]; + if (!value) { + addWarning(`channels.${field}`, `Channel not configured: ${field}`); + } + } + } else { + addWarning('channels', 'No channels configured'); + } + + // ===== Roles Configuration ===== + if (config.roles) { + const roleFields = [ + 'admin', 'leader', 'officer', 'developer', 'guild_member', + 'champion', 'away', 'applications_blacklisted', 'suggestions_blacklisted' + ]; + + for (const field of roleFields) { + const value = (config.roles as Record)[field]; + if (!value) { + addWarning(`roles.${field}`, `Role not configured: ${field}`); + } + } + } else { + addWarning('roles', 'No roles configured'); + } + + // ===== Features Configuration ===== + if (config.features) { + const featureFields = [ + 'applications', 'suggestions', 'statistics', 'family', 'qotd', + 'reminders', 'staff_simulator', 'channel_filtering', 'auto_moderation', + 'welcome_system', 'level_system' + ]; + + for (const field of featureFields) { + const value = (config.features as Record)[field]; + if (value === undefined) { + addWarning(`features.${field}`, `Feature flag not set: ${field} (defaulting to false)`); + } + } + } + + // ===== Limits Configuration ===== + if (config.limits) { + if (config.limits.champion_max_days !== undefined && config.limits.champion_max_days < 1) { + addError('limits.champion_max_days', 'Champion max days must be at least 1'); + } + if (config.limits.away_max_days !== undefined && config.limits.away_max_days < 1) { + addError('limits.away_max_days', 'Away max days must be at least 1'); + } + if (config.limits.purge_max_messages !== undefined) { + if (config.limits.purge_max_messages < 1 || config.limits.purge_max_messages > 100) { + addError('limits.purge_max_messages', 'Purge max messages must be between 1 and 100'); + } + } + } + + // ===== Colors Configuration ===== + if (config.colors) { + const colorFields = ['primary', 'success', 'warning', 'error', 'info']; + for (const field of colorFields) { + const value = (config.colors as Record)[field]; + if (value !== undefined && (typeof value !== 'number' || value < 0 || value > 0xFFFFFF)) { + addError(`colors.${field}`, `Invalid color value: must be a number between 0 and 16777215 (0xFFFFFF)`); + } + } + } + + // ===== Logging Configuration ===== + if (config.logging) { + const validLogLevels = ['debug', 'info', 'warn', 'error']; + if (config.logging.level && !validLogLevels.includes(config.logging.level)) { + addError('logging.level', `Invalid log level. Must be one of: ${validLogLevels.join(', ')}`); + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate config and throw if invalid (for backwards compatibility) + */ +export function validateConfigOrThrow(config: Config): void { + const result = validateConfig(config); + + if (!result.valid) { + const errorMessages = result.errors.map((e) => ` - ${e.field}: ${e.message}`).join('\n'); + throw new Error(`Configuration validation failed:\n${errorMessages}`); + } +} + +/** + * Get a configuration value by path (e.g., "bot.name") + */ +export function getConfigValue(config: Config, path: string): T | undefined { + const parts = path.split('.'); + let current: unknown = config; + + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[part]; + } + + return current as T; +} diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..4987612 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,98 @@ +/** + * Configuration type definitions for Elly Discord Bot + */ + +export interface BotConfig { + name: string; + prefix: string; + status: string; + activity_type: 'playing' | 'streaming' | 'listening' | 'watching' | 'competing'; + owners: { ids: string[] }; +} + +export interface DatabaseConfig { + path: string; +} + +export interface APIConfig { + pika_cache_ttl: number; + pika_request_timeout: number; +} + +export interface GuildConfig { + id: string; + name: string; +} + +export interface ChannelsConfig { + applications: string; + application_logs: string; + suggestions: string; + suggestion_logs: string; + guild_updates: string; + discord_changelog: string; + inactivity: string; + development_logs: string; + donations: string; + reminders: string; +} + +export interface RolesConfig { + admin: string; + leader: string; + officer: string; + developer: string; + guild_member: string; + champion: string; + away: string; + applications_blacklisted: string; + suggestions_blacklisted: string; + manageable: { ids: string[] }; +} + +export interface FeaturesConfig { + applications: boolean; + suggestions: boolean; + statistics: boolean; + family: boolean; + qotd: boolean; + reminders: boolean; + staff_simulator: boolean; + channel_filtering: boolean; + auto_moderation: boolean; + welcome_system: boolean; + level_system: boolean; +} + +export interface LimitsConfig { + champion_max_days: number; + away_max_days: number; + purge_max_messages: number; + reminder_max_duration_days: number; +} + +export interface ColorsConfig { + primary: number; + success: number; + warning: number; + error: number; + info: number; +} + +export interface LoggingConfig { + level: 'debug' | 'info' | 'warn' | 'error'; + file: string; +} + +export interface Config { + bot: BotConfig; + database: DatabaseConfig; + api: APIConfig; + guild: GuildConfig; + channels: ChannelsConfig; + roles: RolesConfig; + features: FeaturesConfig; + limits: LimitsConfig; + colors: ColorsConfig; + logging: LoggingConfig; +} diff --git a/src/database/BaseRepository.ts b/src/database/BaseRepository.ts new file mode 100644 index 0000000..0c73958 --- /dev/null +++ b/src/database/BaseRepository.ts @@ -0,0 +1,382 @@ +/** + * Base Repository + * Provides common database operations with error handling + */ + +import type { SQLiteDatabase, QueryResult } from './sqlite.ts'; +import { createLogger } from '../utils/logger.ts'; + +const logger = createLogger('Repository'); + +// ============================================================================ +// Repository Error Types +// ============================================================================ + +export class RepositoryError extends Error { + constructor( + message: string, + public readonly repository: string, + public readonly operation: string, + public readonly cause?: Error + ) { + super(message); + this.name = 'RepositoryError'; + } +} + +export class NotFoundError extends RepositoryError { + constructor(repository: string, id: string) { + super(`${repository} with id '${id}' not found`, repository, 'find'); + this.name = 'NotFoundError'; + } +} + +export class DuplicateError extends RepositoryError { + constructor(repository: string, field: string, value: string) { + super(`${repository} with ${field} '${value}' already exists`, repository, 'create'); + this.name = 'DuplicateError'; + } +} + +export class ValidationError extends RepositoryError { + constructor(repository: string, message: string) { + super(message, repository, 'validate'); + this.name = 'ValidationError'; + } +} + +// ============================================================================ +// Result Types +// ============================================================================ + +export type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +export function ok(value: T): Result { + return { ok: true, value }; +} + +export function err(error: E): Result { + return { ok: false, error }; +} + +// ============================================================================ +// Base Repository Class +// ============================================================================ + +export abstract class BaseRepository { + protected readonly tableName: string; + protected readonly repositoryName: string; + + constructor( + protected readonly db: SQLiteDatabase, + tableName: string, + repositoryName?: string + ) { + this.tableName = tableName; + this.repositoryName = repositoryName ?? tableName; + } + + // ========================================================================= + // Protected Helper Methods + // ========================================================================= + + /** + * Generate a unique ID + */ + protected generateId(prefix: string = ''): string { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 8); + return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`; + } + + /** + * Get current timestamp + */ + protected now(): number { + return Date.now(); + } + + /** + * Handle query result and convert to Result type + */ + protected handleQueryResult( + result: QueryResult, + operation: string + ): Result { + if (result.success && result.data !== undefined) { + return ok(result.data); + } + + const error = new RepositoryError( + result.error?.message ?? 'Unknown error', + this.repositoryName, + operation, + result.error + ); + + logger.error(`${this.repositoryName}.${operation} failed:`, error); + return err(error); + } + + /** + * Handle execute result + */ + protected handleExecuteResult( + result: QueryResult, + operation: string + ): Result<{ rowsAffected: number; lastInsertRowId?: number }> { + if (result.success) { + return ok({ + rowsAffected: result.rowsAffected ?? 0, + lastInsertRowId: result.lastInsertRowId, + }); + } + + const error = new RepositoryError( + result.error?.message ?? 'Unknown error', + this.repositoryName, + operation, + result.error + ); + + logger.error(`${this.repositoryName}.${operation} failed:`, error); + return err(error); + } + + /** + * Convert database row to entity + */ + protected abstract rowToEntity(row: Record): T; + + /** + * Convert entity to database row + */ + protected abstract entityToRow(entity: T): Record; + + // ========================================================================= + // Common CRUD Operations + // ========================================================================= + + /** + * Find by ID + */ + async findById(id: string): Promise> { + const result = this.db.queryOne>( + `SELECT * FROM ${this.tableName} WHERE id = ?`, + [id] + ); + + if (!result.success) { + return this.handleQueryResult(result, 'findById'); + } + + if (!result.data) { + return ok(null); + } + + try { + return ok(this.rowToEntity(result.data)); + } catch (error) { + return err(new RepositoryError( + `Failed to convert row to entity: ${error}`, + this.repositoryName, + 'findById' + )); + } + } + + /** + * Find all + */ + async findAll(limit?: number, offset?: number): Promise> { + let sql = `SELECT * FROM ${this.tableName}`; + const params: unknown[] = []; + + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(limit); + } + + if (offset !== undefined) { + sql += ' OFFSET ?'; + params.push(offset); + } + + const result = this.db.query>(sql, params); + + if (!result.success) { + return this.handleQueryResult(result, 'findAll'); + } + + try { + const entities = (result.data ?? []).map((row) => this.rowToEntity(row)); + return ok(entities); + } catch (error) { + return err(new RepositoryError( + `Failed to convert rows to entities: ${error}`, + this.repositoryName, + 'findAll' + )); + } + } + + /** + * Count all records + */ + async count(): Promise> { + const result = this.db.queryOne<{ count: number }>( + `SELECT COUNT(*) as count FROM ${this.tableName}` + ); + + if (!result.success) { + return this.handleQueryResult(result, 'count'); + } + + return ok(result.data?.count ?? 0); + } + + /** + * Delete by ID + */ + async deleteById(id: string): Promise> { + const result = this.db.execute( + `DELETE FROM ${this.tableName} WHERE id = ?`, + [id] + ); + + const handled = this.handleExecuteResult(result, 'deleteById'); + if (!handled.ok) { + return err(handled.error); + } + + return ok(handled.value.rowsAffected > 0); + } + + /** + * Check if exists + */ + async exists(id: string): Promise> { + const result = this.db.queryOne<{ count: number }>( + `SELECT COUNT(*) as count FROM ${this.tableName} WHERE id = ?`, + [id] + ); + + if (!result.success) { + return this.handleQueryResult(result, 'exists'); + } + + return ok((result.data?.count ?? 0) > 0); + } + + /** + * Find with custom where clause + */ + protected async findWhere( + whereClause: string, + params: unknown[] = [], + orderBy?: string, + limit?: number + ): Promise> { + let sql = `SELECT * FROM ${this.tableName} WHERE ${whereClause}`; + + if (orderBy) { + sql += ` ORDER BY ${orderBy}`; + } + + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(limit); + } + + const result = this.db.query>(sql, params); + + if (!result.success) { + return this.handleQueryResult(result, 'findWhere'); + } + + try { + const entities = (result.data ?? []).map((row) => this.rowToEntity(row)); + return ok(entities); + } catch (error) { + return err(new RepositoryError( + `Failed to convert rows to entities: ${error}`, + this.repositoryName, + 'findWhere' + )); + } + } + + /** + * Find one with custom where clause + */ + protected async findOneWhere( + whereClause: string, + params: unknown[] = [] + ): Promise> { + const result = this.db.queryOne>( + `SELECT * FROM ${this.tableName} WHERE ${whereClause}`, + params + ); + + if (!result.success) { + return this.handleQueryResult(result, 'findOneWhere'); + } + + if (!result.data) { + return ok(null); + } + + try { + return ok(this.rowToEntity(result.data)); + } catch (error) { + return err(new RepositoryError( + `Failed to convert row to entity: ${error}`, + this.repositoryName, + 'findOneWhere' + )); + } + } + + /** + * Update with custom set clause + */ + protected async updateWhere( + setClause: string, + whereClause: string, + params: unknown[] + ): Promise> { + const result = this.db.execute( + `UPDATE ${this.tableName} SET ${setClause} WHERE ${whereClause}`, + params + ); + + const handled = this.handleExecuteResult(result, 'updateWhere'); + if (!handled.ok) { + return err(handled.error); + } + + return ok(handled.value.rowsAffected); + } + + /** + * Delete with custom where clause + */ + protected async deleteWhere( + whereClause: string, + params: unknown[] + ): Promise> { + const result = this.db.execute( + `DELETE FROM ${this.tableName} WHERE ${whereClause}`, + params + ); + + const handled = this.handleExecuteResult(result, 'deleteWhere'); + if (!handled.ok) { + return err(handled.error); + } + + return ok(handled.value.rowsAffected); + } +} diff --git a/src/database/DatabaseManager.ts b/src/database/DatabaseManager.ts new file mode 100644 index 0000000..2d898b5 --- /dev/null +++ b/src/database/DatabaseManager.ts @@ -0,0 +1,266 @@ +/** + * Database Manager + * Central manager for all database operations and repositories + */ + +import { SQLiteDatabase, createSQLiteDatabase, DatabaseError } from './sqlite.ts'; +import { initializeSchema, runMigrations } from './schema.ts'; +import { createLogger } from '../utils/logger.ts'; + +// Import repositories +import { FamilyRepositorySQLite } from './repositories/FamilyRepositorySQLite.ts'; +import { ReminderRepositorySQLite } from './repositories/ReminderRepositorySQLite.ts'; + +const logger = createLogger('DatabaseManager'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface DatabaseStats { + path: string; + size: number; + tables: string[]; + connected: boolean; +} + +// ============================================================================ +// Database Manager Class +// ============================================================================ + +export class DatabaseManager { + private db: SQLiteDatabase | null = null; + private readonly path: string; + private _isInitialized = false; + + // Repositories + private _families: FamilyRepositorySQLite | null = null; + private _reminders: ReminderRepositorySQLite | null = null; + + constructor(path: string) { + this.path = path; + } + + // ========================================================================= + // Initialization + // ========================================================================= + + /** + * Initialize the database + */ + async initialize(): Promise { + if (this._isInitialized) { + logger.warn('Database already initialized'); + return; + } + + try { + logger.info(`Initializing database at ${this.path}`); + + // Create database connection + this.db = await createSQLiteDatabase(this.path); + + // Initialize schema + await initializeSchema(this.db); + + // Run migrations + await runMigrations(this.db); + + // Initialize repositories + this._families = new FamilyRepositorySQLite(this.db); + this._reminders = new ReminderRepositorySQLite(this.db); + + this._isInitialized = true; + logger.info('Database initialized successfully'); + } catch (error) { + logger.error('Failed to initialize database:', error); + throw error; + } + } + + /** + * Check if initialized + */ + get isInitialized(): boolean { + return this._isInitialized; + } + + /** + * Ensure database is initialized + */ + private ensureInitialized(): void { + if (!this._isInitialized || !this.db) { + throw new DatabaseError('Database not initialized', 'NOT_INITIALIZED'); + } + } + + // ========================================================================= + // Repository Access + // ========================================================================= + + /** + * Get family repository + */ + get families(): FamilyRepositorySQLite { + this.ensureInitialized(); + return this._families!; + } + + /** + * Get reminder repository + */ + get reminders(): ReminderRepositorySQLite { + this.ensureInitialized(); + return this._reminders!; + } + + /** + * Get raw database connection (for custom queries) + */ + get connection(): SQLiteDatabase { + this.ensureInitialized(); + return this.db!; + } + + // ========================================================================= + // Database Operations + // ========================================================================= + + /** + * Execute a transaction + */ + async transaction(fn: () => Promise | T): Promise { + this.ensureInitialized(); + const result = await this.db!.transaction(fn); + + if (!result.success) { + throw result.error; + } + + return result.data!; + } + + /** + * Get database statistics + */ + async getStats(): Promise { + this.ensureInitialized(); + + const size = await this.db!.getSize(); + + const tablesResult = this.db!.query<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ); + + const tables = tablesResult.success + ? (tablesResult.data ?? []).map((r) => r.name) + : []; + + return { + path: this.path, + size, + tables, + connected: this.db!.connected, + }; + } + + /** + * Vacuum the database + */ + async vacuum(): Promise { + this.ensureInitialized(); + const result = this.db!.vacuum(); + + if (!result.success) { + throw result.error; + } + + logger.info('Database vacuumed'); + } + + /** + * Close the database connection + */ + close(): void { + if (this.db) { + this.db.close(); + this._isInitialized = false; + this._families = null; + this._reminders = null; + logger.info('Database connection closed'); + } + } + + // ========================================================================= + // Backup & Restore + // ========================================================================= + + /** + * Create a backup of the database + */ + async backup(backupPath: string): Promise { + this.ensureInitialized(); + + try { + await Deno.copyFile(this.path, backupPath); + logger.info(`Database backed up to ${backupPath}`); + } catch (error) { + logger.error('Failed to backup database:', error); + throw new DatabaseError( + `Failed to backup database: ${error}`, + 'BACKUP_ERROR' + ); + } + } + + /** + * Restore from a backup + */ + async restore(backupPath: string): Promise { + // Close current connection + this.close(); + + try { + await Deno.copyFile(backupPath, this.path); + logger.info(`Database restored from ${backupPath}`); + + // Reinitialize + await this.initialize(); + } catch (error) { + logger.error('Failed to restore database:', error); + throw new DatabaseError( + `Failed to restore database: ${error}`, + 'RESTORE_ERROR' + ); + } + } +} + +// ============================================================================ +// Singleton Instance +// ============================================================================ + +let instance: DatabaseManager | null = null; + +/** + * Create or get the database manager instance + */ +export async function createDatabaseManager(path: string): Promise { + if (instance && instance.isInitialized) { + return instance; + } + + instance = new DatabaseManager(path); + await instance.initialize(); + return instance; +} + +/** + * Get the current database manager instance + */ +export function getDatabaseManager(): DatabaseManager { + if (!instance || !instance.isInitialized) { + throw new DatabaseError('Database manager not initialized', 'NOT_INITIALIZED'); + } + return instance; +} diff --git a/src/database/connection.ts b/src/database/connection.ts new file mode 100644 index 0000000..a3553b9 --- /dev/null +++ b/src/database/connection.ts @@ -0,0 +1,244 @@ +/** + * SQLite Database Connection + * Provides database connection and query utilities for Elly + */ + +/** + * Database connection wrapper for SQLite + * Uses Deno's built-in SQLite support + */ +export class Database { + private db: Deno.Kv | null = null; + private sqliteDb: unknown = null; + private readonly path: string; + private isConnected = false; + + constructor(path: string) { + this.path = path; + } + + /** + * Initialize the database connection + */ + async connect(): Promise { + if (this.isConnected) return; + + try { + // Ensure the directory exists + const dir = this.path.substring(0, this.path.lastIndexOf('/')); + try { + await Deno.mkdir(dir, { recursive: true }); + } catch { + // Directory might already exist + } + + // For Deno, we'll use a simple JSON-based storage approach + // In production, you'd use a proper SQLite library + this.isConnected = true; + console.log(`Database connected: ${this.path}`); + } catch (error) { + throw new Error(`Failed to connect to database: ${error}`); + } + } + + /** + * Close the database connection + */ + close(): void { + if (!this.isConnected) return; + this.isConnected = false; + console.log('Database connection closed'); + } + + /** + * Check if connected + */ + get connected(): boolean { + return this.isConnected; + } +} + +/** + * Simple JSON-based storage for Deno + * This is a lightweight alternative to SQLite for the initial implementation + */ +export class JsonDatabase { + private data: Map> = new Map(); + private readonly path: string; + private isDirty = false; + private saveTimeout: number | undefined; + + constructor(path: string) { + this.path = path; + } + + /** + * Load data from disk + */ + async load(): Promise { + try { + const content = await Deno.readTextFile(this.path); + const parsed = JSON.parse(content); + + for (const [table, records] of Object.entries(parsed)) { + const tableMap = new Map(); + for (const [key, value] of Object.entries(records as Record)) { + tableMap.set(key, value); + } + this.data.set(table, tableMap); + } + + console.log(`Database loaded from ${this.path}`); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + console.log('Database file not found, starting fresh'); + await this.save(); + } else { + throw error; + } + } + } + + /** + * Save data to disk + */ + async save(): Promise { + const obj: Record> = {}; + + for (const [table, records] of this.data.entries()) { + obj[table] = {}; + for (const [key, value] of records.entries()) { + obj[table][key] = value; + } + } + + // Ensure directory exists + const dir = this.path.substring(0, this.path.lastIndexOf('/')); + try { + await Deno.mkdir(dir, { recursive: true }); + } catch { + // Directory might already exist + } + + await Deno.writeTextFile(this.path, JSON.stringify(obj, null, 2)); + this.isDirty = false; + } + + /** + * Schedule a save operation (debounced) + */ + private scheduleSave(): void { + this.isDirty = true; + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + this.saveTimeout = setTimeout(() => this.save(), 5000); + } + + /** + * Get a table (creates if doesn't exist) + */ + private getTable(name: string): Map { + if (!this.data.has(name)) { + this.data.set(name, new Map()); + } + return this.data.get(name)!; + } + + /** + * Insert a record + */ + insert(table: string, key: string, value: T): void { + const tableData = this.getTable(table); + tableData.set(key, value); + this.scheduleSave(); + } + + /** + * Get a record + */ + get(table: string, key: string): T | null { + const tableData = this.getTable(table); + return (tableData.get(key) as T) ?? null; + } + + /** + * Update a record + */ + update(table: string, key: string, value: Partial): boolean { + const tableData = this.getTable(table); + const existing = tableData.get(key) as T | undefined; + + if (!existing) return false; + + tableData.set(key, { ...existing, ...value }); + this.scheduleSave(); + return true; + } + + /** + * Delete a record + */ + delete(table: string, key: string): boolean { + const tableData = this.getTable(table); + const result = tableData.delete(key); + if (result) this.scheduleSave(); + return result; + } + + /** + * Find records matching a predicate + */ + find(table: string, predicate: (value: T) => boolean): T[] { + const tableData = this.getTable(table); + const results: T[] = []; + + for (const value of tableData.values()) { + if (predicate(value as T)) { + results.push(value as T); + } + } + + return results; + } + + /** + * Get all records in a table + */ + getAll(table: string): T[] { + const tableData = this.getTable(table); + return Array.from(tableData.values()) as T[]; + } + + /** + * Count records in a table + */ + count(table: string): number { + return this.getTable(table).size; + } + + /** + * Clear a table + */ + clearTable(table: string): void { + this.data.delete(table); + this.scheduleSave(); + } + + /** + * Force save and cleanup + */ + async close(): Promise { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + if (this.isDirty) { + await this.save(); + } + } +} + +// Export a factory function +export function createDatabase(path: string): JsonDatabase { + return new JsonDatabase(path); +} diff --git a/src/database/index.ts b/src/database/index.ts new file mode 100644 index 0000000..1bdcf0d --- /dev/null +++ b/src/database/index.ts @@ -0,0 +1,36 @@ +/** + * Database Module Exports + * Central export point for all database-related functionality + */ + +// SQLite Database (New) +export { SQLiteDatabase, createSQLiteDatabase, getDatabase } from './sqlite.ts'; +export { DatabaseError, ConnectionError, QueryError, TransactionError } from './sqlite.ts'; +export type { QueryResult } from './sqlite.ts'; + +// Database Manager +export { DatabaseManager, createDatabaseManager, getDatabaseManager } from './DatabaseManager.ts'; + +// Schema +export { initializeSchema, runMigrations, getSchemaVersion } from './schema.ts'; + +// Base Repository +export { BaseRepository, RepositoryError, NotFoundError, DuplicateError, ValidationError } from './BaseRepository.ts'; +export { ok, err } from './BaseRepository.ts'; +export type { Result } from './BaseRepository.ts'; + +// SQLite Repositories (New) +export { FamilyRepositorySQLite } from './repositories/FamilyRepositorySQLite.ts'; +export { ReminderRepositorySQLite } from './repositories/ReminderRepositorySQLite.ts'; + +// Legacy JSON Repositories (for migration) +export { JsonDatabase, createDatabase } from './connection.ts'; +export { ReminderRepository } from './repositories/ReminderRepository.ts'; +export { FamilyRepository } from './repositories/FamilyRepository.ts'; +export { AwayRepository } from './repositories/AwayRepository.ts'; +export { SuggestionRepository } from './repositories/SuggestionRepository.ts'; +export { ApplicationRepository } from './repositories/ApplicationRepository.ts'; +export { ChampionRepository } from './repositories/ChampionRepository.ts'; +export { QOTDRepository } from './repositories/QOTDRepository.ts'; +export { FilterRepository } from './repositories/FilterRepository.ts'; +export { StaffRepository } from './repositories/StaffRepository.ts'; diff --git a/src/database/repositories/ApplicationRepository.ts b/src/database/repositories/ApplicationRepository.ts new file mode 100644 index 0000000..38ae253 --- /dev/null +++ b/src/database/repositories/ApplicationRepository.ts @@ -0,0 +1,209 @@ +/** + * Application Repository + * Manages guild applications in the database + */ + +import type { JsonDatabase } from '../connection.ts'; + +export interface Application { + id: string; + userId: string; + messageId: string; + channelId: string; + status: 'pending' | 'accepted' | 'denied'; + minecraftUsername: string; + discordAge: string; + timezone: string; + activity: string; + whyJoin: string; + experience: string; + extra?: string; + reviewedBy?: string; + reviewedAt?: number; + createdAt: number; +} + +export class ApplicationRepository { + private db: JsonDatabase; + private readonly collection = 'applications'; + + constructor(db: JsonDatabase) { + this.db = db; + } + + /** + * Create a new application + */ + async create(application: Omit): Promise { + const newApplication: Application = { + ...application, + id: this.generateId(), + status: 'pending', + createdAt: Date.now(), + }; + + const applications = this.db.get(this.collection) ?? []; + applications.push(newApplication); + this.db.set(this.collection, applications); + + return newApplication; + } + + /** + * Get application by ID + */ + async getById(id: string): Promise { + const applications = this.db.get(this.collection) ?? []; + return applications.find((a) => a.id === id) ?? null; + } + + /** + * Get application by message ID + */ + async getByMessageId(messageId: string): Promise { + const applications = this.db.get(this.collection) ?? []; + return applications.find((a) => a.messageId === messageId) ?? null; + } + + /** + * Get all applications by user ID + */ + async getByUserId(userId: string): Promise { + const applications = this.db.get(this.collection) ?? []; + return applications + .filter((a) => a.userId === userId) + .sort((a, b) => b.createdAt - a.createdAt); + } + + /** + * Get pending application by user ID + */ + async getPendingByUserId(userId: string): Promise { + const applications = this.db.get(this.collection) ?? []; + return applications.find((a) => a.userId === userId && a.status === 'pending') ?? null; + } + + /** + * Get all pending applications + */ + async getPending(): Promise { + const applications = this.db.get(this.collection) ?? []; + return applications.filter((a) => a.status === 'pending'); + } + + /** + * Get all applications by status + */ + async getByStatus(status: Application['status']): Promise { + const applications = this.db.get(this.collection) ?? []; + return applications.filter((a) => a.status === status); + } + + /** + * Update application status + */ + async updateStatus( + id: string, + status: Application['status'], + reviewedBy: string + ): Promise { + const applications = this.db.get(this.collection) ?? []; + const index = applications.findIndex((a) => a.id === id); + + if (index === -1) return null; + + applications[index] = { + ...applications[index], + status, + reviewedBy, + reviewedAt: Date.now(), + }; + + this.db.set(this.collection, applications); + return applications[index]; + } + + /** + * Delete application + */ + async delete(id: string): Promise { + const applications = this.db.get(this.collection) ?? []; + const filtered = applications.filter((a) => a.id !== id); + + if (filtered.length === applications.length) return false; + + this.db.set(this.collection, filtered); + return true; + } + + /** + * Check if user has pending application + */ + async hasPendingApplication(userId: string): Promise { + const application = await this.getByUserId(userId); + return application !== null; + } + + /** + * Get application count by status + */ + async getCountByStatus(status: Application['status']): Promise { + const applications = await this.getByStatus(status); + return applications.length; + } + + /** + * Get recent applications + */ + async getRecent(limit: number = 10): Promise { + const applications = this.db.get(this.collection) ?? []; + return applications + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); + } + + /** + * Get all applications + */ + async getAll(): Promise { + return this.db.get(this.collection) ?? []; + } + + /** + * Update message ID and channel ID + */ + async updateMessageId(id: string, messageId: string, channelId: string): Promise { + const applications = this.db.get(this.collection) ?? []; + const index = applications.findIndex((a) => a.id === id); + + if (index === -1) return false; + + applications[index] = { + ...applications[index], + messageId, + channelId, + }; + + this.db.set(this.collection, applications); + return true; + } + + /** + * Get recent denied application for user (for cooldown check) + */ + async getRecentDenied(userId: string, withinMs: number): Promise { + const applications = this.db.get(this.collection) ?? []; + const cutoff = Date.now() - withinMs; + + return applications.find( + (a) => a.userId === userId && a.status === 'denied' && a.createdAt >= cutoff + ) ?? null; + } + + /** + * Generate unique ID + */ + private generateId(): string { + return `app_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/src/database/repositories/AwayRepository.ts b/src/database/repositories/AwayRepository.ts new file mode 100644 index 0000000..65842ee --- /dev/null +++ b/src/database/repositories/AwayRepository.ts @@ -0,0 +1,82 @@ +/** + * Away Repository + * Handles all away status database operations + */ + +import type { JsonDatabase } from '../connection.ts'; +import type { AwayStatus } from '../../types/index.ts'; + +const TABLE_NAME = 'away_status'; + +/** + * Repository for managing away statuses + */ +export class AwayRepository { + constructor(private db: JsonDatabase) {} + + /** + * Create a new away status + */ + create(status: Omit): AwayStatus { + const fullStatus: AwayStatus = { + ...status, + createdAt: new Date().toISOString(), + }; + + this.db.insert(TABLE_NAME, status.userId, fullStatus); + return fullStatus; + } + + /** + * Get away status by user ID + */ + getByUserId(userId: string): AwayStatus | null { + return this.db.get(TABLE_NAME, userId); + } + + /** + * Get all away statuses + */ + getAll(): AwayStatus[] { + return this.db.getAll(TABLE_NAME); + } + + /** + * Get all expired away statuses + */ + getExpired(): AwayStatus[] { + const now = new Date().toISOString(); + return this.db.find(TABLE_NAME, (s) => s.expiresAt <= now); + } + + /** + * Update away status + */ + update(userId: string, data: Partial): boolean { + return this.db.update(TABLE_NAME, userId, data); + } + + /** + * Delete away status + */ + delete(userId: string): boolean { + return this.db.delete(TABLE_NAME, userId); + } + + /** + * Check if user is away + */ + isAway(userId: string): boolean { + const status = this.getByUserId(userId); + if (!status) return false; + return new Date(status.expiresAt) > new Date(); + } + + /** + * Count active away statuses + */ + countActive(): number { + const now = new Date().toISOString(); + return this.db.find(TABLE_NAME, (s) => s.expiresAt > now).length; + } +} diff --git a/src/database/repositories/ChampionRepository.ts b/src/database/repositories/ChampionRepository.ts new file mode 100644 index 0000000..d3d11f0 --- /dev/null +++ b/src/database/repositories/ChampionRepository.ts @@ -0,0 +1,156 @@ +/** + * Champion Repository + * Manages champion role assignments in the database + */ + +import type { JsonDatabase } from '../connection.ts'; + +export interface Champion { + id: string; + userId: string; + assignedBy: string; + reason?: string; + startDate: number; + endDate: number; + isActive: boolean; +} + +export class ChampionRepository { + private db: JsonDatabase; + private readonly collection = 'champions'; + + constructor(db: JsonDatabase) { + this.db = db; + } + + /** + * Add a new champion + */ + async add(data: Omit): Promise { + const champion: Champion = { + ...data, + id: this.generateId(), + isActive: true, + }; + + const champions = this.db.get(this.collection) ?? []; + + // Deactivate any existing champion entry for this user + const updated = champions.map((c) => + c.userId === data.userId && c.isActive ? { ...c, isActive: false } : c + ); + + updated.push(champion); + this.db.set(this.collection, updated); + + return champion; + } + + /** + * Get active champion by user ID + */ + async getByUserId(userId: string): Promise { + const champions = this.db.get(this.collection) ?? []; + return champions.find((c) => c.userId === userId && c.isActive) ?? null; + } + + /** + * Get all active champions + */ + async getActive(): Promise { + const champions = this.db.get(this.collection) ?? []; + return champions.filter((c) => c.isActive && c.endDate > Date.now()); + } + + /** + * Get expired champions (still marked active but past end date) + */ + async getExpired(): Promise { + const champions = this.db.get(this.collection) ?? []; + return champions.filter((c) => c.isActive && c.endDate <= Date.now()); + } + + /** + * Remove champion status + */ + async remove(userId: string): Promise { + const champions = this.db.get(this.collection) ?? []; + let found = false; + + const updated = champions.map((c) => { + if (c.userId === userId && c.isActive) { + found = true; + return { ...c, isActive: false }; + } + return c; + }); + + if (!found) return false; + + this.db.set(this.collection, updated); + return true; + } + + /** + * Extend champion duration + */ + async extend(userId: string, additionalDays: number): Promise { + const champions = this.db.get(this.collection) ?? []; + const index = champions.findIndex((c) => c.userId === userId && c.isActive); + + if (index === -1) return null; + + const additionalMs = additionalDays * 24 * 60 * 60 * 1000; + champions[index] = { + ...champions[index], + endDate: champions[index].endDate + additionalMs, + }; + + this.db.set(this.collection, champions); + return champions[index]; + } + + /** + * Check if user is champion + */ + async isChampion(userId: string): Promise { + const champion = await this.getByUserId(userId); + return champion !== null && champion.endDate > Date.now(); + } + + /** + * Get remaining days for champion + */ + async getRemainingDays(userId: string): Promise { + const champion = await this.getByUserId(userId); + if (!champion) return 0; + + const remaining = champion.endDate - Date.now(); + return Math.max(0, Math.ceil(remaining / (24 * 60 * 60 * 1000))); + } + + /** + * Get champion history for user + */ + async getHistory(userId: string): Promise { + const champions = this.db.get(this.collection) ?? []; + return champions + .filter((c) => c.userId === userId) + .sort((a, b) => b.startDate - a.startDate); + } + + /** + * Count total champions + */ + async countActive(): Promise { + const active = await this.getActive(); + return active.length; + } + + /** + * Generate unique ID + */ + private generateId(): string { + return `champ_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/src/database/repositories/FamilyRepository.ts b/src/database/repositories/FamilyRepository.ts new file mode 100644 index 0000000..ecc361f --- /dev/null +++ b/src/database/repositories/FamilyRepository.ts @@ -0,0 +1,220 @@ +/** + * Family Repository + * Handles all family/relationship-related database operations + */ + +import type { JsonDatabase } from '../connection.ts'; +import type { FamilyRelationship } from '../../types/index.ts'; + +const TABLE_NAME = 'family'; + +/** + * Repository for managing family relationships + */ +export class FamilyRepository { + constructor(private db: JsonDatabase) {} + + /** + * Get or create a family record for a user + */ + getOrCreate(userId: string): FamilyRelationship { + let record = this.db.get(TABLE_NAME, userId); + + if (!record) { + record = { + userId, + partnerId: undefined, + parentId: undefined, + children: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + this.db.insert(TABLE_NAME, userId, record); + } + + return record; + } + + /** + * Get a family record by user ID + */ + getByUserId(userId: string): FamilyRelationship | null { + return this.db.get(TABLE_NAME, userId); + } + + /** + * Set a user's partner (marriage) + */ + setPartner(userId: string, partnerId: string): void { + const user = this.getOrCreate(userId); + const partner = this.getOrCreate(partnerId); + + user.partnerId = partnerId; + user.updatedAt = new Date().toISOString(); + + partner.partnerId = userId; + partner.updatedAt = new Date().toISOString(); + + this.db.update(TABLE_NAME, userId, user); + this.db.update(TABLE_NAME, partnerId, partner); + } + + /** + * Remove a user's partner (divorce) + */ + removePartner(userId: string): void { + const user = this.getByUserId(userId); + if (!user || !user.partnerId) return; + + const partnerId = user.partnerId; + const partner = this.getByUserId(partnerId); + + user.partnerId = undefined; + user.updatedAt = new Date().toISOString(); + this.db.update(TABLE_NAME, userId, user); + + if (partner) { + partner.partnerId = undefined; + partner.updatedAt = new Date().toISOString(); + this.db.update(TABLE_NAME, partnerId, partner); + } + } + + /** + * Add a child to a user + */ + addChild(parentId: string, childId: string): void { + const parent = this.getOrCreate(parentId); + const child = this.getOrCreate(childId); + + if (!parent.children.includes(childId)) { + parent.children.push(childId); + parent.updatedAt = new Date().toISOString(); + this.db.update(TABLE_NAME, parentId, parent); + } + + child.parentId = parentId; + child.updatedAt = new Date().toISOString(); + this.db.update(TABLE_NAME, childId, child); + } + + /** + * Remove a child from a user + */ + removeChild(parentId: string, childId: string): void { + const parent = this.getByUserId(parentId); + const child = this.getByUserId(childId); + + if (parent) { + parent.children = parent.children.filter((id) => id !== childId); + parent.updatedAt = new Date().toISOString(); + this.db.update(TABLE_NAME, parentId, parent); + } + + if (child && child.parentId === parentId) { + child.parentId = undefined; + child.updatedAt = new Date().toISOString(); + this.db.update(TABLE_NAME, childId, child); + } + } + + /** + * Set a user's parent + */ + setParent(childId: string, parentId: string): void { + this.addChild(parentId, childId); + } + + /** + * Remove a user's parent (run away) + */ + removeParent(childId: string): void { + const child = this.getByUserId(childId); + if (!child || !child.parentId) return; + + const parentId = child.parentId; + this.removeChild(parentId, childId); + } + + /** + * Check if two users are married + */ + areMarried(userId1: string, userId2: string): boolean { + const user1 = this.getByUserId(userId1); + return user1?.partnerId === userId2; + } + + /** + * Check if a user is a child of another user + */ + isChildOf(childId: string, parentId: string): boolean { + const child = this.getByUserId(childId); + return child?.parentId === parentId; + } + + /** + * Check if a user has a partner + */ + hasPartner(userId: string): boolean { + const user = this.getByUserId(userId); + return !!user?.partnerId; + } + + /** + * Check if a user has a parent + */ + hasParent(userId: string): boolean { + const user = this.getByUserId(userId); + return !!user?.parentId; + } + + /** + * Get all children of a user + */ + getChildren(userId: string): string[] { + const user = this.getByUserId(userId); + return user?.children ?? []; + } + + /** + * Get the partner of a user + */ + getPartner(userId: string): string | null { + const user = this.getByUserId(userId); + return user?.partnerId ?? null; + } + + /** + * Get the parent of a user + */ + getParent(userId: string): string | null { + const user = this.getByUserId(userId); + return user?.parentId ?? null; + } + + /** + * Delete all family data for a user + */ + delete(userId: string): void { + const user = this.getByUserId(userId); + if (!user) return; + + // Remove from partner + if (user.partnerId) { + this.removePartner(userId); + } + + // Remove from parent + if (user.parentId) { + this.removeParent(userId); + } + + // Remove all children + for (const childId of user.children) { + this.removeChild(userId, childId); + } + + // Delete the record + this.db.delete(TABLE_NAME, userId); + } +} diff --git a/src/database/repositories/FamilyRepositorySQLite.ts b/src/database/repositories/FamilyRepositorySQLite.ts new file mode 100644 index 0000000..be5a8f8 --- /dev/null +++ b/src/database/repositories/FamilyRepositorySQLite.ts @@ -0,0 +1,422 @@ +/** + * Family Repository (SQLite) + * Manages family relationships with proper error handling + */ + +import type { SQLiteDatabase } from '../sqlite.ts'; +import { BaseRepository, ok, err, type Result, RepositoryError, ValidationError } from '../BaseRepository.ts'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface Family { + id?: string; + userId: string; + partnerId?: string | null; + parentId?: string | null; + children: string[]; + createdAt: number; + updatedAt: number; +} + +// ============================================================================ +// Repository +// ============================================================================ + +export class FamilyRepositorySQLite extends BaseRepository { + constructor(db: SQLiteDatabase) { + super(db, 'families', 'Family'); + } + + // ========================================================================= + // Entity Conversion + // ========================================================================= + + protected rowToEntity(row: Record): Family { + return { + userId: row.user_id as string, + partnerId: row.partner_id as string | null, + parentId: row.parent_id as string | null, + children: [], // Loaded separately + createdAt: row.created_at as number, + updatedAt: row.updated_at as number, + }; + } + + protected entityToRow(entity: Family): Record { + return { + user_id: entity.userId, + partner_id: entity.partnerId, + parent_id: entity.parentId, + created_at: entity.createdAt, + updated_at: entity.updatedAt, + }; + } + + // ========================================================================= + // Family Operations + // ========================================================================= + + /** + * Get or create a family record for a user + */ + async getOrCreate(userId: string): Promise> { + // Try to find existing + const existing = await this.findByUserId(userId); + if (!existing.ok) return existing; + if (existing.value) return ok(existing.value); + + // Create new + const now = this.now(); + const result = this.db.execute( + `INSERT INTO families (user_id, partner_id, parent_id, created_at, updated_at) + VALUES (?, NULL, NULL, ?, ?)`, + [userId, now, now] + ); + + if (!result.success) { + return err(new RepositoryError( + result.error?.message ?? 'Failed to create family', + 'Family', + 'getOrCreate' + )); + } + + return ok({ + userId, + partnerId: null, + parentId: null, + children: [], + createdAt: now, + updatedAt: now, + }); + } + + /** + * Find family by user ID + */ + async findByUserId(userId: string): Promise> { + const result = this.db.queryOne>( + 'SELECT * FROM families WHERE user_id = ?', + [userId] + ); + + if (!result.success) { + return err(new RepositoryError( + result.error?.message ?? 'Query failed', + 'Family', + 'findByUserId' + )); + } + + if (!result.data) { + return ok(null); + } + + const family = this.rowToEntity(result.data); + + // Load children + const childrenResult = await this.getChildren(userId); + if (childrenResult.ok) { + family.children = childrenResult.value; + } + + return ok(family); + } + + /** + * Get children for a user + */ + async getChildren(userId: string): Promise> { + const result = this.db.query<{ child_id: string }>( + 'SELECT child_id FROM family_children WHERE parent_id = ?', + [userId] + ); + + if (!result.success) { + return err(new RepositoryError( + result.error?.message ?? 'Query failed', + 'Family', + 'getChildren' + )); + } + + return ok((result.data ?? []).map((r) => r.child_id)); + } + + /** + * Set partner (marriage) + */ + async setPartner(userId: string, partnerId: string): Promise> { + // Validate not self + if (userId === partnerId) { + return err(new ValidationError('Family', 'Cannot marry yourself')); + } + + // Ensure both users have family records + const user1 = await this.getOrCreate(userId); + if (!user1.ok) return err(user1.error); + + const user2 = await this.getOrCreate(partnerId); + if (!user2.ok) return err(user2.error); + + // Check if either is already married + if (user1.value.partnerId) { + return err(new ValidationError('Family', 'You are already married')); + } + if (user2.value.partnerId) { + return err(new ValidationError('Family', 'That person is already married')); + } + + // Update both users + const now = this.now(); + const txResult = await this.db.transaction(() => { + this.db.execute( + 'UPDATE families SET partner_id = ?, updated_at = ? WHERE user_id = ?', + [partnerId, now, userId] + ); + this.db.execute( + 'UPDATE families SET partner_id = ?, updated_at = ? WHERE user_id = ?', + [userId, now, partnerId] + ); + }); + + if (!txResult.success) { + return err(new RepositoryError( + txResult.error?.message ?? 'Transaction failed', + 'Family', + 'setPartner' + )); + } + + return ok(undefined); + } + + /** + * Remove partner (divorce) + */ + async removePartner(userId: string): Promise> { + const family = await this.findByUserId(userId); + if (!family.ok) return err(family.error); + if (!family.value?.partnerId) { + return ok(null); + } + + const partnerId = family.value.partnerId; + const now = this.now(); + + const txResult = await this.db.transaction(() => { + this.db.execute( + 'UPDATE families SET partner_id = NULL, updated_at = ? WHERE user_id = ?', + [now, userId] + ); + this.db.execute( + 'UPDATE families SET partner_id = NULL, updated_at = ? WHERE user_id = ?', + [now, partnerId] + ); + }); + + if (!txResult.success) { + return err(new RepositoryError( + txResult.error?.message ?? 'Transaction failed', + 'Family', + 'removePartner' + )); + } + + return ok(partnerId); + } + + /** + * Set parent (adoption) + */ + async setParent(childId: string, parentId: string): Promise> { + // Validate + if (childId === parentId) { + return err(new ValidationError('Family', 'Cannot adopt yourself')); + } + + // Ensure both have family records + const child = await this.getOrCreate(childId); + if (!child.ok) return err(child.error); + + const parent = await this.getOrCreate(parentId); + if (!parent.ok) return err(parent.error); + + // Check if child already has a parent + if (child.value.parentId) { + return err(new ValidationError('Family', 'This person already has a parent')); + } + + const now = this.now(); + + const txResult = await this.db.transaction(() => { + // Update child's parent + this.db.execute( + 'UPDATE families SET parent_id = ?, updated_at = ? WHERE user_id = ?', + [parentId, now, childId] + ); + + // Add to parent's children + this.db.execute( + 'INSERT OR IGNORE INTO family_children (parent_id, child_id, created_at) VALUES (?, ?, ?)', + [parentId, childId, now] + ); + }); + + if (!txResult.success) { + return err(new RepositoryError( + txResult.error?.message ?? 'Transaction failed', + 'Family', + 'setParent' + )); + } + + return ok(undefined); + } + + /** + * Remove parent + */ + async removeParent(childId: string): Promise> { + const child = await this.findByUserId(childId); + if (!child.ok) return err(child.error); + if (!child.value?.parentId) { + return ok(null); + } + + const parentId = child.value.parentId; + const now = this.now(); + + const txResult = await this.db.transaction(() => { + this.db.execute( + 'UPDATE families SET parent_id = NULL, updated_at = ? WHERE user_id = ?', + [now, childId] + ); + this.db.execute( + 'DELETE FROM family_children WHERE parent_id = ? AND child_id = ?', + [parentId, childId] + ); + }); + + if (!txResult.success) { + return err(new RepositoryError( + txResult.error?.message ?? 'Transaction failed', + 'Family', + 'removeParent' + )); + } + + return ok(parentId); + } + + /** + * Get full family tree + */ + async getFamilyTree(userId: string): Promise> { + const user = await this.findByUserId(userId); + if (!user.ok) return err(user.error); + if (!user.value) { + return err(new RepositoryError('User not found', 'Family', 'getFamilyTree')); + } + + const tree: { + user: Family; + partner?: Family; + parent?: Family; + children: Family[]; + } = { + user: user.value, + children: [], + }; + + // Get partner + if (user.value.partnerId) { + const partner = await this.findByUserId(user.value.partnerId); + if (partner.ok && partner.value) { + tree.partner = partner.value; + } + } + + // Get parent + if (user.value.parentId) { + const parent = await this.findByUserId(user.value.parentId); + if (parent.ok && parent.value) { + tree.parent = parent.value; + } + } + + // Get children + for (const childId of user.value.children) { + const child = await this.findByUserId(childId); + if (child.ok && child.value) { + tree.children.push(child.value); + } + } + + return ok(tree); + } + + /** + * Check if two users are related + */ + async areRelated(userId1: string, userId2: string): Promise> { + const family1 = await this.findByUserId(userId1); + if (!family1.ok) return err(family1.error); + if (!family1.value) return ok(false); + + // Check direct relationships + if (family1.value.partnerId === userId2) return ok(true); + if (family1.value.parentId === userId2) return ok(true); + if (family1.value.children.includes(userId2)) return ok(true); + + return ok(false); + } + + /** + * Delete family record + */ + async deleteFamily(userId: string): Promise> { + const txResult = await this.db.transaction(() => { + // Remove from partner + this.db.execute( + 'UPDATE families SET partner_id = NULL WHERE partner_id = ?', + [userId] + ); + + // Remove from children's parent + this.db.execute( + 'UPDATE families SET parent_id = NULL WHERE parent_id = ?', + [userId] + ); + + // Remove children records + this.db.execute( + 'DELETE FROM family_children WHERE parent_id = ? OR child_id = ?', + [userId, userId] + ); + + // Delete family record + this.db.execute( + 'DELETE FROM families WHERE user_id = ?', + [userId] + ); + }); + + if (!txResult.success) { + return err(new RepositoryError( + txResult.error?.message ?? 'Transaction failed', + 'Family', + 'deleteFamily' + )); + } + + return ok(undefined); + } +} diff --git a/src/database/repositories/FilterRepository.ts b/src/database/repositories/FilterRepository.ts new file mode 100644 index 0000000..9a03fbf --- /dev/null +++ b/src/database/repositories/FilterRepository.ts @@ -0,0 +1,273 @@ +/** + * Filter Repository + * Manages channel message filters in the database + */ + +import type { JsonDatabase } from '../connection.ts'; + +export type FilterType = 'links' | 'images' | 'attachments' | 'invites' | 'custom'; + +export interface ChannelFilter { + id: string; + channelId: string; + filterType: FilterType; + pattern?: string; // For custom regex patterns + allowedRoles: string[]; // Role IDs that bypass the filter + isEnabled: boolean; + createdBy: string; + createdAt: number; + updatedAt: number; +} + +export interface FilterAction { + id: string; + filterId: string; + channelId: string; + userId: string; + messageContent: string; + action: 'deleted' | 'warned'; + timestamp: number; +} + +export class FilterRepository { + private db: JsonDatabase; + private readonly filtersCollection = 'channel_filters'; + private readonly actionsCollection = 'filter_actions'; + + constructor(db: JsonDatabase) { + this.db = db; + } + + // ========================================================================= + // Filter Management + // ========================================================================= + + /** + * Create a new filter + */ + async createFilter(data: Omit): Promise { + const filter: ChannelFilter = { + ...data, + id: this.generateId(), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const filters = this.db.get(this.filtersCollection) ?? []; + filters.push(filter); + this.db.set(this.filtersCollection, filters); + + return filter; + } + + /** + * Get filter by ID + */ + async getFilter(id: string): Promise { + const filters = this.db.get(this.filtersCollection) ?? []; + return filters.find((f) => f.id === id) ?? null; + } + + /** + * Get all filters for a channel + */ + async getChannelFilters(channelId: string): Promise { + const filters = this.db.get(this.filtersCollection) ?? []; + return filters.filter((f) => f.channelId === channelId && f.isEnabled); + } + + /** + * Get all enabled filters + */ + async getAllEnabled(): Promise { + const filters = this.db.get(this.filtersCollection) ?? []; + return filters.filter((f) => f.isEnabled); + } + + /** + * Update filter + */ + async updateFilter(id: string, data: Partial): Promise { + const filters = this.db.get(this.filtersCollection) ?? []; + const index = filters.findIndex((f) => f.id === id); + + if (index === -1) return null; + + filters[index] = { + ...filters[index], + ...data, + updatedAt: Date.now(), + }; + + this.db.set(this.filtersCollection, filters); + return filters[index]; + } + + /** + * Delete filter + */ + async deleteFilter(id: string): Promise { + const filters = this.db.get(this.filtersCollection) ?? []; + const filtered = filters.filter((f) => f.id !== id); + + if (filtered.length === filters.length) return false; + + this.db.set(this.filtersCollection, filtered); + return true; + } + + /** + * Toggle filter enabled state + */ + async toggleFilter(id: string): Promise { + const filter = await this.getFilter(id); + if (!filter) return null; + + return this.updateFilter(id, { isEnabled: !filter.isEnabled }); + } + + /** + * Add allowed role to filter + */ + async addAllowedRole(filterId: string, roleId: string): Promise { + const filter = await this.getFilter(filterId); + if (!filter) return null; + + if (filter.allowedRoles.includes(roleId)) return filter; + + return this.updateFilter(filterId, { + allowedRoles: [...filter.allowedRoles, roleId], + }); + } + + /** + * Remove allowed role from filter + */ + async removeAllowedRole(filterId: string, roleId: string): Promise { + const filter = await this.getFilter(filterId); + if (!filter) return null; + + return this.updateFilter(filterId, { + allowedRoles: filter.allowedRoles.filter((r) => r !== roleId), + }); + } + + // ========================================================================= + // Filter Actions (Logging) + // ========================================================================= + + /** + * Log a filter action + */ + async logAction(data: Omit): Promise { + const action: FilterAction = { + ...data, + id: this.generateActionId(), + timestamp: Date.now(), + }; + + const actions = this.db.get(this.actionsCollection) ?? []; + actions.push(action); + + // Keep only last 1000 actions + if (actions.length > 1000) { + actions.splice(0, actions.length - 1000); + } + + this.db.set(this.actionsCollection, actions); + return action; + } + + /** + * Get recent actions for a channel + */ + async getChannelActions(channelId: string, limit: number = 50): Promise { + const actions = this.db.get(this.actionsCollection) ?? []; + return actions + .filter((a) => a.channelId === channelId) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit); + } + + /** + * Get actions for a user + */ + async getUserActions(userId: string, limit: number = 50): Promise { + const actions = this.db.get(this.actionsCollection) ?? []; + return actions + .filter((a) => a.userId === userId) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit); + } + + /** + * Count actions by user in time period + */ + async countUserActions(userId: string, periodMs: number): Promise { + const actions = this.db.get(this.actionsCollection) ?? []; + const cutoff = Date.now() - periodMs; + return actions.filter((a) => a.userId === userId && a.timestamp > cutoff).length; + } + + // ========================================================================= + // Utility Methods + // ========================================================================= + + /** + * Check if message matches any filter + */ + async checkMessage(channelId: string, content: string, userRoles: string[]): Promise { + const filters = await this.getChannelFilters(channelId); + + for (const filter of filters) { + // Check if user has bypass role + if (filter.allowedRoles.some((r) => userRoles.includes(r))) { + continue; + } + + // Check filter type + if (this.matchesFilter(content, filter)) { + return filter; + } + } + + return null; + } + + /** + * Check if content matches filter + */ + private matchesFilter(content: string, filter: ChannelFilter): boolean { + switch (filter.filterType) { + case 'links': + return /https?:\/\/[^\s]+/i.test(content); + case 'images': + return /\.(jpg|jpeg|png|gif|webp|bmp)(\?.*)?$/i.test(content); + case 'invites': + return /(discord\.gg|discord\.com\/invite)\/[a-zA-Z0-9]+/i.test(content); + case 'attachments': + return false; // Handled separately for actual attachments + case 'custom': + if (!filter.pattern) return false; + try { + const regex = new RegExp(filter.pattern, 'i'); + return regex.test(content); + } catch { + return false; + } + default: + return false; + } + } + + /** + * Generate unique ID + */ + private generateId(): string { + return `filter_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + private generateActionId(): string { + return `faction_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/src/database/repositories/QOTDRepository.ts b/src/database/repositories/QOTDRepository.ts new file mode 100644 index 0000000..27c0a23 --- /dev/null +++ b/src/database/repositories/QOTDRepository.ts @@ -0,0 +1,205 @@ +/** + * QOTD (Question of the Day) Repository + * Manages QOTD questions and scheduling + */ + +import type { JsonDatabase } from '../connection.ts'; + +export interface QOTDQuestion { + id: string; + question: string; + addedBy: string; + addedAt: number; + usedAt?: number; + isUsed: boolean; +} + +export interface QOTDConfig { + channelId: string; + roleId?: string; + scheduledTime: string; // HH:MM format + isEnabled: boolean; + lastSentAt?: number; +} + +export class QOTDRepository { + private db: JsonDatabase; + private readonly questionsCollection = 'qotd_questions'; + private readonly configCollection = 'qotd_config'; + + constructor(db: JsonDatabase) { + this.db = db; + } + + // ========================================================================= + // Question Management + // ========================================================================= + + /** + * Add a new question + */ + async addQuestion(question: string, addedBy: string): Promise { + const newQuestion: QOTDQuestion = { + id: this.generateId(), + question, + addedBy, + addedAt: Date.now(), + isUsed: false, + }; + + const questions = this.db.get(this.questionsCollection) ?? []; + questions.push(newQuestion); + this.db.set(this.questionsCollection, questions); + + return newQuestion; + } + + /** + * Get question by ID + */ + async getQuestion(id: string): Promise { + const questions = this.db.get(this.questionsCollection) ?? []; + return questions.find((q) => q.id === id) ?? null; + } + + /** + * Get all unused questions + */ + async getUnusedQuestions(): Promise { + const questions = this.db.get(this.questionsCollection) ?? []; + return questions.filter((q) => !q.isUsed); + } + + /** + * Get random unused question + */ + async getRandomQuestion(): Promise { + const unused = await this.getUnusedQuestions(); + if (unused.length === 0) return null; + + const randomIndex = Math.floor(Math.random() * unused.length); + return unused[randomIndex]; + } + + /** + * Mark question as used + */ + async markAsUsed(id: string): Promise { + const questions = this.db.get(this.questionsCollection) ?? []; + const index = questions.findIndex((q) => q.id === id); + + if (index === -1) return null; + + questions[index] = { + ...questions[index], + isUsed: true, + usedAt: Date.now(), + }; + + this.db.set(this.questionsCollection, questions); + return questions[index]; + } + + /** + * Delete question + */ + async deleteQuestion(id: string): Promise { + const questions = this.db.get(this.questionsCollection) ?? []; + const filtered = questions.filter((q) => q.id !== id); + + if (filtered.length === questions.length) return false; + + this.db.set(this.questionsCollection, filtered); + return true; + } + + /** + * Get all questions + */ + async getAllQuestions(): Promise { + return this.db.get(this.questionsCollection) ?? []; + } + + /** + * Reset all questions to unused + */ + async resetAllQuestions(): Promise { + const questions = this.db.get(this.questionsCollection) ?? []; + const resetQuestions = questions.map((q) => ({ + ...q, + isUsed: false, + usedAt: undefined, + })); + + this.db.set(this.questionsCollection, resetQuestions); + return resetQuestions.length; + } + + /** + * Count questions + */ + async countQuestions(): Promise<{ total: number; unused: number; used: number }> { + const questions = this.db.get(this.questionsCollection) ?? []; + const unused = questions.filter((q) => !q.isUsed).length; + return { + total: questions.length, + unused, + used: questions.length - unused, + }; + } + + // ========================================================================= + // Configuration Management + // ========================================================================= + + /** + * Get QOTD configuration + */ + async getConfig(): Promise { + return this.db.get(this.configCollection) ?? null; + } + + /** + * Set QOTD configuration + */ + async setConfig(config: Partial): Promise { + const existing = await this.getConfig(); + const updated: QOTDConfig = { + channelId: config.channelId ?? existing?.channelId ?? '', + roleId: config.roleId ?? existing?.roleId, + scheduledTime: config.scheduledTime ?? existing?.scheduledTime ?? '12:00', + isEnabled: config.isEnabled ?? existing?.isEnabled ?? false, + lastSentAt: config.lastSentAt ?? existing?.lastSentAt, + }; + + this.db.set(this.configCollection, updated); + return updated; + } + + /** + * Update last sent time + */ + async updateLastSent(): Promise { + const config = await this.getConfig(); + if (config) { + await this.setConfig({ ...config, lastSentAt: Date.now() }); + } + } + + /** + * Enable/disable QOTD + */ + async setEnabled(enabled: boolean): Promise { + const config = await this.getConfig(); + if (config) { + await this.setConfig({ ...config, isEnabled: enabled }); + } + } + + /** + * Generate unique ID + */ + private generateId(): string { + return `qotd_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } +} diff --git a/src/database/repositories/ReminderRepository.ts b/src/database/repositories/ReminderRepository.ts new file mode 100644 index 0000000..82d4f2c --- /dev/null +++ b/src/database/repositories/ReminderRepository.ts @@ -0,0 +1,102 @@ +/** + * Reminder Repository + * Handles all reminder-related database operations + */ + +import type { JsonDatabase } from '../connection.ts'; +import type { Reminder } from '../../types/index.ts'; + +const TABLE_NAME = 'reminders'; + +/** + * Repository for managing reminders + */ +export class ReminderRepository { + constructor(private db: JsonDatabase) {} + + /** + * Create a new reminder + */ + create(reminder: Omit): Reminder { + const fullReminder: Reminder = { + ...reminder, + createdAt: new Date().toISOString(), + }; + + this.db.insert(TABLE_NAME, reminder.id, fullReminder); + return fullReminder; + } + + /** + * Get a reminder by ID + */ + getById(id: string): Reminder | null { + return this.db.get(TABLE_NAME, id); + } + + /** + * Get all reminders for a user + */ + getByUserId(userId: string): Reminder[] { + return this.db.find(TABLE_NAME, (r) => r.userId === userId); + } + + /** + * Get all expired reminders + */ + getExpired(): Reminder[] { + const now = new Date().toISOString(); + return this.db.find(TABLE_NAME, (r) => r.remindAt <= now); + } + + /** + * Get all reminders + */ + getAll(): Reminder[] { + return this.db.getAll(TABLE_NAME); + } + + /** + * Delete a reminder + */ + delete(id: string): boolean { + return this.db.delete(TABLE_NAME, id); + } + + /** + * Delete all reminders for a user + */ + deleteByUserId(userId: string): number { + const reminders = this.getByUserId(userId); + let deleted = 0; + + for (const reminder of reminders) { + if (this.db.delete(TABLE_NAME, reminder.id)) { + deleted++; + } + } + + return deleted; + } + + /** + * Update a reminder's next remind time (for recurring reminders) + */ + updateRemindAt(id: string, remindAt: string): boolean { + return this.db.update(TABLE_NAME, id, { remindAt }); + } + + /** + * Count reminders for a user + */ + countByUserId(userId: string): number { + return this.getByUserId(userId).length; + } + + /** + * Generate a unique reminder ID + */ + static generateId(): string { + return `rem_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + } +} diff --git a/src/database/repositories/ReminderRepositorySQLite.ts b/src/database/repositories/ReminderRepositorySQLite.ts new file mode 100644 index 0000000..9f82c1d --- /dev/null +++ b/src/database/repositories/ReminderRepositorySQLite.ts @@ -0,0 +1,204 @@ +/** + * Reminder Repository (SQLite) + * Manages reminders with proper error handling + */ + +import type { SQLiteDatabase } from '../sqlite.ts'; +import { BaseRepository, ok, err, type Result, RepositoryError } from '../BaseRepository.ts'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface Reminder { + id: string; + userId: string; + channelId?: string; + reminderText: string; + remindAt: number; + isRecurring: boolean; + recurrenceInterval?: number; + createdAt: number; +} + +// ============================================================================ +// Repository +// ============================================================================ + +export class ReminderRepositorySQLite extends BaseRepository { + constructor(db: SQLiteDatabase) { + super(db, 'reminders', 'Reminder'); + } + + // ========================================================================= + // Entity Conversion + // ========================================================================= + + protected rowToEntity(row: Record): Reminder { + return { + id: row.id as string, + userId: row.user_id as string, + channelId: row.channel_id as string | undefined, + reminderText: row.reminder_text as string, + remindAt: row.remind_at as number, + isRecurring: Boolean(row.is_recurring), + recurrenceInterval: row.recurrence_interval as number | undefined, + createdAt: row.created_at as number, + }; + } + + protected entityToRow(entity: Reminder): Record { + return { + id: entity.id, + user_id: entity.userId, + channel_id: entity.channelId, + reminder_text: entity.reminderText, + remind_at: entity.remindAt, + is_recurring: entity.isRecurring ? 1 : 0, + recurrence_interval: entity.recurrenceInterval, + created_at: entity.createdAt, + }; + } + + // ========================================================================= + // Reminder Operations + // ========================================================================= + + /** + * Create a new reminder + */ + async create(data: Omit): Promise> { + const id = this.generateId('rem'); + const now = this.now(); + + const reminder: Reminder = { + ...data, + id, + createdAt: now, + }; + + const row = this.entityToRow(reminder); + const result = this.db.execute( + `INSERT INTO reminders (id, user_id, channel_id, reminder_text, remind_at, is_recurring, recurrence_interval, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [row.id, row.user_id, row.channel_id, row.reminder_text, row.remind_at, row.is_recurring, row.recurrence_interval, row.created_at] + ); + + if (!result.success) { + return err(new RepositoryError( + result.error?.message ?? 'Failed to create reminder', + 'Reminder', + 'create' + )); + } + + return ok(reminder); + } + + /** + * Get reminders for a user + */ + async findByUserId(userId: string): Promise> { + return this.findWhere('user_id = ?', [userId], 'remind_at ASC'); + } + + /** + * Get due reminders + */ + async findDue(): Promise> { + const now = this.now(); + return this.findWhere('remind_at <= ?', [now], 'remind_at ASC'); + } + + /** + * Get upcoming reminders for a user + */ + async findUpcoming(userId: string, limit: number = 10): Promise> { + const now = this.now(); + return this.findWhere( + 'user_id = ? AND remind_at > ?', + [userId, now], + 'remind_at ASC', + limit + ); + } + + /** + * Update reminder time (for recurring reminders) + */ + async updateRemindAt(id: string, newTime: number): Promise> { + const result = this.db.execute( + 'UPDATE reminders SET remind_at = ? WHERE id = ?', + [newTime, id] + ); + + if (!result.success) { + return err(new RepositoryError( + result.error?.message ?? 'Failed to update reminder', + 'Reminder', + 'updateRemindAt' + )); + } + + return ok(undefined); + } + + /** + * Delete reminder + */ + async delete(id: string): Promise> { + return this.deleteById(id); + } + + /** + * Delete all reminders for a user + */ + async deleteByUserId(userId: string): Promise> { + return this.deleteWhere('user_id = ?', [userId]); + } + + /** + * Count reminders for a user + */ + async countByUserId(userId: string): Promise> { + const result = this.db.queryOne<{ count: number }>( + 'SELECT COUNT(*) as count FROM reminders WHERE user_id = ?', + [userId] + ); + + if (!result.success) { + return err(new RepositoryError( + result.error?.message ?? 'Query failed', + 'Reminder', + 'countByUserId' + )); + } + + return ok(result.data?.count ?? 0); + } + + /** + * Process recurring reminder (update to next occurrence) + */ + async processRecurring(id: string): Promise> { + const findResult = await this.findById(id); + if (!findResult.ok) return err(findResult.error); + if (!findResult.value) return ok(null); + + const reminder = findResult.value; + + if (!reminder.isRecurring || !reminder.recurrenceInterval) { + // Not recurring, just delete + await this.delete(id); + return ok(null); + } + + // Update to next occurrence + const newTime = reminder.remindAt + reminder.recurrenceInterval; + const updateResult = await this.updateRemindAt(id, newTime); + + if (!updateResult.ok) return err(updateResult.error); + + return ok({ ...reminder, remindAt: newTime }); + } +} diff --git a/src/database/repositories/StaffRepository.ts b/src/database/repositories/StaffRepository.ts new file mode 100644 index 0000000..71b7749 --- /dev/null +++ b/src/database/repositories/StaffRepository.ts @@ -0,0 +1,264 @@ +/** + * Staff Repository + * Manages staff progress and simulator game data + */ + +import type { JsonDatabase } from '../connection.ts'; + +export interface StaffProgress { + userId: string; + username: string; + appealsHandled: number; + punishmentsIssued: number; + reportsHandled: number; + assistsGiven: number; + totalPoints: number; + level: number; + lastActive: number; + createdAt: number; + updatedAt: number; +} + +export interface StaffAction { + id: string; + oderId: string; + actionType: 'appeal' | 'punishment' | 'report' | 'assist'; + points: number; + description?: string; + timestamp: number; +} + +// Points per action type +const POINTS = { + appeal: 10, + punishment: 5, + report: 8, + assist: 3, +}; + +// Points required per level +const LEVEL_THRESHOLDS = [0, 50, 150, 300, 500, 750, 1000, 1500, 2000, 3000]; + +export class StaffRepository { + private db: JsonDatabase; + private readonly progressCollection = 'staff_progress'; + private readonly actionsCollection = 'staff_actions'; + + constructor(db: JsonDatabase) { + this.db = db; + } + + // ========================================================================= + // Progress Management + // ========================================================================= + + /** + * Get or create staff progress for a user + */ + async getOrCreate(userId: string, username: string): Promise { + const progress = this.db.get(this.progressCollection) ?? []; + let staff = progress.find((s) => s.userId === userId); + + if (!staff) { + staff = { + userId, + username, + appealsHandled: 0, + punishmentsIssued: 0, + reportsHandled: 0, + assistsGiven: 0, + totalPoints: 0, + level: 0, + lastActive: Date.now(), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + progress.push(staff); + this.db.set(this.progressCollection, progress); + } + + return staff; + } + + /** + * Get staff progress by user ID + */ + async getByUserId(userId: string): Promise { + const progress = this.db.get(this.progressCollection) ?? []; + return progress.find((s) => s.userId === userId) ?? null; + } + + /** + * Update staff progress + */ + async update(userId: string, data: Partial): Promise { + const progress = this.db.get(this.progressCollection) ?? []; + const index = progress.findIndex((s) => s.userId === userId); + + if (index === -1) return null; + + progress[index] = { + ...progress[index], + ...data, + updatedAt: Date.now(), + }; + + this.db.set(this.progressCollection, progress); + return progress[index]; + } + + /** + * Add points and record action + */ + async addAction( + userId: string, + username: string, + actionType: StaffAction['actionType'], + description?: string + ): Promise<{ progress: StaffProgress; leveledUp: boolean; newLevel: number }> { + const staff = await this.getOrCreate(userId, username); + const points = POINTS[actionType]; + + // Update counters + const updates: Partial = { + totalPoints: staff.totalPoints + points, + lastActive: Date.now(), + }; + + switch (actionType) { + case 'appeal': + updates.appealsHandled = staff.appealsHandled + 1; + break; + case 'punishment': + updates.punishmentsIssued = staff.punishmentsIssued + 1; + break; + case 'report': + updates.reportsHandled = staff.reportsHandled + 1; + break; + case 'assist': + updates.assistsGiven = staff.assistsGiven + 1; + break; + } + + // Calculate new level + const newTotalPoints = staff.totalPoints + points; + let newLevel = 0; + for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) { + if (newTotalPoints >= LEVEL_THRESHOLDS[i]) { + newLevel = i; + break; + } + } + + const leveledUp = newLevel > staff.level; + updates.level = newLevel; + + // Update progress + const updatedProgress = await this.update(userId, updates); + + // Log action + await this.logAction({ + userId, + actionType, + points, + description, + }); + + return { + progress: updatedProgress!, + leveledUp, + newLevel, + }; + } + + /** + * Log a staff action + */ + private async logAction(data: Omit): Promise { + const actions = this.db.get(this.actionsCollection) ?? []; + + actions.push({ + ...data, + id: `sa_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`, + timestamp: Date.now(), + }); + + // Keep only last 1000 actions + if (actions.length > 1000) { + actions.splice(0, actions.length - 1000); + } + + this.db.set(this.actionsCollection, actions); + } + + /** + * Get leaderboard + */ + async getLeaderboard(limit: number = 10): Promise { + const progress = this.db.get(this.progressCollection) ?? []; + return progress + .sort((a, b) => b.totalPoints - a.totalPoints) + .slice(0, limit); + } + + /** + * Get user's rank on leaderboard + */ + async getRank(userId: string): Promise { + const progress = this.db.get(this.progressCollection) ?? []; + const sorted = progress.sort((a, b) => b.totalPoints - a.totalPoints); + const index = sorted.findIndex((s) => s.userId === userId); + return index === -1 ? -1 : index + 1; + } + + /** + * Get recent actions for a user + */ + async getRecentActions(userId: string, limit: number = 10): Promise { + const actions = this.db.get(this.actionsCollection) ?? []; + return actions + .filter((a) => a.userId === userId) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit); + } + + /** + * Get points needed for next level + */ + getPointsForNextLevel(currentLevel: number): number { + if (currentLevel >= LEVEL_THRESHOLDS.length - 1) { + return 0; // Max level + } + return LEVEL_THRESHOLDS[currentLevel + 1]; + } + + /** + * Get level title + */ + getLevelTitle(level: number): string { + const titles = [ + 'Trainee', + 'Junior Helper', + 'Helper', + 'Senior Helper', + 'Trial Moderator', + 'Moderator', + 'Senior Moderator', + 'Admin', + 'Senior Admin', + 'Manager', + ]; + return titles[Math.min(level, titles.length - 1)]; + } + + /** + * Reset all progress (admin only) + */ + async resetAll(): Promise { + const progress = this.db.get(this.progressCollection) ?? []; + const count = progress.length; + this.db.set(this.progressCollection, []); + this.db.set(this.actionsCollection, []); + return count; + } +} diff --git a/src/database/repositories/SuggestionRepository.ts b/src/database/repositories/SuggestionRepository.ts new file mode 100644 index 0000000..e4760b9 --- /dev/null +++ b/src/database/repositories/SuggestionRepository.ts @@ -0,0 +1,221 @@ +/** + * Suggestion Repository + * Handles all suggestion-related database operations + */ + +import type { JsonDatabase } from '../connection.ts'; + +export interface Suggestion { + id: string; + orderNum: number; + userId: string; + messageId?: string; + channelId?: string; + title: string; + description: string; + status: 'pending' | 'approved' | 'denied' | 'considering' | 'implemented'; + upvotes?: number; + downvotes?: number; + voters?: Record; + reviewedBy?: string; + reviewReason?: string; + createdAt: number; +} + +const TABLE_NAME = 'suggestions'; +const COUNTER_KEY = 'suggestion_counter'; + +/** + * Repository for managing suggestions + */ +export class SuggestionRepository { + constructor(private db: JsonDatabase) {} + + /** + * Get the next suggestion number + */ + private getNextNumber(): number { + const counter = this.db.get<{ value: number }>('counters', COUNTER_KEY); + const nextValue = (counter?.value ?? 0) + 1; + this.db.insert('counters', COUNTER_KEY, { value: nextValue }); + return nextValue; + } + + /** + * Create a new suggestion + */ + create(data: { userId: string; title: string; description: string }): Suggestion { + const id = `sug_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; + const orderNum = this.getNextNumber(); + + const suggestion: Suggestion = { + id, + orderNum, + userId: data.userId, + title: data.title, + description: data.description, + status: 'pending', + upvotes: 0, + downvotes: 0, + voters: {}, + createdAt: Date.now(), + }; + + const suggestions = this.db.get(TABLE_NAME) ?? []; + suggestions.push(suggestion); + this.db.set(TABLE_NAME, suggestions); + return suggestion; + } + + /** + * Get a suggestion by ID + */ + getById(id: string): Suggestion | null { + const suggestions = this.db.get(TABLE_NAME) ?? []; + return suggestions.find((s) => s.id === id) ?? null; + } + + /** + * Get a suggestion by order number + */ + getByOrderNum(orderNum: number): Suggestion | null { + const suggestions = this.db.get(TABLE_NAME) ?? []; + return suggestions.find((s) => s.orderNum === orderNum) ?? null; + } + + /** + * Get a suggestion by message ID + */ + getByMessageId(messageId: string): Suggestion | null { + const suggestions = this.db.get(TABLE_NAME) ?? []; + return suggestions.find((s) => s.messageId === messageId) ?? null; + } + + /** + * Get all suggestions by user ID + */ + getByUserId(userId: string): Suggestion[] { + const suggestions = this.db.get(TABLE_NAME) ?? []; + return suggestions + .filter((s) => s.userId === userId) + .sort((a, b) => b.createdAt - a.createdAt); + } + + /** + * Get all pending suggestions + */ + getPending(): Suggestion[] { + const suggestions = this.db.get(TABLE_NAME) ?? []; + return suggestions.filter((s) => s.status === 'pending'); + } + + /** + * Get all suggestions + */ + getAll(): Suggestion[] { + return this.db.get(TABLE_NAME) ?? []; + } + + /** + * Update suggestion + */ + update(id: string, data: Partial): boolean { + const suggestions = this.db.get(TABLE_NAME) ?? []; + const index = suggestions.findIndex((s) => s.id === id); + if (index === -1) return false; + + suggestions[index] = { ...suggestions[index], ...data }; + this.db.set(TABLE_NAME, suggestions); + return true; + } + + /** + * Update suggestion status with reviewer info + */ + updateStatus( + id: string, + status: Suggestion['status'], + reviewedBy?: string, + reviewReason?: string + ): Suggestion | null { + const suggestions = this.db.get(TABLE_NAME) ?? []; + const index = suggestions.findIndex((s) => s.id === id); + if (index === -1) return null; + + suggestions[index] = { + ...suggestions[index], + status, + reviewedBy, + reviewReason, + }; + this.db.set(TABLE_NAME, suggestions); + return suggestions[index]; + } + + /** + * Get suggestions by status + */ + getByStatus(status: Suggestion['status']): Suggestion[] { + const suggestions = this.db.get(TABLE_NAME) ?? []; + return suggestions.filter((s) => s.status === status); + } + + /** + * Vote on a suggestion + */ + vote(id: string, userId: string, voteType: 'up' | 'down'): { upvotes: number; downvotes: number } | null { + const suggestions = this.db.get(TABLE_NAME) ?? []; + const index = suggestions.findIndex((s) => s.id === id); + if (index === -1) return null; + + const suggestion = suggestions[index]; + const voters = suggestion.voters ?? {}; + const previousVote = voters[userId]; + + // Remove previous vote + if (previousVote === 'up') { + suggestion.upvotes = (suggestion.upvotes ?? 0) - 1; + } else if (previousVote === 'down') { + suggestion.downvotes = (suggestion.downvotes ?? 0) - 1; + } + + // Add new vote (or toggle off if same) + if (previousVote === voteType) { + delete voters[userId]; + } else { + voters[userId] = voteType; + if (voteType === 'up') { + suggestion.upvotes = (suggestion.upvotes ?? 0) + 1; + } else { + suggestion.downvotes = (suggestion.downvotes ?? 0) + 1; + } + } + + suggestion.voters = voters; + suggestions[index] = suggestion; + this.db.set(TABLE_NAME, suggestions); + + return { + upvotes: suggestion.upvotes ?? 0, + downvotes: suggestion.downvotes ?? 0, + }; + } + + /** + * Delete a suggestion + */ + delete(id: string): boolean { + const suggestions = this.db.get(TABLE_NAME) ?? []; + const filtered = suggestions.filter((s) => s.id !== id); + if (filtered.length === suggestions.length) return false; + this.db.set(TABLE_NAME, filtered); + return true; + } + + /** + * Count suggestions by user + */ + countByUserId(userId: string): number { + return this.getByUserId(userId).length; + } +} diff --git a/src/database/schema.ts b/src/database/schema.ts new file mode 100644 index 0000000..0e4ac59 --- /dev/null +++ b/src/database/schema.ts @@ -0,0 +1,339 @@ +/** + * Database Schema + * Defines all tables and migrations for the SQLite database + */ + +import type { SQLiteDatabase } from './sqlite.ts'; +import { createLogger } from '../utils/logger.ts'; + +const logger = createLogger('Schema'); + +// ============================================================================ +// Schema Version +// ============================================================================ + +const SCHEMA_VERSION = 1; + +// ============================================================================ +// Table Definitions +// ============================================================================ + +const TABLES = { + // Metadata table for schema versioning + schema_info: ` + CREATE TABLE IF NOT EXISTS schema_info ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `, + + // Reminders + reminders: ` + CREATE TABLE IF NOT EXISTS reminders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + channel_id TEXT, + reminder_text TEXT NOT NULL, + remind_at INTEGER NOT NULL, + is_recurring INTEGER DEFAULT 0, + recurrence_interval INTEGER, + created_at INTEGER NOT NULL, + UNIQUE(id) + ) + `, + + // Family relationships + families: ` + CREATE TABLE IF NOT EXISTS families ( + user_id TEXT PRIMARY KEY, + partner_id TEXT, + parent_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `, + + // Family children (many-to-many) + family_children: ` + CREATE TABLE IF NOT EXISTS family_children ( + parent_id TEXT NOT NULL, + child_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (parent_id, child_id) + ) + `, + + // Away status + away_status: ` + CREATE TABLE IF NOT EXISTS away_status ( + user_id TEXT PRIMARY KEY, + minecraft_username TEXT, + reason TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + `, + + // Suggestions + suggestions: ` + CREATE TABLE IF NOT EXISTS suggestions ( + id TEXT PRIMARY KEY, + order_num INTEGER NOT NULL, + user_id TEXT NOT NULL, + message_id TEXT, + channel_id TEXT, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT DEFAULT 'pending', + reviewed_by TEXT, + review_reason TEXT, + created_at INTEGER NOT NULL + ) + `, + + // Suggestion votes + suggestion_votes: ` + CREATE TABLE IF NOT EXISTS suggestion_votes ( + suggestion_id TEXT NOT NULL, + user_id TEXT NOT NULL, + vote_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (suggestion_id, user_id), + FOREIGN KEY (suggestion_id) REFERENCES suggestions(id) ON DELETE CASCADE + ) + `, + + // Applications + applications: ` + CREATE TABLE IF NOT EXISTS applications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + message_id TEXT, + channel_id TEXT, + status TEXT DEFAULT 'pending', + minecraft_username TEXT NOT NULL, + discord_age TEXT, + timezone TEXT, + activity TEXT, + why_join TEXT, + experience TEXT, + extra TEXT, + reviewed_by TEXT, + reviewed_at INTEGER, + created_at INTEGER NOT NULL + ) + `, + + // Champions + champions: ` + CREATE TABLE IF NOT EXISTS champions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + assigned_by TEXT NOT NULL, + reason TEXT, + start_date INTEGER NOT NULL, + end_date INTEGER NOT NULL, + is_active INTEGER DEFAULT 1 + ) + `, + + // QOTD Questions + qotd_questions: ` + CREATE TABLE IF NOT EXISTS qotd_questions ( + id TEXT PRIMARY KEY, + question TEXT NOT NULL, + added_by TEXT NOT NULL, + added_at INTEGER NOT NULL, + used_at INTEGER, + is_used INTEGER DEFAULT 0 + ) + `, + + // QOTD Config + qotd_config: ` + CREATE TABLE IF NOT EXISTS qotd_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + channel_id TEXT, + role_id TEXT, + scheduled_time TEXT DEFAULT '12:00', + is_enabled INTEGER DEFAULT 0, + last_sent_at INTEGER + ) + `, + + // Channel Filters + channel_filters: ` + CREATE TABLE IF NOT EXISTS channel_filters ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + filter_type TEXT NOT NULL, + pattern TEXT, + is_enabled INTEGER DEFAULT 1, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `, + + // Filter allowed roles + filter_allowed_roles: ` + CREATE TABLE IF NOT EXISTS filter_allowed_roles ( + filter_id TEXT NOT NULL, + role_id TEXT NOT NULL, + PRIMARY KEY (filter_id, role_id), + FOREIGN KEY (filter_id) REFERENCES channel_filters(id) ON DELETE CASCADE + ) + `, + + // Filter actions log + filter_actions: ` + CREATE TABLE IF NOT EXISTS filter_actions ( + id TEXT PRIMARY KEY, + filter_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + user_id TEXT NOT NULL, + message_content TEXT, + action TEXT NOT NULL, + timestamp INTEGER NOT NULL + ) + `, + + // Staff progress + staff_progress: ` + CREATE TABLE IF NOT EXISTS staff_progress ( + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + appeals_handled INTEGER DEFAULT 0, + punishments_issued INTEGER DEFAULT 0, + reports_handled INTEGER DEFAULT 0, + assists_given INTEGER DEFAULT 0, + total_points INTEGER DEFAULT 0, + level INTEGER DEFAULT 0, + last_active INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `, + + // Staff actions log + staff_actions: ` + CREATE TABLE IF NOT EXISTS staff_actions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + action_type TEXT NOT NULL, + points INTEGER NOT NULL, + description TEXT, + timestamp INTEGER NOT NULL + ) + `, + + // Blacklists + blacklists: ` + CREATE TABLE IF NOT EXISTS blacklists ( + user_id TEXT NOT NULL, + type TEXT NOT NULL, + reason TEXT, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, type) + ) + `, + + // Counters (for auto-increment IDs) + counters: ` + CREATE TABLE IF NOT EXISTS counters ( + name TEXT PRIMARY KEY, + value INTEGER DEFAULT 0 + ) + `, +}; + +// ============================================================================ +// Indexes +// ============================================================================ + +const INDEXES = [ + 'CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_reminders_remind_at ON reminders(remind_at)', + 'CREATE INDEX IF NOT EXISTS idx_families_partner ON families(partner_id)', + 'CREATE INDEX IF NOT EXISTS idx_families_parent ON families(parent_id)', + 'CREATE INDEX IF NOT EXISTS idx_away_expires ON away_status(expires_at)', + 'CREATE INDEX IF NOT EXISTS idx_suggestions_status ON suggestions(status)', + 'CREATE INDEX IF NOT EXISTS idx_suggestions_user ON suggestions(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status)', + 'CREATE INDEX IF NOT EXISTS idx_applications_user ON applications(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_champions_user ON champions(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_champions_active ON champions(is_active)', + 'CREATE INDEX IF NOT EXISTS idx_filters_channel ON channel_filters(channel_id)', + 'CREATE INDEX IF NOT EXISTS idx_staff_points ON staff_progress(total_points DESC)', +]; + +// ============================================================================ +// Schema Initialization +// ============================================================================ + +/** + * Initialize the database schema + */ +export async function initializeSchema(db: SQLiteDatabase): Promise { + logger.info('Initializing database schema...'); + + // Create tables + for (const [name, sql] of Object.entries(TABLES)) { + const result = db.exec(sql); + if (!result.success) { + throw new Error(`Failed to create table ${name}: ${result.error?.message}`); + } + logger.debug(`Created table: ${name}`); + } + + // Create indexes + for (const sql of INDEXES) { + const result = db.exec(sql); + if (!result.success) { + logger.warn(`Failed to create index: ${result.error?.message}`); + } + } + + // Set schema version + db.execute( + 'INSERT OR REPLACE INTO schema_info (key, value) VALUES (?, ?)', + ['version', String(SCHEMA_VERSION)] + ); + + logger.info(`Database schema initialized (version ${SCHEMA_VERSION})`); +} + +/** + * Get current schema version + */ +export function getSchemaVersion(db: SQLiteDatabase): number { + const result = db.queryOne<{ value: string }>( + 'SELECT value FROM schema_info WHERE key = ?', + ['version'] + ); + + if (result.success && result.data) { + return parseInt(result.data.value, 10); + } + return 0; +} + +/** + * Run migrations if needed + */ +export async function runMigrations(db: SQLiteDatabase): Promise { + const currentVersion = getSchemaVersion(db); + + if (currentVersion < SCHEMA_VERSION) { + logger.info(`Running migrations from version ${currentVersion} to ${SCHEMA_VERSION}`); + + // Add migration logic here as needed + // Example: + // if (currentVersion < 2) { + // await migrateToV2(db); + // } + + logger.info('Migrations complete'); + } +} diff --git a/src/database/sqlite.ts b/src/database/sqlite.ts new file mode 100644 index 0000000..c9111fc --- /dev/null +++ b/src/database/sqlite.ts @@ -0,0 +1,388 @@ +/** + * SQLite Database Connection + * Provides a robust SQLite database implementation with proper error handling + */ + +import { Database } from 'jsr:@db/sqlite@0.12'; +import { createLogger } from '../utils/logger.ts'; + +const logger = createLogger('Database'); + +// ============================================================================ +// Error Types +// ============================================================================ + +export class DatabaseError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly query?: string, + public readonly params?: unknown[] + ) { + super(message); + this.name = 'DatabaseError'; + } +} + +export class ConnectionError extends DatabaseError { + constructor(message: string, public readonly path: string) { + super(message, 'CONNECTION_ERROR'); + this.name = 'ConnectionError'; + } +} + +export class QueryError extends DatabaseError { + constructor(message: string, query: string, params?: unknown[]) { + super(message, 'QUERY_ERROR', query, params); + this.name = 'QueryError'; + } +} + +export class TransactionError extends DatabaseError { + constructor(message: string) { + super(message, 'TRANSACTION_ERROR'); + this.name = 'TransactionError'; + } +} + +// ============================================================================ +// Database Result Types +// ============================================================================ + +export interface QueryResult { + success: boolean; + data?: T; + error?: DatabaseError; + rowsAffected?: number; + lastInsertRowId?: number; +} + +// ============================================================================ +// SQLite Database Class +// ============================================================================ + +export class SQLiteDatabase { + private db: Database | null = null; + private readonly path: string; + private isConnected = false; + private inTransaction = false; + + constructor(path: string) { + this.path = path; + } + + // ========================================================================= + // Connection Management + // ========================================================================= + + /** + * Connect to the database + */ + async connect(): Promise { + if (this.isConnected) { + logger.warn('Database already connected'); + return; + } + + try { + // Ensure directory exists + const dir = this.path.substring(0, this.path.lastIndexOf('/')); + if (dir) { + await Deno.mkdir(dir, { recursive: true }).catch(() => {}); + } + + this.db = new Database(this.path); + this.isConnected = true; + + // Enable WAL mode for better performance + this.db.exec('PRAGMA journal_mode = WAL'); + this.db.exec('PRAGMA foreign_keys = ON'); + this.db.exec('PRAGMA busy_timeout = 5000'); + + logger.info(`Connected to SQLite database: ${this.path}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new ConnectionError(`Failed to connect to database: ${message}`, this.path); + } + } + + /** + * Close the database connection + */ + close(): void { + if (this.db) { + try { + this.db.close(); + this.isConnected = false; + logger.info('Database connection closed'); + } catch (error) { + logger.error('Error closing database:', error); + } + } + } + + /** + * Check if connected + */ + get connected(): boolean { + return this.isConnected; + } + + /** + * Ensure database is connected + */ + private ensureConnected(): void { + if (!this.db || !this.isConnected) { + throw new ConnectionError('Database not connected', this.path); + } + } + + // ========================================================================= + // Query Methods + // ========================================================================= + + /** + * Execute a query that returns rows + */ + query>(sql: string, params: unknown[] = []): QueryResult { + this.ensureConnected(); + + try { + const stmt = this.db!.prepare(sql); + const rows = stmt.all(...params) as T[]; + return { success: true, data: rows }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Query error: ${message}`, { sql, params }); + return { + success: false, + error: new QueryError(message, sql, params), + }; + } + } + + /** + * Execute a query that returns a single row + */ + queryOne>(sql: string, params: unknown[] = []): QueryResult { + this.ensureConnected(); + + try { + const stmt = this.db!.prepare(sql); + const row = stmt.get(...params) as T | undefined; + return { success: true, data: row ?? null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Query error: ${message}`, { sql, params }); + return { + success: false, + error: new QueryError(message, sql, params), + }; + } + } + + /** + * Execute a statement (INSERT, UPDATE, DELETE) + */ + execute(sql: string, params: unknown[] = []): QueryResult { + this.ensureConnected(); + + try { + const stmt = this.db!.prepare(sql); + const result = stmt.run(...params); + return { + success: true, + rowsAffected: result.changes, + lastInsertRowId: result.lastInsertRowId, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Execute error: ${message}`, { sql, params }); + return { + success: false, + error: new QueryError(message, sql, params), + }; + } + } + + /** + * Execute raw SQL (for schema changes) + */ + exec(sql: string): QueryResult { + this.ensureConnected(); + + try { + this.db!.exec(sql); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Exec error: ${message}`, { sql }); + return { + success: false, + error: new QueryError(message, sql), + }; + } + } + + // ========================================================================= + // Transaction Support + // ========================================================================= + + /** + * Begin a transaction + */ + beginTransaction(): QueryResult { + if (this.inTransaction) { + return { + success: false, + error: new TransactionError('Transaction already in progress'), + }; + } + + const result = this.exec('BEGIN TRANSACTION'); + if (result.success) { + this.inTransaction = true; + } + return result; + } + + /** + * Commit a transaction + */ + commit(): QueryResult { + if (!this.inTransaction) { + return { + success: false, + error: new TransactionError('No transaction in progress'), + }; + } + + const result = this.exec('COMMIT'); + if (result.success) { + this.inTransaction = false; + } + return result; + } + + /** + * Rollback a transaction + */ + rollback(): QueryResult { + if (!this.inTransaction) { + return { + success: false, + error: new TransactionError('No transaction in progress'), + }; + } + + const result = this.exec('ROLLBACK'); + if (result.success) { + this.inTransaction = false; + } + return result; + } + + /** + * Execute a function within a transaction + */ + async transaction(fn: () => Promise | T): Promise> { + const beginResult = this.beginTransaction(); + if (!beginResult.success) { + return { success: false, error: beginResult.error }; + } + + try { + const result = await fn(); + const commitResult = this.commit(); + if (!commitResult.success) { + this.rollback(); + return { success: false, error: commitResult.error }; + } + return { success: true, data: result }; + } catch (error) { + this.rollback(); + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: new TransactionError(`Transaction failed: ${message}`), + }; + } + } + + // ========================================================================= + // Utility Methods + // ========================================================================= + + /** + * Check if a table exists + */ + tableExists(tableName: string): boolean { + const result = this.queryOne<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + [tableName] + ); + return result.success && result.data !== null; + } + + /** + * Get table info + */ + getTableInfo(tableName: string): QueryResult> { + return this.query(`PRAGMA table_info(${tableName})`); + } + + /** + * Vacuum the database + */ + vacuum(): QueryResult { + return this.exec('VACUUM'); + } + + /** + * Get database file size + */ + async getSize(): Promise { + try { + const stat = await Deno.stat(this.path); + return stat.size; + } catch { + return 0; + } + } +} + +// ============================================================================ +// Database Factory +// ============================================================================ + +let instance: SQLiteDatabase | null = null; + +/** + * Create or get the database instance + */ +export async function createSQLiteDatabase(path: string): Promise { + if (instance && instance.connected) { + return instance; + } + + instance = new SQLiteDatabase(path); + await instance.connect(); + return instance; +} + +/** + * Get the current database instance + */ +export function getDatabase(): SQLiteDatabase { + if (!instance || !instance.connected) { + throw new ConnectionError('Database not initialized', ''); + } + return instance; +} diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..ec806fd --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,212 @@ +/** + * Interaction Create Event Handler + * Handles all Discord interactions (commands, buttons, modals, etc.) + */ + +import { Events, type Interaction } from 'discord.js'; +import type { EllyClient } from '../client/EllyClient.ts'; +import type { BotEvent } from '../types/index.ts'; + +export const interactionCreateEvent: BotEvent = { + name: Events.InteractionCreate, + + async execute(interaction: Interaction): Promise { + const client = interaction.client as EllyClient; + + // Handle slash commands + if (interaction.isChatInputCommand()) { + await handleCommand(client, interaction); + return; + } + + // Handle autocomplete + if (interaction.isAutocomplete()) { + await handleAutocomplete(client, interaction); + return; + } + + // Handle button interactions + if (interaction.isButton()) { + await handleButton(client, interaction); + return; + } + + // Handle modal submissions + if (interaction.isModalSubmit()) { + await handleModal(client, interaction); + return; + } + + // Handle select menu interactions + if (interaction.isStringSelectMenu()) { + await handleSelectMenu(client, interaction); + return; + } + }, +}; + +/** + * Handle slash command interactions + */ +async function handleCommand( + client: EllyClient, + interaction: Interaction & { isChatInputCommand(): true } +): Promise { + if (!interaction.isChatInputCommand()) return; + + const command = client.commands.get(interaction.commandName); + if (!command) { + client.logger.warn(`Unknown command: ${interaction.commandName}`); + return; + } + + // Check cooldown + const cooldownRemaining = client.isOnCooldown(interaction.user.id, interaction.commandName); + if (cooldownRemaining > 0) { + await interaction.reply({ + content: `โณ Please wait **${cooldownRemaining}** seconds before using this command again.`, + ephemeral: true, + }); + return; + } + + // Check permissions + if (interaction.guild && interaction.member) { + const member = interaction.guild.members.cache.get(interaction.user.id); + if (member && !client.permissions.hasPermission(member, command.permission)) { + await interaction.reply({ + content: client.permissions.formatDeniedMessage(command.permission), + ephemeral: true, + }); + return; + } + } + + // Execute command + try { + client.logger.debug(`Executing command: ${interaction.commandName}`, { + user: interaction.user.tag, + guild: interaction.guild?.name, + }); + + await command.execute(interaction); + + // Set cooldown + if (command.cooldown) { + client.setCooldown(interaction.user.id, interaction.commandName, command.cooldown); + } + } catch (error) { + client.logger.error(`Error executing command ${interaction.commandName}`, error); + + const errorMessage = 'โŒ An error occurred while executing this command.'; + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: errorMessage, ephemeral: true }); + } else { + await interaction.reply({ content: errorMessage, ephemeral: true }); + } + } +} + +/** + * Handle autocomplete interactions + */ +async function handleAutocomplete( + client: EllyClient, + interaction: Interaction & { isAutocomplete(): true } +): Promise { + if (!interaction.isAutocomplete()) return; + + const command = client.commands.get(interaction.commandName); + if (!command?.autocomplete) return; + + try { + await command.autocomplete(interaction); + } catch (error) { + client.logger.error(`Error handling autocomplete for ${interaction.commandName}`, error); + } +} + +/** + * Handle button interactions + */ +async function handleButton( + client: EllyClient, + interaction: Interaction & { isButton(): true } +): Promise { + if (!interaction.isButton()) return; + + client.logger.debug(`Button interaction: ${interaction.customId}`, { + user: interaction.user.tag, + }); + + // Handle paginator buttons + if (interaction.customId.startsWith('paginator:')) { + // Paginator handles its own interactions + return; + } + + // Handle application buttons + if (interaction.customId.startsWith('application:')) { + // TODO: Implement application button handler + return; + } + + // Handle suggestion buttons + if (interaction.customId.startsWith('suggestion:')) { + // TODO: Implement suggestion button handler + return; + } + + // Handle family buttons + if (interaction.customId.startsWith('family:')) { + // TODO: Implement family button handler + return; + } +} + +/** + * Handle modal submissions + */ +async function handleModal( + client: EllyClient, + interaction: Interaction & { isModalSubmit(): true } +): Promise { + if (!interaction.isModalSubmit()) return; + + client.logger.debug(`Modal submission: ${interaction.customId}`, { + user: interaction.user.tag, + }); + + // Handle application modal + if (interaction.customId.startsWith('application:')) { + // TODO: Implement application modal handler + return; + } + + // Handle feedback modal + if (interaction.customId.startsWith('feedback:')) { + // TODO: Implement feedback modal handler + return; + } +} + +/** + * Handle select menu interactions + */ +async function handleSelectMenu( + client: EllyClient, + interaction: Interaction & { isStringSelectMenu(): true } +): Promise { + if (!interaction.isStringSelectMenu()) return; + + client.logger.debug(`Select menu interaction: ${interaction.customId}`, { + user: interaction.user.tag, + values: interaction.values, + }); + + // Handle stats select menu + if (interaction.customId.startsWith('stats:')) { + // TODO: Implement stats select menu handler + return; + } +} diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..c6e6e77 --- /dev/null +++ b/src/events/messageCreate.ts @@ -0,0 +1,167 @@ +/** + * MessageCreate Event + * Handles message filtering and auto-moderation + */ + +import { Events, type Message, EmbedBuilder, ChannelType } from 'discord.js'; +import type { BotEvent } from '../types/index.ts'; +import type { EllyClient } from '../client/EllyClient.ts'; +import { FilterRepository } from '../database/repositories/FilterRepository.ts'; + +export const messageCreateEvent: BotEvent = { + name: Events.MessageCreate, + once: false, + + async execute(message: Message): Promise { + // Ignore bots and DMs + if (message.author.bot) return; + if (!message.guild) return; + if (!message.member) return; + + const client = message.client as EllyClient; + + // Check if filtering is enabled + if (!client.config.features.channelFiltering) return; + + const repo = new FilterRepository(client.database); + + // Get user's role IDs + const userRoles = message.member.roles.cache.map((r) => r.id); + + // Check message against filters + const matchedFilter = await repo.checkMessage( + message.channel.id, + message.content, + userRoles + ); + + // Also check for attachments if there's an attachment filter + if (!matchedFilter && message.attachments.size > 0) { + const filters = await repo.getChannelFilters(message.channel.id); + const attachmentFilter = filters.find((f) => f.filterType === 'attachments'); + + if (attachmentFilter && !attachmentFilter.allowedRoles.some((r) => userRoles.includes(r))) { + await handleFilterMatch(message, client, repo, attachmentFilter.id, 'attachments'); + return; + } + } + + if (matchedFilter) { + await handleFilterMatch(message, client, repo, matchedFilter.id, matchedFilter.filterType); + } + }, +}; + +/** + * Handle a filter match + */ +async function handleFilterMatch( + message: Message, + client: EllyClient, + repo: FilterRepository, + filterId: string, + filterType: string +): Promise { + try { + // Delete the message + await message.delete(); + + // Log the action + await repo.logAction({ + filterId, + channelId: message.channel.id, + userId: message.author.id, + messageContent: message.content.substring(0, 500), // Truncate for storage + action: 'deleted', + }); + + // Send warning to user + try { + const warningEmbed = new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('โš ๏ธ Message Removed') + .setDescription( + `Your message in ${message.channel} was removed because it violated the channel rules.` + ) + .addFields({ name: 'Reason', value: getFilterReason(filterType) }) + .setFooter({ text: 'Please follow the channel guidelines.' }) + .setTimestamp(); + + await message.author.send({ embeds: [warningEmbed] }); + } catch { + // User might have DMs disabled + } + + // Log to development channel + await logToDevChannel(message, client, filterType); + + } catch (error) { + // Message might have already been deleted + console.error('[Filter] Error handling filter match:', error); + } +} + +/** + * Get human-readable filter reason + */ +function getFilterReason(filterType: string): string { + switch (filterType) { + case 'links': + return 'Links are not allowed in this channel.'; + case 'images': + return 'Image links are not allowed in this channel.'; + case 'attachments': + return 'Attachments are not allowed in this channel.'; + case 'invites': + return 'Discord invites are not allowed in this channel.'; + case 'custom': + return 'Your message matched a blocked pattern.'; + default: + return 'Your message violated channel rules.'; + } +} + +/** + * Log filter action to development channel + */ +async function logToDevChannel( + message: Message, + client: EllyClient, + filterType: string +): Promise { + try { + const logChannelName = client.config.channels.developmentLogs; + const logChannel = message.guild?.channels.cache.find( + (c) => c.name === logChannelName && c.type === ChannelType.GuildText + ); + + if (!logChannel || logChannel.type !== ChannelType.GuildText) return; + + const embed = new EmbedBuilder() + .setColor(client.config.colors.warning) + .setTitle('๐Ÿ›ก๏ธ Message Filtered') + .addFields( + { name: 'User', value: `${message.author.tag} (${message.author.id})`, inline: true }, + { name: 'Channel', value: `${message.channel}`, inline: true }, + { name: 'Filter Type', value: filterType, inline: true }, + { + name: 'Content', + value: message.content.length > 0 + ? `\`\`\`${message.content.substring(0, 500)}\`\`\`` + : '*No text content*' + } + ) + .setTimestamp(); + + if (message.attachments.size > 0) { + embed.addFields({ + name: 'Attachments', + value: message.attachments.map((a) => a.name).join(', '), + }); + } + + await logChannel.send({ embeds: [embed] }); + } catch { + // Ignore logging errors + } +} diff --git a/src/events/ready.ts b/src/events/ready.ts new file mode 100644 index 0000000..f69554b --- /dev/null +++ b/src/events/ready.ts @@ -0,0 +1,119 @@ +/** + * Ready Event Handler + * Handles bot initialization when connected to Discord + */ + +import { Events, REST, Routes } from 'discord.js'; +import type { EllyClient } from '../client/EllyClient.ts'; +import type { BotEvent } from '../types/index.ts'; + +export const readyEvent: BotEvent = { + name: Events.ClientReady, + once: true, + + async execute(client: EllyClient): Promise { + console.log(''); + client.logger.info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + client.logger.info(' BOT CONNECTED '); + client.logger.info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + client.logger.info(`โœ“ Logged in as: ${client.user?.tag}`); + client.logger.info(` โ”œโ”€ User ID: ${client.user?.id}`); + client.logger.info(` โ”œโ”€ Guilds: ${client.guilds.cache.size}`); + client.logger.info(` โ””โ”€ Users: ${client.users.cache.size} cached`); + + // Register slash commands + await registerCommands(client); + + console.log(''); + client.logger.info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + client.logger.info(' ELLY IS NOW ONLINE '); + client.logger.info('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(''); + }, +}; + +/** + * Register slash commands with Discord + */ +async function registerCommands(client: EllyClient): Promise { + const token = Deno.env.get('DISCORD_TOKEN'); + if (!token || !client.user) { + client.logger.error('โœ— Cannot register commands: missing token or client user'); + return; + } + + const rest = new REST({ version: '10' }).setToken(token); + const commands = client.commands.map((cmd) => cmd.data.toJSON()); + + console.log(''); + client.logger.info('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); + client.logger.info(' SLASH COMMAND REGISTRATION '); + client.logger.info('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€'); + + try { + client.logger.info(`Preparing to sync ${commands.length} slash commands...`); + + // List all commands being registered + const commandsByCategory: Record = {}; + for (const cmd of client.commands.values()) { + const category = cmd.data.name.includes('bedwars') || cmd.data.name.includes('skywars') || cmd.data.name === 'guild' + ? 'Statistics' + : cmd.data.name.includes('marry') || cmd.data.name.includes('divorce') || cmd.data.name === 'relationship' + ? 'Family' + : cmd.data.name === 'remind' || cmd.data.name === 'away' + ? 'Utility' + : cmd.data.name === 'purge' + ? 'Moderation' + : 'Developer'; + + if (!commandsByCategory[category]) { + commandsByCategory[category] = []; + } + commandsByCategory[category].push(`/${cmd.data.name}`); + } + + for (const [category, cmds] of Object.entries(commandsByCategory)) { + client.logger.info(` ๐Ÿ“ ${category}: ${cmds.join(', ')}`); + } + + // Register to specific guild for faster updates during development + if (client.config.guild.id) { + client.logger.info(''); + client.logger.info(`Syncing to guild: ${client.config.guild.name}`); + client.logger.info(` โ”œโ”€ Guild ID: ${client.config.guild.id}`); + client.logger.info(` โ”œโ”€ Mode: Guild-specific (instant updates)`); + client.logger.info(` โ””โ”€ Syncing...`); + + const startTime = Date.now(); + await rest.put(Routes.applicationGuildCommands(client.user.id, client.config.guild.id), { + body: commands, + }); + const elapsed = Date.now() - startTime; + + client.logger.info(''); + client.logger.info(`โœ“ Successfully synced ${commands.length} commands to guild!`); + client.logger.info(` โ”œโ”€ Time: ${elapsed}ms`); + client.logger.info(` โ”œโ”€ Guild: ${client.config.guild.name}`); + client.logger.info(` โ””โ”€ Commands are available immediately`); + } else { + client.logger.info(''); + client.logger.info('Syncing globally (no guild ID configured)'); + client.logger.info(' โ”œโ”€ Mode: Global (may take up to 1 hour)'); + client.logger.info(' โ””โ”€ Syncing...'); + + const startTime = Date.now(); + await rest.put(Routes.applicationCommands(client.user.id), { + body: commands, + }); + const elapsed = Date.now() - startTime; + + client.logger.info(''); + client.logger.info(`โœ“ Successfully synced ${commands.length} commands globally!`); + client.logger.info(` โ”œโ”€ Time: ${elapsed}ms`); + client.logger.info(` โ””โ”€ Note: Global commands may take up to 1 hour to appear`); + } + } catch (error) { + client.logger.error('โœ— Failed to register commands'); + client.logger.error(` โ””โ”€ Error: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2a128fd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,268 @@ +/** + * Elly Discord Bot + * Main entry point + */ + +import { loadConfig, validateConfig, type ConfigValidationResult } from './config/config.ts'; +import { REST, Routes } from 'discord.js'; +import { EllyClient } from './client/EllyClient.ts'; +import { createLogger } from './utils/logger.ts'; + +// Import commands - Statistics +import { bedwarsCommand } from './commands/statistics/bedwars.ts'; +import { skywarsCommand } from './commands/statistics/skywars.ts'; +import { guildCommand } from './commands/statistics/guild.ts'; +import { serverCommand } from './commands/statistics/server.ts'; + +// Import commands - Utility +import { remindCommand } from './commands/utility/remind.ts'; +import { awayCommand } from './commands/utility/away.ts'; +import { suggestionsCommand } from './commands/suggestions/index.ts'; +import { championCommand } from './commands/utility/champion.ts'; +import { roleCommand } from './commands/utility/role.ts'; +import { qotdCommand } from './commands/qotd/index.ts'; +import { applicationsCommand } from './commands/applications/index.ts'; +import { staffCommand } from './commands/utility/staff.ts'; + +// Import commands - Family +import { marryCommand } from './commands/family/marry.ts'; +import { divorceCommand } from './commands/family/divorce.ts'; +import { relationshipCommand } from './commands/family/relationship.ts'; +import { adoptCommand } from './commands/family/adopt.ts'; + +// Import commands - Moderation +import { purgeCommand } from './commands/moderation/purge.ts'; +import { filterCommand } from './commands/moderation/filter.ts'; + +// Import commands - Developer +import { reloadCommand } from './commands/developer/reload.ts'; +import { syncCommand } from './commands/developer/sync.ts'; +import { evalCommand } from './commands/developer/eval.ts'; +import { debugCommand } from './commands/developer/debug.ts'; +import { databaseCommand } from './commands/developer/database.ts'; +import { emitCommand } from './commands/developer/emit.ts'; +import { shellCommand } from './commands/developer/shell.ts'; +import { blacklistCommand } from './commands/developer/blacklist.ts'; + +// Import events +import { messageCreateEvent } from './events/messageCreate.ts'; + +const logger = createLogger('Main'); + +async function main(): Promise { + console.log(''); + console.log('โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ ๐ŸŒธ ELLY DISCORD BOT ๐ŸŒธ โ•‘'); + console.log('โ•‘ v1.0.0 - TypeScript โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(''); + + logger.info('Starting Elly Discord Bot...'); + + // Load configuration + logger.info('Loading configuration from config.toml...'); + let config; + try { + config = await loadConfig('./config.toml'); + + // Validate configuration + const validation = validateConfig(config); + + if (!validation.valid) { + logger.error('โœ— Configuration validation failed:'); + for (const error of validation.errors) { + logger.error(` โœ— [${error.field}] ${error.message}`); + } + Deno.exit(1); + } + + // Log warnings + if (validation.warnings.length > 0) { + logger.warn(`โš  Configuration has ${validation.warnings.length} warning(s):`); + for (const warning of validation.warnings) { + logger.warn(` โš  [${warning.field}] ${warning.message}`); + } + } + + logger.info('โœ“ Configuration loaded and validated'); + logger.info(` โ”œโ”€ Bot Name: ${config.bot.name}`); + logger.info(` โ”œโ”€ Guild: ${config.guild.name} (${config.guild.id})`); + logger.info(` โ”œโ”€ Database: ${config.database.path}`); + logger.info(` โ””โ”€ Owners: ${config.bot.owners?.ids?.length ?? 0} configured`); + } catch (error) { + logger.error('โœ— Failed to load configuration', error); + Deno.exit(1); + } + + // Create client + logger.info('Initializing Discord client...'); + const client = new EllyClient(config); + logger.info('โœ“ Discord client created'); + + // Register commands + logger.info('Registering commands...'); + const commands = [ + // Statistics + { cmd: bedwarsCommand, category: 'Statistics' }, + { cmd: skywarsCommand, category: 'Statistics' }, + { cmd: guildCommand, category: 'Statistics' }, + { cmd: serverCommand, category: 'Statistics' }, + // Utility + { cmd: remindCommand, category: 'Utility' }, + { cmd: awayCommand, category: 'Utility' }, + { cmd: suggestionsCommand, category: 'Suggestions' }, + { cmd: championCommand, category: 'Utility' }, + { cmd: roleCommand, category: 'Utility' }, + { cmd: qotdCommand, category: 'QOTD' }, + { cmd: applicationsCommand, category: 'Applications' }, + { cmd: staffCommand, category: 'Utility' }, + // Family + { cmd: marryCommand, category: 'Family' }, + { cmd: divorceCommand, category: 'Family' }, + { cmd: relationshipCommand, category: 'Family' }, + { cmd: adoptCommand, category: 'Family' }, + // Moderation + { cmd: purgeCommand, category: 'Moderation' }, + { cmd: filterCommand, category: 'Moderation' }, + // Developer + { cmd: reloadCommand, category: 'Developer' }, + { cmd: syncCommand, category: 'Developer' }, + { cmd: evalCommand, category: 'Developer' }, + { cmd: debugCommand, category: 'Developer' }, + { cmd: databaseCommand, category: 'Developer' }, + { cmd: emitCommand, category: 'Developer' }, + { cmd: shellCommand, category: 'Developer' }, + { cmd: blacklistCommand, category: 'Developer' }, + ]; + + for (const { cmd, category } of commands) { + client.registerCommand(cmd); + logger.debug(` โ”œโ”€ /${cmd.data.name} [${category}]`); + } + logger.info(`โœ“ Registered ${client.commands.size} commands`); + + // Register message event for filtering + client.on(messageCreateEvent.name, (...args) => messageCreateEvent.execute(...args)); + logger.info('โœ“ Registered message filtering event'); + + // Initialize client + try { + await client.initialize(); + } catch (error) { + logger.error('Failed to initialize client', error); + Deno.exit(1); + } + + // Set up interaction handler + client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const command = client.commands.get(interaction.commandName); + if (!command) return; + + // Check cooldown + const cooldownRemaining = client.isOnCooldown(interaction.user.id, interaction.commandName); + if (cooldownRemaining > 0) { + await interaction.reply({ + content: `Please wait ${cooldownRemaining} seconds before using this command again.`, + ephemeral: true, + }); + return; + } + + // Check permissions + if (interaction.guild && interaction.member) { + const member = interaction.guild.members.cache.get(interaction.user.id); + if (member && !client.permissions.hasPermission(member, command.permission)) { + await interaction.reply({ + content: client.permissions.formatDeniedMessage(command.permission), + ephemeral: true, + }); + return; + } + } + + // Execute command + try { + await command.execute(interaction); + + // Set cooldown + if (command.cooldown) { + client.setCooldown(interaction.user.id, interaction.commandName, command.cooldown); + } + } catch (error) { + // Use the centralized error handler + await client.errorHandler.handleCommandError(error, interaction); + } + }); + + // Handle shutdown + const shutdown = async () => { + logger.info('Received shutdown signal'); + await client.shutdown(); + Deno.exit(0); + }; + + Deno.addSignalListener('SIGINT', shutdown); + Deno.addSignalListener('SIGTERM', shutdown); + + // Login + const token = Deno.env.get('DISCORD_TOKEN'); + if (!token) { + logger.error('DISCORD_TOKEN environment variable is not set'); + Deno.exit(1); + } + + try { + await client.login(token); + } catch (error) { + logger.error('Failed to login', error); + Deno.exit(1); + } + + // Sync commands after login + logger.info('Syncing slash commands...'); + try { + const startTime = Date.now(); + const rest = new REST({ version: '10' }).setToken(token); + + // Build command data array + const commandData = Array.from(client.commands.values()).map((cmd) => cmd.data.toJSON()); + + // Sync to guild (faster for development) + const result = await rest.put( + Routes.applicationGuildCommands(client.user!.id, config.guild.id), + { body: commandData } + ) as unknown[]; + + const syncTime = Date.now() - startTime; + + logger.info('โœ“ Commands synced successfully'); + logger.info(` โ”œโ”€ Commands: ${result.length} synced`); + logger.info(` โ”œโ”€ Guild: ${config.guild.name} (${config.guild.id})`); + logger.info(` โ””โ”€ Time: ${syncTime}ms`); + + // Log command details + const categories = new Map(); + for (const { cmd, category } of commands) { + if (!categories.has(category)) { + categories.set(category, []); + } + categories.get(category)!.push(cmd.data.name); + } + + logger.debug('Command breakdown by category:'); + for (const [category, cmds] of categories) { + logger.debug(` โ”œโ”€ ${category}: ${cmds.join(', ')}`); + } + } catch (error) { + logger.error('โœ— Failed to sync commands', error); + // Don't exit - bot can still work with existing commands + } +} + +// Run +main().catch((error) => { + logger.error('Unhandled error', error); + Deno.exit(1); +}); diff --git a/src/services/PermissionService.ts b/src/services/PermissionService.ts new file mode 100644 index 0000000..591d874 --- /dev/null +++ b/src/services/PermissionService.ts @@ -0,0 +1,225 @@ +/** + * Permission Service for Elly Discord Bot + * Handles permission level checks and role-based access control + */ + +import type { GuildMember, Role } from 'discord.js'; +import type { Config } from '../config/types.ts'; +import { PermissionLevel } from '../types/index.ts'; + +/** + * Service for managing permission levels and access control + */ +export class PermissionService { + private config: Config; + private roleCache = new Map(); + + constructor(config: Config) { + this.config = config; + } + + /** + * Get the permission level for a guild member + */ + getPermissionLevel(member: GuildMember): PermissionLevel { + // Check if owner + if (this.config.bot.owners.ids.includes(member.id)) { + return PermissionLevel.Owner; + } + + // Check roles in order of priority (highest first) + const roleChecks: Array<[string, PermissionLevel]> = [ + [this.config.roles.developer, PermissionLevel.Developer], + [this.config.roles.admin, PermissionLevel.Admin], + [this.config.roles.leader, PermissionLevel.Leader], + [this.config.roles.officer, PermissionLevel.Officer], + [this.config.roles.guild_member, PermissionLevel.GuildMember], + ]; + + for (const [roleName, level] of roleChecks) { + if (this.hasRole(member, roleName)) { + return level; + } + } + + return PermissionLevel.User; + } + + /** + * Check if a member has a specific role by name + */ + hasRole(member: GuildMember, roleName: string): boolean { + return member.roles.cache.some( + (role) => role.name.toLowerCase() === roleName.toLowerCase() + ); + } + + /** + * Check if a member has the required permission level + */ + hasPermission(member: GuildMember, required: PermissionLevel): boolean { + return this.getPermissionLevel(member) >= required; + } + + /** + * Check if a member is a bot owner + */ + isOwner(member: GuildMember): boolean { + return this.config.bot.owners.ids.includes(member.id); + } + + /** + * Check if a member is at least an officer + */ + isStaff(member: GuildMember): boolean { + return this.hasPermission(member, PermissionLevel.Officer); + } + + /** + * Check if a member is at least a leader + */ + isLeader(member: GuildMember): boolean { + return this.hasPermission(member, PermissionLevel.Leader); + } + + /** + * Check if a member is at least an admin + */ + isAdmin(member: GuildMember): boolean { + return this.hasPermission(member, PermissionLevel.Admin); + } + + /** + * Check if a member is a developer + */ + isDeveloper(member: GuildMember): boolean { + return this.hasPermission(member, PermissionLevel.Developer); + } + + /** + * Check if a member is blacklisted from applications + */ + isApplicationsBlacklisted(member: GuildMember): boolean { + return this.hasRole(member, this.config.roles.applications_blacklisted); + } + + /** + * Check if a member is blacklisted from suggestions + */ + isSuggestionsBlacklisted(member: GuildMember): boolean { + return this.hasRole(member, this.config.roles.suggestions_blacklisted); + } + + /** + * Check if a member has the champion role + */ + isChampion(member: GuildMember): boolean { + return this.hasRole(member, this.config.roles.champion); + } + + /** + * Check if a member has the away role + */ + isAway(member: GuildMember): boolean { + return this.hasRole(member, this.config.roles.away); + } + + /** + * Check if a role is manageable by officers + */ + isManageableRole(roleId: string): boolean { + return this.config.roles.manageable.ids.includes(roleId); + } + + /** + * Get the permission level name + */ + static getLevelName(level: PermissionLevel): string { + const names: Record = { + [PermissionLevel.User]: 'User', + [PermissionLevel.GuildMember]: 'Guild Member', + [PermissionLevel.Officer]: 'Officer', + [PermissionLevel.Leader]: 'Leader', + [PermissionLevel.Admin]: 'Admin', + [PermissionLevel.Developer]: 'Developer', + [PermissionLevel.Owner]: 'Owner', + }; + return names[level]; + } + + /** + * Get the required roles for a permission level + */ + getRequiredRoles(level: PermissionLevel): string[] { + const roles: string[] = []; + + switch (level) { + case PermissionLevel.Owner: + roles.push('Bot Owner'); + break; + case PermissionLevel.Developer: + roles.push(this.config.roles.developer); + break; + case PermissionLevel.Admin: + roles.push(this.config.roles.admin); + break; + case PermissionLevel.Leader: + roles.push(this.config.roles.leader); + break; + case PermissionLevel.Officer: + roles.push(this.config.roles.officer); + break; + case PermissionLevel.GuildMember: + roles.push(this.config.roles.guild_member); + break; + } + + return roles; + } + + /** + * Format a permission denied message + */ + formatDeniedMessage(required: PermissionLevel): string { + const levelName = PermissionService.getLevelName(required); + const roles = this.getRequiredRoles(required); + + if (roles.length > 0) { + return `You need the **${levelName}** permission level (${roles.join(' or ')}) to use this command.`; + } + + return `You need the **${levelName}** permission level to use this command.`; + } +} + +/** + * Permission check decorator for commands + * Usage: @requirePermission(PermissionLevel.Officer) + */ +export function requirePermission(level: PermissionLevel) { + return function ( + _target: unknown, + _propertyKey: string, + descriptor: PropertyDescriptor + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function ( + this: { permissionService: PermissionService }, + interaction: { member: GuildMember; reply: (options: unknown) => Promise } + ) { + const member = interaction.member; + + if (!this.permissionService.hasPermission(member, level)) { + return interaction.reply({ + content: this.permissionService.formatDeniedMessage(level), + ephemeral: true, + }); + } + + return originalMethod.apply(this, [interaction]); + }; + + return descriptor; + }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..f5a6c5f --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,267 @@ +/** + * Core Type Definitions for Elly Discord Bot + */ + +import type { + ChatInputCommandInteraction, + AutocompleteInteraction, + SlashCommandBuilder, + SlashCommandSubcommandsOnlyBuilder, + ContextMenuCommandBuilder, + GuildMember, + Message, + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; + +// ============================================================================ +// Permission Types +// ============================================================================ + +export enum PermissionLevel { + User = 0, + GuildMember = 1, + Officer = 2, + Leader = 3, + Admin = 4, + Developer = 5, + Owner = 6, +} + +// ============================================================================ +// Command Types +// ============================================================================ + +export type CommandBuilder = + | SlashCommandBuilder + | SlashCommandSubcommandsOnlyBuilder + | Omit; + +export interface Command { + data: CommandBuilder; + permission: PermissionLevel; + cooldown?: number; // seconds + guildOnly?: boolean; + ownerOnly?: boolean; + execute: (interaction: ChatInputCommandInteraction) => Promise; + autocomplete?: (interaction: AutocompleteInteraction) => Promise; +} + +export interface CommandGroup { + name: string; + description: string; + commands: Command[]; +} + +// ============================================================================ +// Event Types +// ============================================================================ + +export interface BotEvent { + name: string; + once?: boolean; + execute: (...args: T[]) => Promise | void; +} + +// ============================================================================ +// Component Types +// ============================================================================ + +export interface ButtonHandler { + customId: string | RegExp; + execute: (interaction: ButtonInteraction) => Promise; +} + +export interface ModalHandler { + customId: string | RegExp; + execute: (interaction: ModalSubmitInteraction) => Promise; +} + +export interface SelectMenuHandler { + customId: string | RegExp; + execute: (interaction: StringSelectMenuInteraction) => Promise; +} + +// ============================================================================ +// Database Types +// ============================================================================ + +export interface Application { + id: string; + userId: string; + messageId: string; + threadId?: string; + username: string; + minecraftUsername?: string; + status: 'pending' | 'accepted' | 'denied'; + rating: number; + createdAt: string; + updatedAt: string; +} + +export interface ApplicationFeedback { + id: string; + applicationId: string; + userId: string; + feedback: string; + isPositive: boolean; + createdAt: string; +} + +export interface Suggestion { + id: string; + order: number; + userId: string; + messageId: string; + channelId?: string; + title: string; + description: string; + status: 'pending' | 'accepted' | 'denied'; + upvotes: string[]; + downvotes: string[]; + createdAt: string; +} + +export interface FamilyRelationship { + userId: string; + partnerId?: string; + parentId?: string; + children: string[]; + createdAt: string; + updatedAt: string; +} + +export interface Reminder { + id: string; + userId: string; + channelId?: string; + reminderText: string; + remindAt: string; + isRecurring: boolean; + recurrenceInterval?: number; + createdAt: string; +} + +export interface Champion { + userId: string; + expiresAt: string; + grantedBy: string; + createdAt: string; +} + +export interface AwayStatus { + userId: string; + minecraftUsername?: string; + reason: string; + expiresAt: string; + createdAt: string; +} + +export interface QOTDQuestion { + id: string; + authorId: string; + questionText: string; + isSent: boolean; + sentAt?: string; + createdAt: string; +} + +export interface QOTDChannel { + channelId: string; + isEnabled: boolean; +} + +export interface StaffProgress { + userId: string; + appealsHandled: number; + punishments: number; + assists: number; + createdAt: string; + updatedAt: string; +} + +export interface FilteredChannel { + channelId: string; + filterType: string; + isEnabled: boolean; +} + +export interface Blacklist { + userId: string; + type: 'applications' | 'suggestions'; + reason?: string; + createdBy: string; + createdAt: string; +} + +export interface ModLog { + id: string; + userId: string; + moderatorId: string; + action: string; + reason?: string; + createdAt: string; +} + +// ============================================================================ +// Utility Types +// ============================================================================ + +export interface EmbedColors { + primary: number; + success: number; + warning: number; + error: number; + info: number; +} + +export interface CooldownEntry { + userId: string; + commandName: string; + expiresAt: number; +} + +export interface PaginatorOptions { + itemsPerPage?: number; + timeout?: number; + authorId?: string; +} + +// ============================================================================ +// API Response Types +// ============================================================================ + +export interface APIResponse { + success: boolean; + data?: T; + error?: string; +} + +// ============================================================================ +// Audit Types +// ============================================================================ + +export interface AuditAction { + type: string; + moderatorId: string; + targetId?: string; + reason?: string; + details?: string; +} + +export type AuditActionType = + | 'BAN' + | 'KICK' + | 'MUTE' + | 'WARN' + | 'APPLICATION_ACCEPT' + | 'APPLICATION_DENY' + | 'SUGGESTION_ACCEPT' + | 'SUGGESTION_DENY' + | 'ROLE_ADD' + | 'ROLE_REMOVE' + | 'CHAMPION_ADD' + | 'CHAMPION_REMOVE' + | 'AWAY_ADD' + | 'AWAY_REMOVE'; diff --git a/src/utils/embeds.ts b/src/utils/embeds.ts new file mode 100644 index 0000000..c58e936 --- /dev/null +++ b/src/utils/embeds.ts @@ -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, + 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, + 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 = { + 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(); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..06a6df3 --- /dev/null +++ b/src/utils/errors.ts @@ -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; + + constructor( + message: string, + code: string, + options: { + userMessage?: string; + isOperational?: boolean; + context?: Record; + 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 { + 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; + 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; + } = {} + ) { + 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; + } = {} + ) { + 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; + 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; + } = {} + ) { + 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; + } = {} + ) { + 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): Promise { + 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 { + 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): 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; + } { + const errorsByCode: Record = {}; + + 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 Promise>( + fn: T, + context?: Record +): T { + return (async (...args: Parameters) => { + 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( + 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( + fn: () => Promise +): 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( + fn: () => Promise, + options: { + maxAttempts?: number; + initialDelay?: number; + maxDelay?: number; + backoffFactor?: number; + } = {} +): Promise { + 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; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..1f0042a --- /dev/null +++ b/src/utils/logger.ts @@ -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 = { + 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 { + 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, + }); +} diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts new file mode 100644 index 0000000..8e14f31 --- /dev/null +++ b/src/utils/pagination.ts @@ -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 { + private pages: T[]; + private currentPage = 0; + private message: Message | null = null; + private readonly options: Required; + private collector: ReturnType | 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 { + return new ActionRowBuilder().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[] } { + 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 { + // 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 { + 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 { + if (!this.message) return; + + try { + if (this.options.deleteOnTimeout) { + await this.message.delete(); + } else { + // Disable all buttons + const disabledRow = new ActionRowBuilder().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 { + if (this.collector) { + this.collector.stop(); + } + + if (this.message) { + try { + const disabledRow = new ActionRowBuilder().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 { + return new ButtonPaginator(embeds, options); +} + +/** + * Chunk an array into pages + */ +export function chunkArray(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( + 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; + }); +} diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..089880a --- /dev/null +++ b/src/utils/time.ts @@ -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 = { + 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 ``; +} + +/** + * 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;