(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