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