(Feat): Added a minimal pikanetwork client

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

280
src/api/pika/cache.ts Normal file
View File

@@ -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<T> {
data: T;
expires: number;
}
/**
* Generic cache with TTL support
*/
class TTLCache<T> {
private cache = new Map<string, CacheEntry<T>>();
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<ProfileResponse>;
private clans: TTLCache<ClanResponse>;
private leaderboards: TTLCache<LeaderboardResponse>;
private cleanupInterval: number | undefined;
constructor(ttl: number = 3600000) {
this.profiles = new TTLCache<ProfileResponse>(ttl);
this.clans = new TTLCache<ClanResponse>(ttl);
this.leaderboards = new TTLCache<LeaderboardResponse>(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();
}
}

779
src/api/pika/client.ts Normal file
View File

@@ -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<string, string> = {
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<T>(endpoint: string): Promise<T | null> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// =========================================================================
// Profile Methods
// =========================================================================
/**
* Get a player's profile
*/
async getProfile(username: string): Promise<ProfileResponse | null> {
// Check cache first
const cached = this.cache.getProfile(username);
if (cached) return cached;
// Fetch from API
const data = await this.request<ProfileResponse>(`/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<boolean> {
const profile = await this.getProfile(username);
return profile !== null;
}
// =========================================================================
// Clan Methods
// =========================================================================
/**
* Get clan information
*/
async getClan(name: string): Promise<ClanResponse | null> {
// Check cache first
const cached = this.cache.getClan(name);
if (cached) return cached;
// Fetch from API
const data = await this.request<ClanResponse>(`/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<string[]> {
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<LeaderboardResponse | null> {
// 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<LeaderboardResponse>(
`/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<BedWarsStats | null> {
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<SkyWarsStats | null> {
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<MinimalLeaderboardData[]> {
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<Map<string, LeaderboardResponse | null>> {
const results = new Map<string, LeaderboardResponse | null>();
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<TotalLeaderboardEntry[] | null> {
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<TotalLeaderboardEntry[]>(
`/leaderboards?${params.toString()}`
);
return data;
}
// =========================================================================
// Profile Extended Methods
// =========================================================================
/**
* Get friend list for a player
*/
async getFriendList(username: string): Promise<string[]> {
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<ClanResponse | null> {
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<MiscInfo | null> {
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<JoinInfo | null> {
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<ServerStatus | null> {
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<string | null> {
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<Punishment[]> {
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 = /<div class="row"[^>]*>([\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>([^<]+)<\/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<VoteLeaderboard | null> {
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();
}
}

63
src/api/pika/index.ts Normal file
View File

@@ -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';

354
src/api/pika/types.ts Normal file
View File

@@ -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;
}

361
src/client/EllyClient.ts Normal file
View File

@@ -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<string, Command>();
public readonly cooldowns = new Collection<string, Collection<string, number>>();
// 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<void> {
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<void> {
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<string, ActivityType> = {
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<void> {
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<void> {
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');
}
}

View File

@@ -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<void> {
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<void> {
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<ButtonBuilder>().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<Array<{ appId: string }>>('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: [],
});
}
}

View File

@@ -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<void> {
// Check if user is blacklisted
const blacklists = client.database.get<Array<{ userId: string; type: string }>>('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 <t:${Math.floor(waitUntil / 1000)}:R>.`,
ephemeral: true,
});
return;
}
// Show application modal
const modal = new ModalBuilder()
.setCustomId('application:submit')
.setTitle('Guild Application')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().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<TextInputBuilder>().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<TextInputBuilder>().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<TextInputBuilder>().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<TextInputBuilder>().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<void> {
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<ButtonBuilder>().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<ButtonBuilder>().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<ButtonBuilder>, row2 as ActionRowBuilder<ButtonBuilder>] });
} catch {
// Message might be deleted
}
});
}
async function handleButtonAccept(
i: ButtonInteraction,
applicationId: string,
client: EllyClient,
repo: ApplicationRepository,
message: import('discord.js').Message
): Promise<void> {
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<ButtonBuilder>().addComponents(
...message.components[0].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
const row2 = new ActionRowBuilder<ButtonBuilder>().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<void> {
// Show denial reason modal
const modal = new ModalBuilder()
.setCustomId(`app:deny_modal:${applicationId}`)
.setTitle('Deny Application')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().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<ButtonBuilder>().addComponents(
...message.components[0].components.map((c) =>
ButtonBuilder.from(c as import('discord.js').ButtonComponent).setDisabled(true)
)
);
const row2 = new ActionRowBuilder<ButtonBuilder>().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<void> {
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<void> {
const modal = new ModalBuilder()
.setCustomId(`app:add_note:${applicationId}`)
.setTitle('Add Note')
.addComponents(
new ActionRowBuilder<TextInputBuilder>().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<Array<{ appId: string; note: string; by: string; at: number }>>('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<void> {
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<void> {
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<void> {
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
}
}

View File

@@ -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<void> {
// 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<ButtonBuilder>().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<ButtonBuilder>().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<string, string> = {
pending: '⏳',
accepted: '✅',
denied: '❌',
all: '📋',
};
const statusColors: Record<string, number> = {
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<void> {
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<void> {
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] });
}

View File

@@ -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<void> {
// 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<Array<{ appId: string; note: string; by: string; at: number }>>('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<void> {
// 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<Array<{ appId: string; note: string; by: string; at: number }>>('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<void> {
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
}
}

View File

@@ -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<void> {
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<void> {
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 });
}

View File

@@ -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<void> {
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<string, number> = {
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<string, number> = {};
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<string, number> = {};
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<void> {
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<string, { total: number; accepted: number; denied: number }> = {};
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`;
}

View File

@@ -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<void> {
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<Array<{ appId: string; note: string; by: string; at: number }>>('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<ButtonBuilder>[] = [];
if (application.status === 'pending') {
components.push(
new ActionRowBuilder<ButtonBuilder>().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: `<t:${submittedAt}:F> (<t:${submittedAt}:R>)`, 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: `<t:${reviewedAt}:F>`,
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;
}

View File

@@ -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<void> {
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<void> {
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: `<t:${Math.floor(latest.createdAt / 1000)}:R>`, 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 });
}

View File

@@ -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<void> {
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<void> {
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<BlacklistEntry[]>('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<void> {
const user = interaction.options.getUser('user', true);
const type = interaction.options.getString('type', true);
// Get existing blacklists
const blacklists = client.database.get<BlacklistEntry[]>('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<void> {
const user = interaction.options.getUser('user', true);
// Get existing blacklists
const blacklists = client.database.get<BlacklistEntry[]>('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<void> {
const typeFilter = interaction.options.getString('type');
// Get existing blacklists
let blacklists = client.database.get<BlacklistEntry[]>('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<string, BlacklistEntry[]>();
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 });
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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]}`;
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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]}`;
}

View File

@@ -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<void> {
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}`);
}
},
};

View File

@@ -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<void> {
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] });
},
};

View File

@@ -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<void> {
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.'),
],
});
}
},
};

View File

@@ -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<void> {
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] });
}
},
};

View File

@@ -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<void> {
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.`),
],
});
}
},
};

View File

@@ -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<void> {
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<ButtonBuilder>().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: [],
});
}
},
};

View File

@@ -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<void> {
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<ButtonBuilder>().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: [],
});
}
},
};

View File

@@ -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<void> {
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<ButtonBuilder>().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: [],
});
}
},
};

View File

@@ -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<void> {
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] });
},
};

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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(),
],
});
}

View File

@@ -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<void> {
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.'),
],
});
}
},
};

611
src/commands/qotd/index.ts Normal file
View File

@@ -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<void> {
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<void> {
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<TextInputBuilder>().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<void> {
const question = interaction.options.getString('question', true);
// Store suggestion
const suggestions = client.database.get<Array<{
id: string;
question: string;
userId: string;
createdAt: number;
status: 'pending' | 'approved' | 'rejected';
}>>('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<void> {
const targetUser = interaction.options.getUser('user') ?? interaction.user;
const streaks = client.database.get<Record<string, { current: number; best: number; lastAnswer: number }>>('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 ? `<t:${Math.floor(userStreak.lastAnswer / 1000)}:R>` : 'Never', inline: true }
)
.setTimestamp();
await interaction.reply({ embeds: [embed], ephemeral: true });
}
async function handleAdd(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: QOTDRepository
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<ButtonBuilder>().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<void> {
const questions = await repo.getUnusedQuestions();
const usedQuestions = await repo.getUsedQuestions();
const config = await repo.getConfig();
const answers = client.database.get<Array<{ oderId: string }>>('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<void> {
const streaks = client.database.get<Record<string, { current: number; best: number }>>('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<void> {
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<void> {
const streaks = client.database.get<Record<string, { current: number; best: number; lastAnswer: number }>>('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);
}

View File

@@ -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<void> {
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<string, string> = {
all_modes: 'All Modes',
solo: 'Solo',
doubles: 'Doubles',
triples: 'Triples',
quad: 'Quads',
};
// Get rank color
const rankColors: Record<string, number> = {
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] });
},
};

View File

@@ -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<void> {
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';
}
}

View File

@@ -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<void> {
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] });
},
};

View File

@@ -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<void> {
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<string, string> = {
all_modes: 'All Modes',
solo: 'Solo',
doubles: 'Doubles',
};
// Get rank color
const rankColors: Record<string, number> = {
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] });
},
};

View File

@@ -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<void> {
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<void> {
// Check blacklist
const blacklists = client.database.get<Array<{ userId: string; type: string }>>('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<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('title')
.setLabel('Title')
.setPlaceholder('Brief title for your suggestion')
.setStyle(TextInputStyle.Short)
.setMaxLength(100)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().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<void> {
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<void> {
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<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId('title')
.setLabel('Title')
.setValue(suggestion.title)
.setStyle(TextInputStyle.Short)
.setMaxLength(100)
.setRequired(true)
),
new ActionRowBuilder<TextInputBuilder>().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<void> {
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<void> {
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<string, string> = {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, string> = {
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<void> {
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<void> {
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<string, { color: number; emoji: string }> = {
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: `<t:${Math.floor(suggestion.createdAt / 1000)}:R>`, 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<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().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<void> {
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<string, { color: number; emoji: string }> = {
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<void> {
try {
const user = await client.users.fetch(userId);
const statusConfig: Record<string, { color: number; title: string }> = {
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
}
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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] });
}

View File

@@ -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<void> {
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<void> {
// 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: `<t:${Math.floor(champion.endDate / 1000)}:R>`, 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<void> {
// 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<void> {
// 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: `<t:${Math.floor(champion.endDate / 1000)}:R>`, inline: true }
)
.setFooter({ text: `Extended by ${interaction.user.tag}` })
.setTimestamp(),
],
});
}
/**
* Handle checking champion status
*/
async function handleCheck(
interaction: ChatInputCommandInteraction,
client: EllyClient,
repo: ChampionRepository
): Promise<void> {
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: `<t:${Math.floor(champion.endDate / 1000)}:F>`, 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<void> {
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(),
],
});
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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,
});
}

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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
}
}

View File

@@ -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<void> {
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<void> {
// Pick random scenario type and scenario
const types = Object.keys(SCENARIOS) as Array<keyof typeof SCENARIOS>;
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<ButtonBuilder>().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<void> {
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<string, 'appeal' | 'punishment' | 'report' | 'assist'> = {
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<string, number> = {
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<string, string> = {
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<void> {
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<void> {
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] });
}

427
src/config/config.ts Normal file
View File

@@ -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<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
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<Config> {
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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<T>(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<string, unknown>)[part];
}
return current as T;
}

98
src/config/types.ts Normal file
View File

@@ -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;
}

View File

@@ -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<T, E = RepositoryError> =
| { ok: true; value: T }
| { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// ============================================================================
// Base Repository Class
// ============================================================================
export abstract class BaseRepository<T extends { id?: string }> {
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<R>(
result: QueryResult<R>,
operation: string
): Result<R> {
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<void>,
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<string, unknown>): T;
/**
* Convert entity to database row
*/
protected abstract entityToRow(entity: T): Record<string, unknown>;
// =========================================================================
// Common CRUD Operations
// =========================================================================
/**
* Find by ID
*/
async findById(id: string): Promise<Result<T | null>> {
const result = this.db.queryOne<Record<string, unknown>>(
`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<Result<T[]>> {
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<Record<string, unknown>>(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<Result<number>> {
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<Result<boolean>> {
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<Result<boolean>> {
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<Result<T[]>> {
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<Record<string, unknown>>(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<Result<T | null>> {
const result = this.db.queryOne<Record<string, unknown>>(
`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<Result<number>> {
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<Result<number>> {
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);
}
}

View File

@@ -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<void> {
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<T>(fn: () => Promise<T> | T): Promise<T> {
this.ensureInitialized();
const result = await this.db!.transaction(fn);
if (!result.success) {
throw result.error;
}
return result.data!;
}
/**
* Get database statistics
*/
async getStats(): Promise<DatabaseStats> {
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<void> {
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<void> {
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<void> {
// 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<DatabaseManager> {
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;
}

244
src/database/connection.ts Normal file
View File

@@ -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<void> {
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<string, Map<string, unknown>> = 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<void> {
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<string, unknown>();
for (const [key, value] of Object.entries(records as Record<string, unknown>)) {
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<void> {
const obj: Record<string, Record<string, unknown>> = {};
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<string, unknown> {
if (!this.data.has(name)) {
this.data.set(name, new Map());
}
return this.data.get(name)!;
}
/**
* Insert a record
*/
insert<T>(table: string, key: string, value: T): void {
const tableData = this.getTable(table);
tableData.set(key, value);
this.scheduleSave();
}
/**
* Get a record
*/
get<T>(table: string, key: string): T | null {
const tableData = this.getTable(table);
return (tableData.get(key) as T) ?? null;
}
/**
* Update a record
*/
update<T>(table: string, key: string, value: Partial<T>): 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<T>(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<T>(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<void> {
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);
}

36
src/database/index.ts Normal file
View File

@@ -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';

View File

@@ -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<Application, 'id' | 'createdAt' | 'status'>): Promise<Application> {
const newApplication: Application = {
...application,
id: this.generateId(),
status: 'pending',
createdAt: Date.now(),
};
const applications = this.db.get<Application[]>(this.collection) ?? [];
applications.push(newApplication);
this.db.set(this.collection, applications);
return newApplication;
}
/**
* Get application by ID
*/
async getById(id: string): Promise<Application | null> {
const applications = this.db.get<Application[]>(this.collection) ?? [];
return applications.find((a) => a.id === id) ?? null;
}
/**
* Get application by message ID
*/
async getByMessageId(messageId: string): Promise<Application | null> {
const applications = this.db.get<Application[]>(this.collection) ?? [];
return applications.find((a) => a.messageId === messageId) ?? null;
}
/**
* Get all applications by user ID
*/
async getByUserId(userId: string): Promise<Application[]> {
const applications = this.db.get<Application[]>(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<Application | null> {
const applications = this.db.get<Application[]>(this.collection) ?? [];
return applications.find((a) => a.userId === userId && a.status === 'pending') ?? null;
}
/**
* Get all pending applications
*/
async getPending(): Promise<Application[]> {
const applications = this.db.get<Application[]>(this.collection) ?? [];
return applications.filter((a) => a.status === 'pending');
}
/**
* Get all applications by status
*/
async getByStatus(status: Application['status']): Promise<Application[]> {
const applications = this.db.get<Application[]>(this.collection) ?? [];
return applications.filter((a) => a.status === status);
}
/**
* Update application status
*/
async updateStatus(
id: string,
status: Application['status'],
reviewedBy: string
): Promise<Application | null> {
const applications = this.db.get<Application[]>(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<boolean> {
const applications = this.db.get<Application[]>(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<boolean> {
const application = await this.getByUserId(userId);
return application !== null;
}
/**
* Get application count by status
*/
async getCountByStatus(status: Application['status']): Promise<number> {
const applications = await this.getByStatus(status);
return applications.length;
}
/**
* Get recent applications
*/
async getRecent(limit: number = 10): Promise<Application[]> {
const applications = this.db.get<Application[]>(this.collection) ?? [];
return applications
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit);
}
/**
* Get all applications
*/
async getAll(): Promise<Application[]> {
return this.db.get<Application[]>(this.collection) ?? [];
}
/**
* Update message ID and channel ID
*/
async updateMessageId(id: string, messageId: string, channelId: string): Promise<boolean> {
const applications = this.db.get<Application[]>(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<Application | null> {
const applications = this.db.get<Application[]>(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)}`;
}
}

View File

@@ -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, 'createdAt'>): 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<AwayStatus>(TABLE_NAME, userId);
}
/**
* Get all away statuses
*/
getAll(): AwayStatus[] {
return this.db.getAll<AwayStatus>(TABLE_NAME);
}
/**
* Get all expired away statuses
*/
getExpired(): AwayStatus[] {
const now = new Date().toISOString();
return this.db.find<AwayStatus>(TABLE_NAME, (s) => s.expiresAt <= now);
}
/**
* Update away status
*/
update(userId: string, data: Partial<AwayStatus>): boolean {
return this.db.update<AwayStatus>(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<AwayStatus>(TABLE_NAME, (s) => s.expiresAt > now).length;
}
}

View File

@@ -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<Champion, 'id' | 'isActive'>): Promise<Champion> {
const champion: Champion = {
...data,
id: this.generateId(),
isActive: true,
};
const champions = this.db.get<Champion[]>(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<Champion | null> {
const champions = this.db.get<Champion[]>(this.collection) ?? [];
return champions.find((c) => c.userId === userId && c.isActive) ?? null;
}
/**
* Get all active champions
*/
async getActive(): Promise<Champion[]> {
const champions = this.db.get<Champion[]>(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<Champion[]> {
const champions = this.db.get<Champion[]>(this.collection) ?? [];
return champions.filter((c) => c.isActive && c.endDate <= Date.now());
}
/**
* Remove champion status
*/
async remove(userId: string): Promise<boolean> {
const champions = this.db.get<Champion[]>(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<Champion | null> {
const champions = this.db.get<Champion[]>(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<boolean> {
const champion = await this.getByUserId(userId);
return champion !== null && champion.endDate > Date.now();
}
/**
* Get remaining days for champion
*/
async getRemainingDays(userId: string): Promise<number> {
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<Champion[]> {
const champions = this.db.get<Champion[]>(this.collection) ?? [];
return champions
.filter((c) => c.userId === userId)
.sort((a, b) => b.startDate - a.startDate);
}
/**
* Count total champions
*/
async countActive(): Promise<number> {
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)}`;
}
}

View File

@@ -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<FamilyRelationship>(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<FamilyRelationship>(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);
}
}

View File

@@ -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<Family> {
constructor(db: SQLiteDatabase) {
super(db, 'families', 'Family');
}
// =========================================================================
// Entity Conversion
// =========================================================================
protected rowToEntity(row: Record<string, unknown>): 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<string, unknown> {
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<Result<Family>> {
// 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<Result<Family | null>> {
const result = this.db.queryOne<Record<string, unknown>>(
'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<Result<string[]>> {
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<Result<void>> {
// 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<Result<string | null>> {
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<Result<void>> {
// 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<Result<string | null>> {
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<Result<{
user: Family;
partner?: Family;
parent?: Family;
children: Family[];
}>> {
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<Result<boolean>> {
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<Result<void>> {
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);
}
}

View File

@@ -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<ChannelFilter, 'id' | 'createdAt' | 'updatedAt'>): Promise<ChannelFilter> {
const filter: ChannelFilter = {
...data,
id: this.generateId(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
const filters = this.db.get<ChannelFilter[]>(this.filtersCollection) ?? [];
filters.push(filter);
this.db.set(this.filtersCollection, filters);
return filter;
}
/**
* Get filter by ID
*/
async getFilter(id: string): Promise<ChannelFilter | null> {
const filters = this.db.get<ChannelFilter[]>(this.filtersCollection) ?? [];
return filters.find((f) => f.id === id) ?? null;
}
/**
* Get all filters for a channel
*/
async getChannelFilters(channelId: string): Promise<ChannelFilter[]> {
const filters = this.db.get<ChannelFilter[]>(this.filtersCollection) ?? [];
return filters.filter((f) => f.channelId === channelId && f.isEnabled);
}
/**
* Get all enabled filters
*/
async getAllEnabled(): Promise<ChannelFilter[]> {
const filters = this.db.get<ChannelFilter[]>(this.filtersCollection) ?? [];
return filters.filter((f) => f.isEnabled);
}
/**
* Update filter
*/
async updateFilter(id: string, data: Partial<ChannelFilter>): Promise<ChannelFilter | null> {
const filters = this.db.get<ChannelFilter[]>(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<boolean> {
const filters = this.db.get<ChannelFilter[]>(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<ChannelFilter | null> {
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<ChannelFilter | null> {
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<ChannelFilter | null> {
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<FilterAction, 'id' | 'timestamp'>): Promise<FilterAction> {
const action: FilterAction = {
...data,
id: this.generateActionId(),
timestamp: Date.now(),
};
const actions = this.db.get<FilterAction[]>(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<FilterAction[]> {
const actions = this.db.get<FilterAction[]>(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<FilterAction[]> {
const actions = this.db.get<FilterAction[]>(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<number> {
const actions = this.db.get<FilterAction[]>(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<ChannelFilter | null> {
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)}`;
}
}

View File

@@ -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<QOTDQuestion> {
const newQuestion: QOTDQuestion = {
id: this.generateId(),
question,
addedBy,
addedAt: Date.now(),
isUsed: false,
};
const questions = this.db.get<QOTDQuestion[]>(this.questionsCollection) ?? [];
questions.push(newQuestion);
this.db.set(this.questionsCollection, questions);
return newQuestion;
}
/**
* Get question by ID
*/
async getQuestion(id: string): Promise<QOTDQuestion | null> {
const questions = this.db.get<QOTDQuestion[]>(this.questionsCollection) ?? [];
return questions.find((q) => q.id === id) ?? null;
}
/**
* Get all unused questions
*/
async getUnusedQuestions(): Promise<QOTDQuestion[]> {
const questions = this.db.get<QOTDQuestion[]>(this.questionsCollection) ?? [];
return questions.filter((q) => !q.isUsed);
}
/**
* Get random unused question
*/
async getRandomQuestion(): Promise<QOTDQuestion | null> {
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<QOTDQuestion | null> {
const questions = this.db.get<QOTDQuestion[]>(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<boolean> {
const questions = this.db.get<QOTDQuestion[]>(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<QOTDQuestion[]> {
return this.db.get<QOTDQuestion[]>(this.questionsCollection) ?? [];
}
/**
* Reset all questions to unused
*/
async resetAllQuestions(): Promise<number> {
const questions = this.db.get<QOTDQuestion[]>(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<QOTDQuestion[]>(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<QOTDConfig | null> {
return this.db.get<QOTDConfig>(this.configCollection) ?? null;
}
/**
* Set QOTD configuration
*/
async setConfig(config: Partial<QOTDConfig>): Promise<QOTDConfig> {
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<void> {
const config = await this.getConfig();
if (config) {
await this.setConfig({ ...config, lastSentAt: Date.now() });
}
}
/**
* Enable/disable QOTD
*/
async setEnabled(enabled: boolean): Promise<void> {
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)}`;
}
}

View File

@@ -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, 'createdAt'>): 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<Reminder>(TABLE_NAME, id);
}
/**
* Get all reminders for a user
*/
getByUserId(userId: string): Reminder[] {
return this.db.find<Reminder>(TABLE_NAME, (r) => r.userId === userId);
}
/**
* Get all expired reminders
*/
getExpired(): Reminder[] {
const now = new Date().toISOString();
return this.db.find<Reminder>(TABLE_NAME, (r) => r.remindAt <= now);
}
/**
* Get all reminders
*/
getAll(): Reminder[] {
return this.db.getAll<Reminder>(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<Reminder>(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)}`;
}
}

View File

@@ -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<Reminder> {
constructor(db: SQLiteDatabase) {
super(db, 'reminders', 'Reminder');
}
// =========================================================================
// Entity Conversion
// =========================================================================
protected rowToEntity(row: Record<string, unknown>): 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<string, unknown> {
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<Reminder, 'id' | 'createdAt'>): Promise<Result<Reminder>> {
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<Result<Reminder[]>> {
return this.findWhere('user_id = ?', [userId], 'remind_at ASC');
}
/**
* Get due reminders
*/
async findDue(): Promise<Result<Reminder[]>> {
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<Result<Reminder[]>> {
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<Result<void>> {
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<Result<boolean>> {
return this.deleteById(id);
}
/**
* Delete all reminders for a user
*/
async deleteByUserId(userId: string): Promise<Result<number>> {
return this.deleteWhere('user_id = ?', [userId]);
}
/**
* Count reminders for a user
*/
async countByUserId(userId: string): Promise<Result<number>> {
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<Result<Reminder | null>> {
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 });
}
}

View File

@@ -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<StaffProgress> {
const progress = this.db.get<StaffProgress[]>(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<StaffProgress | null> {
const progress = this.db.get<StaffProgress[]>(this.progressCollection) ?? [];
return progress.find((s) => s.userId === userId) ?? null;
}
/**
* Update staff progress
*/
async update(userId: string, data: Partial<StaffProgress>): Promise<StaffProgress | null> {
const progress = this.db.get<StaffProgress[]>(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<StaffProgress> = {
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<StaffAction, 'id' | 'timestamp'>): Promise<void> {
const actions = this.db.get<StaffAction[]>(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<StaffProgress[]> {
const progress = this.db.get<StaffProgress[]>(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<number> {
const progress = this.db.get<StaffProgress[]>(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<StaffAction[]> {
const actions = this.db.get<StaffAction[]>(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<number> {
const progress = this.db.get<StaffProgress[]>(this.progressCollection) ?? [];
const count = progress.length;
this.db.set(this.progressCollection, []);
this.db.set(this.actionsCollection, []);
return count;
}
}

View File

@@ -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<string, 'up' | 'down'>;
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<Suggestion[]>(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<Suggestion[]>(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<Suggestion[]>(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<Suggestion[]>(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<Suggestion[]>(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<Suggestion[]>(TABLE_NAME) ?? [];
return suggestions.filter((s) => s.status === 'pending');
}
/**
* Get all suggestions
*/
getAll(): Suggestion[] {
return this.db.get<Suggestion[]>(TABLE_NAME) ?? [];
}
/**
* Update suggestion
*/
update(id: string, data: Partial<Suggestion>): boolean {
const suggestions = this.db.get<Suggestion[]>(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<Suggestion[]>(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<Suggestion[]>(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<Suggestion[]>(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<Suggestion[]>(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;
}
}

339
src/database/schema.ts Normal file
View File

@@ -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<void> {
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<void> {
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');
}
}

388
src/database/sqlite.ts Normal file
View File

@@ -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<T> {
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<void> {
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<T = Record<string, unknown>>(sql: string, params: unknown[] = []): QueryResult<T[]> {
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<T = Record<string, unknown>>(sql: string, params: unknown[] = []): QueryResult<T | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<T>(fn: () => Promise<T> | T): Promise<QueryResult<T>> {
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<Array<{
cid: number;
name: string;
type: string;
notnull: number;
dflt_value: unknown;
pk: number;
}>> {
return this.query(`PRAGMA table_info(${tableName})`);
}
/**
* Vacuum the database
*/
vacuum(): QueryResult<void> {
return this.exec('VACUUM');
}
/**
* Get database file size
*/
async getSize(): Promise<number> {
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<SQLiteDatabase> {
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;
}

View File

@@ -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<Interaction> = {
name: Events.InteractionCreate,
async execute(interaction: Interaction): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}

167
src/events/messageCreate.ts Normal file
View File

@@ -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<void> {
// 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<void> {
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<void> {
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
}
}

119
src/events/ready.ts Normal file
View File

@@ -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<void> {
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<void> {
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<string, string[]> = {};
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)}`);
}
}

268
src/index.ts Normal file
View File

@@ -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<void> {
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<string, string[]>();
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);
});

View File

@@ -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<string, Role | undefined>();
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, string> = {
[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<void> }
) {
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;
};
}

267
src/types/index.ts Normal file
View File

@@ -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<SlashCommandBuilder, 'addSubcommand' | 'addSubcommandGroup'>;
export interface Command {
data: CommandBuilder;
permission: PermissionLevel;
cooldown?: number; // seconds
guildOnly?: boolean;
ownerOnly?: boolean;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
autocomplete?: (interaction: AutocompleteInteraction) => Promise<void>;
}
export interface CommandGroup {
name: string;
description: string;
commands: Command[];
}
// ============================================================================
// Event Types
// ============================================================================
export interface BotEvent<T = unknown> {
name: string;
once?: boolean;
execute: (...args: T[]) => Promise<void> | void;
}
// ============================================================================
// Component Types
// ============================================================================
export interface ButtonHandler {
customId: string | RegExp;
execute: (interaction: ButtonInteraction) => Promise<void>;
}
export interface ModalHandler {
customId: string | RegExp;
execute: (interaction: ModalSubmitInteraction) => Promise<void>;
}
export interface SelectMenuHandler {
customId: string | RegExp;
execute: (interaction: StringSelectMenuInteraction) => Promise<void>;
}
// ============================================================================
// 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<T> {
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';

334
src/utils/embeds.ts Normal file
View File

@@ -0,0 +1,334 @@
/**
* Embed Utilities for Elly Discord Bot
* Provides helper functions for creating consistent embeds
*/
import { EmbedBuilder, type ColorResolvable, type User } from 'discord.js';
import type { EmbedColors } from '../types/index.ts';
/**
* Default embed colors
*/
export const DEFAULT_COLORS: EmbedColors = {
primary: 0x5865f2,
success: 0x57f287,
warning: 0xfee75c,
error: 0xed4245,
info: 0x5865f2,
};
/**
* Create a success embed
*/
export function successEmbed(
title: string,
description?: string,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(colors.success as ColorResolvable)
.setTitle(`${title}`);
if (description) {
embed.setDescription(description);
}
return embed;
}
/**
* Create an error embed
*/
export function errorEmbed(
title: string,
description?: string,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(colors.error as ColorResolvable)
.setTitle(`${title}`);
if (description) {
embed.setDescription(description);
}
return embed;
}
/**
* Create a warning embed
*/
export function warningEmbed(
title: string,
description?: string,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(colors.warning as ColorResolvable)
.setTitle(`⚠️ ${title}`);
if (description) {
embed.setDescription(description);
}
return embed;
}
/**
* Create an info embed
*/
export function infoEmbed(
title: string,
description?: string,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(colors.info as ColorResolvable)
.setTitle(` ${title}`);
if (description) {
embed.setDescription(description);
}
return embed;
}
/**
* Create a primary embed
*/
export function primaryEmbed(
title: string,
description?: string,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(colors.primary as ColorResolvable)
.setTitle(title);
if (description) {
embed.setDescription(description);
}
return embed;
}
/**
* Add a footer with user info
*/
export function withUserFooter(embed: EmbedBuilder, user: User, text?: string): EmbedBuilder {
const footerText = text ? `${text} • Requested by ${user.tag}` : `Requested by ${user.tag}`;
return embed.setFooter({
text: footerText,
iconURL: user.displayAvatarURL(),
});
}
/**
* Add a timestamp to an embed
*/
export function withTimestamp(embed: EmbedBuilder, date?: Date): EmbedBuilder {
return embed.setTimestamp(date ?? new Date());
}
/**
* Create a loading embed
*/
export function loadingEmbed(
message: string = 'Loading...',
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
return new EmbedBuilder()
.setColor(colors.info as ColorResolvable)
.setDescription(`${message}`);
}
/**
* Create a stats embed for BedWars/SkyWars
*/
export function statsEmbed(
title: string,
username: string,
stats: Record<string, string | number>,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(colors.primary as ColorResolvable)
.setTitle(title)
.setThumbnail(`https://mc-heads.net/head/${username}/right`);
for (const [key, value] of Object.entries(stats)) {
embed.addFields({
name: key,
value: String(value),
inline: true,
});
}
return embed;
}
/**
* Create a guild info embed
*/
export function guildInfoEmbed(
guildName: string,
owner: string,
members: number,
level: number,
exp: number,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
return new EmbedBuilder()
.setColor(colors.primary as ColorResolvable)
.setTitle(`📜 ${guildName} | Guild Information`)
.setThumbnail(`https://mc-heads.net/head/${owner}/right`)
.addFields(
{ name: 'Owner', value: owner, inline: true },
{ name: 'Members', value: String(members), inline: true },
{ name: 'Level', value: String(level), inline: true },
{ name: 'Experience', value: exp.toLocaleString(), inline: true }
);
}
/**
* Create an application embed
*/
export function applicationEmbed(
applicant: User,
answers: Record<string, string>,
rating: number = 0,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(colors.warning as ColorResolvable)
.setTitle(`${applicant.globalName ?? applicant.username}'s Application`)
.setThumbnail(applicant.displayAvatarURL())
.setFooter({ text: `User ID: ${applicant.id}` });
for (const [question, answer] of Object.entries(answers)) {
embed.addFields({
name: question,
value: answer,
inline: false,
});
}
embed.addFields({
name: 'Application Rating',
value: String(rating),
inline: false,
});
return embed;
}
/**
* Create a suggestion embed
*/
export function suggestionEmbed(
author: User,
title: string,
description: string,
suggestionNumber: number,
rating: number = 0,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
return new EmbedBuilder()
.setColor(colors.success as ColorResolvable)
.setTitle(title)
.setDescription(description)
.addFields({
name: 'Suggestion Rating',
value: String(rating),
inline: false,
})
.setFooter({
text: `By ${author.tag} • Suggestion #${suggestionNumber}`,
iconURL: author.displayAvatarURL(),
});
}
/**
* Create a relationship embed
*/
export function relationshipEmbed(
user: User,
partner: string | null,
parent: string | null,
children: string[],
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const embed = new EmbedBuilder()
.setColor(0x1ab968)
.setTitle(`${user.tag}'s Relationships`)
.setThumbnail(user.displayAvatarURL())
.addFields(
{
name: 'Parent',
value: parent ?? 'Nobody',
inline: false,
},
{
name: 'Partner',
value: partner ?? 'Nobody',
inline: false,
},
{
name: 'Children',
value: children.length > 0 ? children.join('\n') : 'None',
inline: false,
}
);
return embed;
}
/**
* Create a reminder embed
*/
export function reminderEmbed(
user: User,
reminderText: string,
reminderId: string,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
return new EmbedBuilder()
.setColor(colors.info as ColorResolvable)
.setTitle('⏰ Reminder')
.setDescription(`\`\`\`${reminderText}\`\`\``)
.setFooter({
text: `Reminder ID: ${reminderId}`,
iconURL: user.displayAvatarURL(),
})
.setTimestamp();
}
/**
* Create a moderation log embed
*/
export function modLogEmbed(
action: string,
moderator: User,
target: User,
reason: string,
colors: EmbedColors = DEFAULT_COLORS
): EmbedBuilder {
const colorMap: Record<string, number> = {
BAN: colors.error,
KICK: colors.warning,
MUTE: colors.warning,
WARN: colors.warning,
UNBAN: colors.success,
UNMUTE: colors.success,
};
return new EmbedBuilder()
.setColor((colorMap[action] ?? colors.info) as ColorResolvable)
.setTitle(`📋 ${action}`)
.addFields(
{ name: 'Moderator', value: `<@${moderator.id}>`, inline: true },
{ name: 'Target', value: `<@${target.id}>`, inline: true },
{ name: 'Reason', value: reason || 'No reason provided', inline: false }
)
.setTimestamp();
}

492
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,492 @@
/**
* Error Handling Utilities
* Provides comprehensive error handling for the bot
*/
import { EmbedBuilder, type ChatInputCommandInteraction } from 'discord.js';
import { createLogger } from './logger.ts';
const logger = createLogger('ErrorHandler');
// ============================================================================
// Error Types
// ============================================================================
/**
* Base error class for all bot errors
*/
export class BotError extends Error {
public readonly code: string;
public readonly userMessage: string;
public readonly isOperational: boolean;
public readonly timestamp: Date;
public readonly context?: Record<string, unknown>;
constructor(
message: string,
code: string,
options: {
userMessage?: string;
isOperational?: boolean;
context?: Record<string, unknown>;
cause?: Error;
} = {}
) {
super(message);
this.name = 'BotError';
this.code = code;
this.userMessage = options.userMessage ?? 'An unexpected error occurred.';
this.isOperational = options.isOperational ?? true;
this.timestamp = new Date();
this.context = options.context;
this.cause = options.cause;
// Capture stack trace
Error.captureStackTrace(this, this.constructor);
}
/**
* Convert to JSON for logging
*/
toJSON(): Record<string, unknown> {
return {
name: this.name,
code: this.code,
message: this.message,
userMessage: this.userMessage,
isOperational: this.isOperational,
timestamp: this.timestamp.toISOString(),
context: this.context,
stack: this.stack,
cause: this.cause instanceof Error ? {
name: this.cause.name,
message: this.cause.message,
stack: this.cause.stack,
} : this.cause,
};
}
}
/**
* Command execution error
*/
export class CommandError extends BotError {
public readonly commandName: string;
public readonly userId: string;
public readonly guildId?: string;
constructor(
message: string,
commandName: string,
userId: string,
options: {
guildId?: string;
userMessage?: string;
context?: Record<string, unknown>;
cause?: Error;
} = {}
) {
super(message, 'COMMAND_ERROR', {
userMessage: options.userMessage ?? 'Failed to execute command.',
context: { commandName, userId, guildId: options.guildId, ...options.context },
cause: options.cause,
});
this.name = 'CommandError';
this.commandName = commandName;
this.userId = userId;
this.guildId = options.guildId;
}
}
/**
* Permission error
*/
export class PermissionError extends BotError {
constructor(
requiredPermission: string,
options: {
context?: Record<string, unknown>;
} = {}
) {
super(
`Missing required permission: ${requiredPermission}`,
'PERMISSION_ERROR',
{
userMessage: 'You do not have permission to use this command.',
context: { requiredPermission, ...options.context },
}
);
this.name = 'PermissionError';
}
}
/**
* Validation error
*/
export class ValidationError extends BotError {
public readonly field?: string;
constructor(
message: string,
options: {
field?: string;
userMessage?: string;
context?: Record<string, unknown>;
} = {}
) {
super(message, 'VALIDATION_ERROR', {
userMessage: options.userMessage ?? message,
context: { field: options.field, ...options.context },
});
this.name = 'ValidationError';
this.field = options.field;
}
}
/**
* API error
*/
export class APIError extends BotError {
public readonly statusCode?: number;
public readonly endpoint?: string;
constructor(
message: string,
options: {
statusCode?: number;
endpoint?: string;
userMessage?: string;
context?: Record<string, unknown>;
cause?: Error;
} = {}
) {
super(message, 'API_ERROR', {
userMessage: options.userMessage ?? 'Failed to fetch data from external service.',
context: { statusCode: options.statusCode, endpoint: options.endpoint, ...options.context },
cause: options.cause,
});
this.name = 'APIError';
this.statusCode = options.statusCode;
this.endpoint = options.endpoint;
}
}
/**
* Rate limit error
*/
export class RateLimitError extends BotError {
public readonly retryAfter: number;
constructor(
retryAfter: number,
options: {
context?: Record<string, unknown>;
} = {}
) {
super(
`Rate limited. Retry after ${retryAfter}ms`,
'RATE_LIMIT_ERROR',
{
userMessage: `Please wait ${Math.ceil(retryAfter / 1000)} seconds before trying again.`,
context: { retryAfter, ...options.context },
}
);
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
}
/**
* Configuration error
*/
export class ConfigError extends BotError {
constructor(
message: string,
options: {
context?: Record<string, unknown>;
} = {}
) {
super(message, 'CONFIG_ERROR', {
userMessage: 'Bot configuration error. Please contact an administrator.',
isOperational: false,
context: options.context,
});
this.name = 'ConfigError';
}
}
// ============================================================================
// Error Handler Class
// ============================================================================
export class ErrorHandler {
private static instance: ErrorHandler;
private errorCount = 0;
private lastErrors: BotError[] = [];
private readonly maxStoredErrors = 100;
private constructor() {}
static getInstance(): ErrorHandler {
if (!ErrorHandler.instance) {
ErrorHandler.instance = new ErrorHandler();
}
return ErrorHandler.instance;
}
/**
* Handle an error
*/
async handle(error: unknown, context?: Record<string, unknown>): Promise<BotError> {
const botError = this.normalize(error, context);
// Log the error
this.log(botError);
// Store for analysis
this.store(botError);
// Track count
this.errorCount++;
return botError;
}
/**
* Handle command error and respond to interaction
*/
async handleCommandError(
error: unknown,
interaction: ChatInputCommandInteraction
): Promise<void> {
const botError = await this.handle(error, {
commandName: interaction.commandName,
userId: interaction.user.id,
guildId: interaction.guildId,
});
// Create error embed
const embed = this.createErrorEmbed(botError);
// Respond to interaction
try {
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ embeds: [embed], ephemeral: true });
} else {
await interaction.reply({ embeds: [embed], ephemeral: true });
}
} catch (replyError) {
logger.error('Failed to send error response:', replyError);
}
}
/**
* Normalize any error to BotError
*/
private normalize(error: unknown, context?: Record<string, unknown>): BotError {
if (error instanceof BotError) {
if (context) {
error.context = { ...error.context, ...context };
}
return error;
}
if (error instanceof Error) {
return new BotError(error.message, 'UNKNOWN_ERROR', {
context,
cause: error,
isOperational: false,
});
}
return new BotError(String(error), 'UNKNOWN_ERROR', {
context,
isOperational: false,
});
}
/**
* Log error
*/
private log(error: BotError): void {
const logData = error.toJSON();
if (error.isOperational) {
logger.warn(`[${error.code}] ${error.message}`, logData);
} else {
logger.error(`[${error.code}] ${error.message}`, logData);
}
}
/**
* Store error for analysis
*/
private store(error: BotError): void {
this.lastErrors.push(error);
// Keep only recent errors
if (this.lastErrors.length > this.maxStoredErrors) {
this.lastErrors.shift();
}
}
/**
* Create error embed for user
*/
private createErrorEmbed(error: BotError): EmbedBuilder {
return new EmbedBuilder()
.setColor(0xED4245)
.setTitle('❌ Error')
.setDescription(error.userMessage)
.addFields({
name: 'Error Code',
value: `\`${error.code}\``,
inline: true,
})
.setFooter({ text: `Error ID: ${error.timestamp.getTime()}` })
.setTimestamp();
}
/**
* Get error statistics
*/
getStats(): {
totalErrors: number;
recentErrors: number;
errorsByCode: Record<string, number>;
} {
const errorsByCode: Record<string, number> = {};
for (const error of this.lastErrors) {
errorsByCode[error.code] = (errorsByCode[error.code] ?? 0) + 1;
}
return {
totalErrors: this.errorCount,
recentErrors: this.lastErrors.length,
errorsByCode,
};
}
/**
* Get recent errors
*/
getRecentErrors(limit: number = 10): BotError[] {
return this.lastErrors.slice(-limit);
}
/**
* Clear stored errors
*/
clearErrors(): void {
this.lastErrors = [];
}
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Get the global error handler instance
*/
export function getErrorHandler(): ErrorHandler {
return ErrorHandler.getInstance();
}
/**
* Wrap an async function with error handling
*/
export function withErrorHandling<T extends (...args: unknown[]) => Promise<unknown>>(
fn: T,
context?: Record<string, unknown>
): T {
return (async (...args: Parameters<T>) => {
try {
return await fn(...args);
} catch (error) {
await getErrorHandler().handle(error, context);
throw error;
}
}) as T;
}
/**
* Assert a condition, throwing ValidationError if false
*/
export function assert(
condition: boolean,
message: string,
options?: { field?: string; userMessage?: string }
): asserts condition {
if (!condition) {
throw new ValidationError(message, options);
}
}
/**
* Assert a value is not null/undefined
*/
export function assertDefined<T>(
value: T | null | undefined,
message: string
): asserts value is T {
if (value === null || value === undefined) {
throw new ValidationError(message);
}
}
/**
* Try to execute a function, returning Result type
*/
export async function tryAsync<T>(
fn: () => Promise<T>
): Promise<{ ok: true; value: T } | { ok: false; error: Error }> {
try {
const value = await fn();
return { ok: true, value };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
}
}
/**
* Retry a function with exponential backoff
*/
export async function retry<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
initialDelay?: number;
maxDelay?: number;
backoffFactor?: number;
} = {}
): Promise<T> {
const {
maxAttempts = 3,
initialDelay = 1000,
maxDelay = 30000,
backoffFactor = 2,
} = options;
let lastError: Error | undefined;
let delay = initialDelay;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxAttempts) {
break;
}
logger.warn(`Attempt ${attempt} failed, retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * backoffFactor, maxDelay);
}
}
throw lastError;
}

163
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* Logger Utility for Elly Discord Bot
* Provides structured logging with levels and formatting
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: string;
data?: unknown;
}
const LOG_COLORS = {
debug: '\x1b[36m', // Cyan
info: '\x1b[32m', // Green
warn: '\x1b[33m', // Yellow
error: '\x1b[31m', // Red
reset: '\x1b[0m',
};
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/**
* Logger class with configurable log level and file output
*/
export class Logger {
private level: LogLevel;
private logFile?: string;
private context?: string;
constructor(options: { level?: LogLevel; logFile?: string; context?: string } = {}) {
this.level = options.level ?? 'info';
this.logFile = options.logFile;
this.context = options.context;
}
/**
* Create a child logger with a specific context
*/
child(context: string): Logger {
return new Logger({
level: this.level,
logFile: this.logFile,
context: this.context ? `${this.context}:${context}` : context,
});
}
/**
* Check if a log level should be output
*/
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
}
/**
* Format a log entry for console output
*/
private formatConsole(entry: LogEntry): string {
const color = LOG_COLORS[entry.level];
const reset = LOG_COLORS.reset;
const levelStr = entry.level.toUpperCase().padEnd(5);
const contextStr = entry.context ? `[${entry.context}] ` : '';
let output = `${color}${entry.timestamp} ${levelStr}${reset} ${contextStr}${entry.message}`;
if (entry.data !== undefined) {
output += `\n${JSON.stringify(entry.data, null, 2)}`;
}
return output;
}
/**
* Format a log entry for file output
*/
private formatFile(entry: LogEntry): string {
return JSON.stringify(entry);
}
/**
* Write a log entry
*/
private async write(level: LogLevel, message: string, data?: unknown): Promise<void> {
if (!this.shouldLog(level)) return;
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
context: this.context,
data,
};
// Console output
console.log(this.formatConsole(entry));
// File output
if (this.logFile) {
try {
const line = this.formatFile(entry) + '\n';
await Deno.writeTextFile(this.logFile, line, { append: true });
} catch {
// Silently fail file logging
}
}
}
/**
* Log a debug message
*/
debug(message: string, data?: unknown): void {
this.write('debug', message, data);
}
/**
* Log an info message
*/
info(message: string, data?: unknown): void {
this.write('info', message, data);
}
/**
* Log a warning message
*/
warn(message: string, data?: unknown): void {
this.write('warn', message, data);
}
/**
* Log an error message
*/
error(message: string, data?: unknown): void {
this.write('error', message, data);
}
/**
* Set the log level
*/
setLevel(level: LogLevel): void {
this.level = level;
}
}
// Default logger instance
export const logger = new Logger();
/**
* Create a logger with a specific context
*/
export function createLogger(context: string, options?: { level?: LogLevel; logFile?: string }): Logger {
return new Logger({
...options,
context,
});
}

290
src/utils/pagination.ts Normal file
View File

@@ -0,0 +1,290 @@
/**
* Pagination Utility for Elly Discord Bot
* Provides interactive pagination for embeds and content
*/
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
type ChatInputCommandInteraction,
type Message,
type ButtonInteraction,
ComponentType,
} from 'discord.js';
export interface PaginatorOptions {
itemsPerPage?: number;
timeout?: number;
authorId?: string;
showPageNumbers?: boolean;
deleteOnTimeout?: boolean;
}
/**
* Button-based paginator for embeds
*/
export class ButtonPaginator<T = EmbedBuilder> {
private pages: T[];
private currentPage = 0;
private message: Message | null = null;
private readonly options: Required<PaginatorOptions>;
private collector: ReturnType<Message['createMessageComponentCollector']> | null = null;
constructor(pages: T[], options: PaginatorOptions = {}) {
this.pages = pages;
this.options = {
itemsPerPage: options.itemsPerPage ?? 1,
timeout: options.timeout ?? 60000,
authorId: options.authorId ?? '',
showPageNumbers: options.showPageNumbers ?? true,
deleteOnTimeout: options.deleteOnTimeout ?? false,
};
}
/**
* Get total number of pages
*/
get totalPages(): number {
return this.pages.length;
}
/**
* Get current page content
*/
get currentContent(): T {
return this.pages[this.currentPage];
}
/**
* Create pagination buttons (max 5 per row - Discord limit)
*/
private createButtons(): ActionRowBuilder<ButtonBuilder> {
return new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId('paginator:prev')
.setEmoji('◀️')
.setStyle(ButtonStyle.Primary)
.setDisabled(this.currentPage === 0),
new ButtonBuilder()
.setCustomId('paginator:page')
.setLabel(`${this.currentPage + 1}/${this.totalPages}`)
.setStyle(ButtonStyle.Secondary)
.setDisabled(true),
new ButtonBuilder()
.setCustomId('paginator:next')
.setEmoji('▶️')
.setStyle(ButtonStyle.Primary)
.setDisabled(this.currentPage >= this.totalPages - 1),
new ButtonBuilder()
.setCustomId('paginator:stop')
.setEmoji('⏹️')
.setStyle(ButtonStyle.Danger)
);
}
/**
* Get message payload for current page
*/
private getPayload(): { embeds?: EmbedBuilder[]; components: ActionRowBuilder<ButtonBuilder>[] } {
const content = this.currentContent;
const components = this.totalPages > 1 ? [this.createButtons()] : [];
if (content instanceof EmbedBuilder) {
const embed = EmbedBuilder.from(content);
if (this.options.showPageNumbers && this.totalPages > 1) {
const existingFooter = embed.data.footer?.text ?? '';
embed.setFooter({
text: existingFooter
? `${existingFooter} • Page ${this.currentPage + 1}/${this.totalPages}`
: `Page ${this.currentPage + 1}/${this.totalPages}`,
iconURL: embed.data.footer?.icon_url,
});
}
return { embeds: [embed], components };
}
return { components };
}
/**
* Handle button interaction
*/
private async handleInteraction(interaction: ButtonInteraction): Promise<void> {
// Check if the user is authorized
if (this.options.authorId && interaction.user.id !== this.options.authorId) {
await interaction.reply({
content: 'You cannot interact with this menu.',
ephemeral: true,
});
return;
}
const action = interaction.customId.split(':')[1];
switch (action) {
case 'prev':
this.currentPage = Math.max(0, this.currentPage - 1);
break;
case 'next':
this.currentPage = Math.min(this.totalPages - 1, this.currentPage + 1);
break;
case 'stop':
await this.stop();
return;
}
await interaction.update(this.getPayload());
}
/**
* Start the paginator
*/
async start(interaction: ChatInputCommandInteraction): Promise<Message | null> {
if (this.pages.length === 0) {
await interaction.reply({
content: 'No content to display.',
ephemeral: true,
});
return null;
}
const payload = this.getPayload();
if (interaction.replied || interaction.deferred) {
this.message = await interaction.followUp({
...payload,
fetchReply: true,
});
} else {
this.message = await interaction.reply({
...payload,
fetchReply: true,
});
}
if (this.totalPages <= 1) {
return this.message;
}
// Set up collector
this.collector = this.message.createMessageComponentCollector({
componentType: ComponentType.Button,
time: this.options.timeout,
filter: (i) => i.customId.startsWith('paginator:'),
});
this.collector.on('collect', (i) => this.handleInteraction(i));
this.collector.on('end', () => this.onTimeout());
return this.message;
}
/**
* Handle timeout
*/
private async onTimeout(): Promise<void> {
if (!this.message) return;
try {
if (this.options.deleteOnTimeout) {
await this.message.delete();
} else {
// Disable all buttons
const disabledRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
...this.createButtons().components.map((btn) => btn.setDisabled(true))
);
await this.message.edit({ components: [disabledRow] });
}
} catch {
// Message might have been deleted
}
}
/**
* Stop the paginator
*/
async stop(): Promise<void> {
if (this.collector) {
this.collector.stop();
}
if (this.message) {
try {
const disabledRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
...this.createButtons().components.map((btn) => btn.setDisabled(true))
);
await this.message.edit({ components: [disabledRow] });
} catch {
// Message might have been deleted
}
}
}
/**
* Go to a specific page
*/
goToPage(page: number): void {
this.currentPage = Math.max(0, Math.min(page, this.totalPages - 1));
}
}
/**
* Create a simple paginator from an array of embeds
*/
export function createPaginator(
embeds: EmbedBuilder[],
options?: PaginatorOptions
): ButtonPaginator<EmbedBuilder> {
return new ButtonPaginator(embeds, options);
}
/**
* Chunk an array into pages
*/
export function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
/**
* Create paginated embeds from an array of items
*/
export function createPaginatedEmbeds<T>(
items: T[],
itemsPerPage: number,
formatItem: (item: T, index: number) => string,
embedOptions: {
title?: string;
color?: number;
description?: string;
} = {}
): EmbedBuilder[] {
const chunks = chunkArray(items, itemsPerPage);
return chunks.map((chunk, pageIndex) => {
const embed = new EmbedBuilder();
if (embedOptions.title) {
embed.setTitle(embedOptions.title);
}
if (embedOptions.color) {
embed.setColor(embedOptions.color);
}
const content = chunk
.map((item, index) => formatItem(item, pageIndex * itemsPerPage + index))
.join('\n');
embed.setDescription(
embedOptions.description ? `${embedOptions.description}\n\n${content}` : content
);
return embed;
});
}

254
src/utils/time.ts Normal file
View File

@@ -0,0 +1,254 @@
/**
* Time Utilities for Elly Discord Bot
* Provides time parsing and formatting functions
*/
/**
* Time unit multipliers in milliseconds
*/
const TIME_UNITS: Record<string, number> = {
s: 1000,
sec: 1000,
second: 1000,
seconds: 1000,
m: 60 * 1000,
min: 60 * 1000,
minute: 60 * 1000,
minutes: 60 * 1000,
h: 60 * 60 * 1000,
hr: 60 * 60 * 1000,
hour: 60 * 60 * 1000,
hours: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
day: 24 * 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
week: 7 * 24 * 60 * 60 * 1000,
weeks: 7 * 24 * 60 * 60 * 1000,
mo: 30 * 24 * 60 * 60 * 1000,
month: 30 * 24 * 60 * 60 * 1000,
months: 30 * 24 * 60 * 60 * 1000,
y: 365 * 24 * 60 * 60 * 1000,
year: 365 * 24 * 60 * 60 * 1000,
years: 365 * 24 * 60 * 60 * 1000,
};
/**
* Parse a time string into milliseconds
* Examples: "15m", "2h30m", "1d", "1 day 2 hours"
*/
export function parseTime(input: string): number | null {
if (!input) return null;
// Try to parse as a simple number (assume minutes)
const simpleNumber = parseInt(input, 10);
if (!isNaN(simpleNumber) && input === String(simpleNumber)) {
return simpleNumber * 60 * 1000;
}
// Parse time string with units
const regex = /(\d+)\s*([a-zA-Z]+)/g;
let totalMs = 0;
let match;
let hasMatch = false;
while ((match = regex.exec(input)) !== null) {
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
if (TIME_UNITS[unit]) {
totalMs += value * TIME_UNITS[unit];
hasMatch = true;
}
}
return hasMatch ? totalMs : null;
}
/**
* Format milliseconds into a human-readable string
*/
export function formatDuration(ms: number): string {
if (ms < 0) return 'Invalid duration';
if (ms === 0) return '0 seconds';
const parts: string[] = [];
const years = Math.floor(ms / (365 * 24 * 60 * 60 * 1000));
ms %= 365 * 24 * 60 * 60 * 1000;
const months = Math.floor(ms / (30 * 24 * 60 * 60 * 1000));
ms %= 30 * 24 * 60 * 60 * 1000;
const weeks = Math.floor(ms / (7 * 24 * 60 * 60 * 1000));
ms %= 7 * 24 * 60 * 60 * 1000;
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
ms %= 24 * 60 * 60 * 1000;
const hours = Math.floor(ms / (60 * 60 * 1000));
ms %= 60 * 60 * 1000;
const minutes = Math.floor(ms / (60 * 1000));
ms %= 60 * 1000;
const seconds = Math.floor(ms / 1000);
if (years > 0) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
if (months > 0) parts.push(`${months} month${months !== 1 ? 's' : ''}`);
if (weeks > 0) parts.push(`${weeks} week${weeks !== 1 ? 's' : ''}`);
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`);
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`);
if (seconds > 0) parts.push(`${seconds} second${seconds !== 1 ? 's' : ''}`);
if (parts.length === 0) return '0 seconds';
if (parts.length === 1) return parts[0];
if (parts.length === 2) return parts.join(' and ');
return parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1];
}
/**
* Format milliseconds into a short string
* Example: "2d 5h 30m"
*/
export function formatDurationShort(ms: number): string {
if (ms < 0) return 'Invalid';
if (ms === 0) return '0s';
const parts: string[] = [];
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
ms %= 24 * 60 * 60 * 1000;
const hours = Math.floor(ms / (60 * 60 * 1000));
ms %= 60 * 60 * 1000;
const minutes = Math.floor(ms / (60 * 1000));
ms %= 60 * 1000;
const seconds = Math.floor(ms / 1000);
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (seconds > 0 && parts.length === 0) parts.push(`${seconds}s`);
return parts.join(' ') || '0s';
}
/**
* Get a relative time string (e.g., "2 hours ago", "in 3 days")
*/
export function relativeTime(date: Date | number): string {
const now = Date.now();
const timestamp = date instanceof Date ? date.getTime() : date;
const diff = timestamp - now;
const absDiff = Math.abs(diff);
const formatUnit = (value: number, unit: string): string => {
const rounded = Math.round(value);
const unitStr = rounded === 1 ? unit : unit + 's';
return diff < 0 ? `${rounded} ${unitStr} ago` : `in ${rounded} ${unitStr}`;
};
if (absDiff < 60 * 1000) {
return diff < 0 ? 'just now' : 'in a moment';
}
if (absDiff < 60 * 60 * 1000) {
return formatUnit(absDiff / (60 * 1000), 'minute');
}
if (absDiff < 24 * 60 * 60 * 1000) {
return formatUnit(absDiff / (60 * 60 * 1000), 'hour');
}
if (absDiff < 7 * 24 * 60 * 60 * 1000) {
return formatUnit(absDiff / (24 * 60 * 60 * 1000), 'day');
}
if (absDiff < 30 * 24 * 60 * 60 * 1000) {
return formatUnit(absDiff / (7 * 24 * 60 * 60 * 1000), 'week');
}
if (absDiff < 365 * 24 * 60 * 60 * 1000) {
return formatUnit(absDiff / (30 * 24 * 60 * 60 * 1000), 'month');
}
return formatUnit(absDiff / (365 * 24 * 60 * 60 * 1000), 'year');
}
/**
* Format a date to ISO string (YYYY-MM-DD)
*/
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
/**
* Format a date to datetime string (YYYY-MM-DD HH:MM:SS)
*/
export function formatDateTime(date: Date): string {
return date.toISOString().replace('T', ' ').split('.')[0];
}
/**
* Get Discord timestamp format
* @param date The date to format
* @param style The timestamp style (t, T, d, D, f, F, R)
*/
export function discordTimestamp(
date: Date | number,
style: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R' = 'f'
): string {
const timestamp = Math.floor((date instanceof Date ? date.getTime() : date) / 1000);
return `<t:${timestamp}:${style}>`;
}
/**
* Check if a date is in the past
*/
export function isPast(date: Date | number): boolean {
const timestamp = date instanceof Date ? date.getTime() : date;
return timestamp < Date.now();
}
/**
* Check if a date is in the future
*/
export function isFuture(date: Date | number): boolean {
const timestamp = date instanceof Date ? date.getTime() : date;
return timestamp > Date.now();
}
/**
* Add time to a date
*/
export function addTime(date: Date, ms: number): Date {
return new Date(date.getTime() + ms);
}
/**
* Get the start of today (midnight)
*/
export function startOfDay(date: Date = new Date()): Date {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
}
/**
* Get the end of today (23:59:59.999)
*/
export function endOfDay(date: Date = new Date()): Date {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
}
/**
* Alias for parseTime for backward compatibility
*/
export const parseDuration = parseTime;