(Feat): Initial Commit

This commit is contained in:
2025-12-12 16:54:00 +00:00
parent 101d093965
commit 470e5a13aa
10 changed files with 1330 additions and 173 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# Elly Discord Bot Environment Variables
# ========================================
# Discord Bot Token (required)
# Get this from https://discord.com/developers/applications
DISCORD_TOKEN=your_discord_bot_token_here

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Environment variables
.env
# Database
data/
*.db
*.json
!deno.json
!package.json
# Logs
logs/
*.log
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Deno
.deno/
# Node (if using npm packages)
node_modules/
# Build output
dist/
build/
# Project files
config.toml
PORTING_DOCUMENTATION.md
pikanetwork.js
GEM
.qodo

145
README.md Normal file
View File

@@ -0,0 +1,145 @@
# Elly Discord Bot
A modern Discord bot for PikaNetwork guild management, built with TypeScript and Deno.
## Features
- **PikaNetwork Statistics**: View BedWars and SkyWars stats for any player
- **Guild Management**: Track guild members, activity reports, and updates
- **Applications System**: Handle guild member applications with voting
- **Suggestions System**: Community suggestions with upvote/downvote
- **Family System**: Marriage, adoption, and relationship tracking
- **QOTD System**: Question of the Day management
- **Reminders**: Personal reminder system
- **Staff Simulator**: Fun PikaNetwork staff simulation game
- **Channel Filtering**: Media-only channel enforcement
- **Moderation**: Ban, purge, role management
## Requirements
- [Deno](https://deno.land/) 1.40+
- Discord Bot Token
- Discord Application with slash commands enabled
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd EllyProject
```
2. Copy and configure the config file:
```bash
cp config.toml.example config.toml
# Edit config.toml with your settings
```
3. Set up environment variables:
```bash
export DISCORD_TOKEN="your-bot-token"
```
4. Run the bot:
```bash
deno task start
```
## Development
```bash
# Run with watch mode
deno task dev
# Type check
deno task check
# Lint
deno task lint
# Format
deno task fmt
```
## Configuration
All configuration is done through `config.toml`. See the file for available options:
- **bot**: Bot name, prefix, status, owners
- **database**: SQLite database path
- **api**: PikaNetwork API settings
- **guild**: Target guild ID and name
- **channels**: Channel names for various features
- **roles**: Role names for permissions
- **features**: Enable/disable features
- **limits**: Various limits (max days, messages, etc.)
- **colors**: Embed colors
- **logging**: Log level and file path
## Commands
### Statistics
- `/bedwars <username> [mode] [interval]` - View BedWars stats
- `/skywars <username> [mode] [interval]` - View SkyWars stats
- `/guild <name>` - View guild information
### Applications
- `/applications accept <user>` - Accept an application
- `/applications deny <user> <reason>` - Deny an application
- `/applications blacklist <user>` - Blacklist from applications
### Suggestions
- `/suggest <title> <description>` - Create a suggestion
- `/suggestion accept <id>` - Accept a suggestion
- `/suggestion deny <id> <reason>` - Deny a suggestion
### Family
- `/marry <user>` - Propose marriage
- `/divorce` - Divorce your partner
- `/adopt <user>` - Adopt someone
- `/relationship [user]` - View relationships
### Utility
- `/remind <duration> <text>` - Set a reminder
- `/reminders` - View your reminders
- `/away <user> <days> <reason>` - Set away status
### Moderation
- `/purge <amount>` - Delete messages
- `/ban <user> [reason]` - Ban a user
- `/role <user> <role> <add|remove>` - Manage roles
## Project Structure
```
src/
├── index.ts # Entry point
├── client/ # Discord client
├── config/ # Configuration
├── api/pika/ # PikaNetwork API client
├── database/ # Database layer
├── commands/ # Slash commands
├── components/ # UI components
├── events/ # Event handlers
├── services/ # Business logic
├── utils/ # Utilities
└── types/ # TypeScript types
```
## License
MIT
## Credits
Ported from the GEM Discord bot (Python/discord.py) to TypeScript/Discord.js with Deno.

0
config.example.toml Normal file
View File

34
deno.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"strict": true,
"lib": ["deno.window", "esnext"],
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitReturns": true
},
"imports": {
"discord.js": "npm:discord.js@^14.14.1",
"@discordjs/rest": "npm:@discordjs/rest@^2.2.0",
"@toml-tools/parser": "npm:@toml-tools/parser@^1.0.0"
},
"tasks": {
"start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi --env src/index.ts",
"dev": "deno run --watch --allow-net --allow-read --allow-write --allow-env --allow-ffi --unstable-ffi --env src/index.ts",
"check": "deno check src/index.ts",
"lint": "deno lint",
"fmt": "deno fmt"
},
"fmt": {
"useTabs": false,
"lineWidth": 100,
"indentWidth": 2,
"singleQuote": true,
"proseWrap": "preserve"
},
"lint": {
"rules": {
"tags": ["recommended"]
}
}
}

261
deno.lock generated Normal file
View File

@@ -0,0 +1,261 @@
{
"version": "5",
"specifiers": {
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.1.0",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/fmt@1": "1.0.8",
"jsr:@std/fs@1": "1.0.20",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/path@0.217": "0.217.0",
"jsr:@std/path@1": "1.1.3",
"jsr:@std/path@^1.1.3": "1.1.3",
"npm:@discordjs/rest@^2.2.0": "2.6.0",
"npm:@toml-tools/parser@1": "1.0.0",
"npm:discord.js@^14.14.1": "14.25.1"
},
"jsr": {
"@db/sqlite@0.12.0": {
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
"dependencies": [
"jsr:@denosaurs/plug",
"jsr:@std/path@0.217"
]
},
"@denosaurs/plug@1.1.0": {
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
"dependencies": [
"jsr:@std/encoding",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/path@1"
]
},
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
},
"@std/fs@1.0.20": {
"integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187",
"dependencies": [
"jsr:@std/internal",
"jsr:@std/path@^1.1.3"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/path@0.217.0": {
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
"dependencies": [
"jsr:@std/assert"
]
},
"@std/path@1.1.3": {
"integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": {
"@chevrotain/cst-dts-gen@11.0.3": {
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"dependencies": [
"@chevrotain/gast",
"@chevrotain/types",
"lodash-es"
]
},
"@chevrotain/gast@11.0.3": {
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"dependencies": [
"@chevrotain/types",
"lodash-es"
]
},
"@chevrotain/regexp-to-ast@11.0.3": {
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="
},
"@chevrotain/types@11.0.3": {
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="
},
"@chevrotain/utils@11.0.3": {
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="
},
"@discordjs/builders@1.13.0": {
"integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==",
"dependencies": [
"@discordjs/formatters",
"@discordjs/util",
"@sapphire/shapeshift",
"discord-api-types",
"fast-deep-equal",
"ts-mixer",
"tslib"
]
},
"@discordjs/collection@1.5.3": {
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="
},
"@discordjs/collection@2.1.1": {
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="
},
"@discordjs/formatters@0.6.2": {
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"dependencies": [
"discord-api-types"
]
},
"@discordjs/rest@2.6.0": {
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
"dependencies": [
"@discordjs/collection@2.1.1",
"@discordjs/util",
"@sapphire/async-queue",
"@sapphire/snowflake",
"@vladfrangu/async_event_emitter",
"discord-api-types",
"magic-bytes.js",
"tslib",
"undici"
]
},
"@discordjs/util@1.2.0": {
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"dependencies": [
"discord-api-types"
]
},
"@discordjs/ws@1.2.3": {
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"dependencies": [
"@discordjs/collection@2.1.1",
"@discordjs/rest",
"@discordjs/util",
"@sapphire/async-queue",
"@types/ws",
"@vladfrangu/async_event_emitter",
"discord-api-types",
"tslib",
"ws"
]
},
"@sapphire/async-queue@1.5.5": {
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="
},
"@sapphire/shapeshift@4.0.0": {
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"dependencies": [
"fast-deep-equal",
"lodash"
]
},
"@sapphire/snowflake@3.5.3": {
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="
},
"@toml-tools/lexer@1.0.0": {
"integrity": "sha512-rVoOC9FibF2CICwCBWQnYcjAEOmLCJExer178K2AsY0Nk9FjJNVoVJuR5UAtuq42BZOajvH+ainf6Gj2GpCnXQ==",
"dependencies": [
"chevrotain"
]
},
"@toml-tools/parser@1.0.0": {
"integrity": "sha512-j8cd3A3ccLHppGoWI69urbiVJslrpwI6sZ61ySDUPxM/FTkQWRx/JkkF8aipnl0Ds0feWXyjyvmWzn70mIohYg==",
"dependencies": [
"@toml-tools/lexer",
"chevrotain"
]
},
"@types/node@24.2.0": {
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"dependencies": [
"undici-types"
]
},
"@types/ws@8.18.1": {
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": [
"@types/node"
]
},
"@vladfrangu/async_event_emitter@2.4.7": {
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="
},
"chevrotain@11.0.3": {
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"dependencies": [
"@chevrotain/cst-dts-gen",
"@chevrotain/gast",
"@chevrotain/regexp-to-ast",
"@chevrotain/types",
"@chevrotain/utils",
"lodash-es"
]
},
"discord-api-types@0.38.34": {
"integrity": "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q=="
},
"discord.js@14.25.1": {
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"dependencies": [
"@discordjs/builders",
"@discordjs/collection@1.5.3",
"@discordjs/formatters",
"@discordjs/rest",
"@discordjs/util",
"@discordjs/ws",
"@sapphire/snowflake",
"discord-api-types",
"fast-deep-equal",
"lodash.snakecase",
"magic-bytes.js",
"tslib",
"undici"
]
},
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"lodash-es@4.17.21": {
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
},
"lodash.snakecase@4.1.1": {
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="
},
"lodash@4.17.21": {
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"magic-bytes.js@1.12.1": {
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="
},
"ts-mixer@6.0.4": {
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"undici-types@7.10.0": {
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
},
"undici@6.21.3": {
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="
},
"ws@8.18.3": {
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
}
},
"workspace": {
"dependencies": [
"npm:@discordjs/rest@^2.2.0",
"npm:@toml-tools/parser@1",
"npm:discord.js@^14.14.1"
]
}
}

View File

@@ -1,45 +1,127 @@
/**
* PikaNetwork API Cache System
* Implements TTL-based caching for API responses
* Advanced TTL-based caching with LRU eviction, statistics, and persistence support
*/
import type { ProfileResponse, ClanResponse, LeaderboardResponse } from './types.ts';
import type {
ProfileResponse,
ClanResponse,
LeaderboardResponse,
StaffList,
VoteLeaderboard,
ServerStatus,
Punishment,
} from './types.ts';
// ============================================================================
// Cache Entry Types
// ============================================================================
interface CacheEntry<T> {
data: T;
expires: number;
createdAt: number;
hits: number;
lastAccess: number;
}
interface CacheStats {
hits: number;
misses: number;
size: number;
evictions: number;
hitRate: number;
}
interface CacheOptions {
maxSize?: number;
defaultTTL?: number;
enableLRU?: boolean;
onEvict?: (key: string, reason: 'expired' | 'lru' | 'manual') => void;
}
// ============================================================================
// Advanced TTL Cache with LRU Eviction
// ============================================================================
/**
* Generic cache with TTL support
* Generic cache with TTL support and LRU eviction
*/
class TTLCache<T> {
class AdvancedCache<T> {
private cache = new Map<string, CacheEntry<T>>();
private readonly defaultTTL: number;
private readonly maxSize: number;
private readonly enableLRU: boolean;
private readonly onEvict?: (key: string, reason: 'expired' | 'lru' | 'manual') => void;
constructor(defaultTTL: number) {
this.defaultTTL = defaultTTL;
// Statistics
private stats = {
hits: 0,
misses: 0,
evictions: 0,
};
constructor(options: CacheOptions = {}) {
this.defaultTTL = options.defaultTTL ?? 3600000; // 1 hour default
this.maxSize = options.maxSize ?? 1000;
this.enableLRU = options.enableLRU ?? true;
this.onEvict = options.onEvict;
}
/**
* Check if an entry has expired
*/
private isExpired(expires: number): boolean {
return Date.now() > expires;
private isExpired(entry: CacheEntry<T>): boolean {
return Date.now() > entry.expires;
}
/**
* Evict the least recently used entry
*/
private evictLRU(): void {
if (!this.enableLRU || this.cache.size === 0) return;
let oldestKey: string | null = null;
let oldestAccess = Infinity;
for (const [key, entry] of this.cache.entries()) {
if (entry.lastAccess < oldestAccess) {
oldestAccess = entry.lastAccess;
oldestKey = key;
}
}
if (oldestKey) {
this.cache.delete(oldestKey);
this.stats.evictions++;
this.onEvict?.(oldestKey, 'lru');
}
}
/**
* Get a value from the cache
*/
get(key: string): T | null {
const entry = this.cache.get(key.toLowerCase());
if (!entry) return null;
const normalizedKey = key.toLowerCase();
const entry = this.cache.get(normalizedKey);
if (this.isExpired(entry.expires)) {
this.cache.delete(key.toLowerCase());
if (!entry) {
this.stats.misses++;
return null;
}
if (this.isExpired(entry)) {
this.cache.delete(normalizedKey);
this.onEvict?.(normalizedKey, 'expired');
this.stats.misses++;
return null;
}
// Update access stats
entry.hits++;
entry.lastAccess = Date.now();
this.stats.hits++;
return entry.data;
}
@@ -47,9 +129,20 @@ class TTLCache<T> {
* Set a value in the cache
*/
set(key: string, data: T, ttl?: number): void {
this.cache.set(key.toLowerCase(), {
const normalizedKey = key.toLowerCase();
// Evict if at max capacity
if (this.cache.size >= this.maxSize && !this.cache.has(normalizedKey)) {
this.evictLRU();
}
const now = Date.now();
this.cache.set(normalizedKey, {
data,
expires: Date.now() + (ttl ?? this.defaultTTL),
expires: now + (ttl ?? this.defaultTTL),
createdAt: now,
hits: 0,
lastAccess: now,
});
}
@@ -57,11 +150,14 @@ class TTLCache<T> {
* Check if a key exists and is not expired
*/
has(key: string): boolean {
const entry = this.cache.get(key.toLowerCase());
const normalizedKey = key.toLowerCase();
const entry = this.cache.get(normalizedKey);
if (!entry) return false;
if (this.isExpired(entry.expires)) {
this.cache.delete(key.toLowerCase());
if (this.isExpired(entry)) {
this.cache.delete(normalizedKey);
this.onEvict?.(normalizedKey, 'expired');
return false;
}
@@ -72,7 +168,13 @@ class TTLCache<T> {
* Delete a specific key
*/
delete(key: string): boolean {
return this.cache.delete(key.toLowerCase());
const normalizedKey = key.toLowerCase();
const existed = this.cache.has(normalizedKey);
if (existed) {
this.cache.delete(normalizedKey);
this.onEvict?.(normalizedKey, 'manual');
}
return existed;
}
/**
@@ -80,6 +182,7 @@ class TTLCache<T> {
*/
clear(): void {
this.cache.clear();
this.stats = { hits: 0, misses: 0, evictions: 0 };
}
/**
@@ -99,90 +202,200 @@ class TTLCache<T> {
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expires) {
this.cache.delete(key);
this.onEvict?.(key, 'expired');
removed++;
}
}
return removed;
}
/**
* Get cache statistics
*/
getStats(): CacheStats {
const total = this.stats.hits + this.stats.misses;
return {
hits: this.stats.hits,
misses: this.stats.misses,
size: this.cache.size,
evictions: this.stats.evictions,
hitRate: total > 0 ? this.stats.hits / total : 0,
};
}
/**
* Get all keys
*/
keys(): string[] {
return Array.from(this.cache.keys());
}
/**
* Get entry metadata (without data)
*/
getMetadata(key: string): Omit<CacheEntry<T>, 'data'> | null {
const entry = this.cache.get(key.toLowerCase());
if (!entry) return null;
const { data, ...metadata } = entry;
return metadata;
}
/**
* Update TTL for an existing entry
*/
touch(key: string, ttl?: number): boolean {
const normalizedKey = key.toLowerCase();
const entry = this.cache.get(normalizedKey);
if (!entry || this.isExpired(entry)) return false;
entry.expires = Date.now() + (ttl ?? this.defaultTTL);
entry.lastAccess = Date.now();
return true;
}
}
// ============================================================================
// PikaNetwork API Cache Manager
// ============================================================================
export interface PikaCacheOptions {
profileTTL?: number;
clanTTL?: number;
leaderboardTTL?: number;
staffTTL?: number;
voteTTL?: number;
serverTTL?: number;
punishmentTTL?: number;
maxProfileEntries?: number;
maxClanEntries?: number;
maxLeaderboardEntries?: number;
cleanupIntervalMs?: number;
}
/**
* PikaNetwork API Cache
* Manages caching for profiles, clans, and leaderboards
* Manages caching for all API responses with configurable TTLs
*/
export class PikaCache {
private profiles: TTLCache<ProfileResponse>;
private clans: TTLCache<ClanResponse>;
private leaderboards: TTLCache<LeaderboardResponse>;
private profiles: AdvancedCache<ProfileResponse>;
private clans: AdvancedCache<ClanResponse>;
private leaderboards: AdvancedCache<LeaderboardResponse>;
private staff: AdvancedCache<StaffList>;
private votes: AdvancedCache<VoteLeaderboard>;
private server: AdvancedCache<ServerStatus>;
private punishments: AdvancedCache<Punishment[]>;
private generic: AdvancedCache<unknown>;
private cleanupInterval: number | undefined;
private readonly options: Required<PikaCacheOptions>;
constructor(ttl: number = 3600000) {
this.profiles = new TTLCache<ProfileResponse>(ttl);
this.clans = new TTLCache<ClanResponse>(ttl);
this.leaderboards = new TTLCache<LeaderboardResponse>(ttl);
constructor(options: PikaCacheOptions = {}) {
this.options = {
profileTTL: options.profileTTL ?? 600000, // 10 minutes
clanTTL: options.clanTTL ?? 900000, // 15 minutes
leaderboardTTL: options.leaderboardTTL ?? 300000, // 5 minutes
staffTTL: options.staffTTL ?? 3600000, // 1 hour
voteTTL: options.voteTTL ?? 1800000, // 30 minutes
serverTTL: options.serverTTL ?? 60000, // 1 minute
punishmentTTL: options.punishmentTTL ?? 600000, // 10 minutes
maxProfileEntries: options.maxProfileEntries ?? 500,
maxClanEntries: options.maxClanEntries ?? 100,
maxLeaderboardEntries: options.maxLeaderboardEntries ?? 2000,
cleanupIntervalMs: options.cleanupIntervalMs ?? 300000, // 5 minutes
};
// Run cleanup every 5 minutes
this.cleanupInterval = setInterval(() => this.cleanup(), 300000);
this.profiles = new AdvancedCache<ProfileResponse>({
defaultTTL: this.options.profileTTL,
maxSize: this.options.maxProfileEntries,
});
this.clans = new AdvancedCache<ClanResponse>({
defaultTTL: this.options.clanTTL,
maxSize: this.options.maxClanEntries,
});
this.leaderboards = new AdvancedCache<LeaderboardResponse>({
defaultTTL: this.options.leaderboardTTL,
maxSize: this.options.maxLeaderboardEntries,
});
this.staff = new AdvancedCache<StaffList>({
defaultTTL: this.options.staffTTL,
maxSize: 10,
});
this.votes = new AdvancedCache<VoteLeaderboard>({
defaultTTL: this.options.voteTTL,
maxSize: 10,
});
this.server = new AdvancedCache<ServerStatus>({
defaultTTL: this.options.serverTTL,
maxSize: 20,
});
this.punishments = new AdvancedCache<Punishment[]>({
defaultTTL: this.options.punishmentTTL,
maxSize: 200,
});
this.generic = new AdvancedCache<unknown>({
defaultTTL: 300000,
maxSize: 100,
});
// Run cleanup periodically
this.cleanupInterval = setInterval(
() => this.cleanup(),
this.options.cleanupIntervalMs
);
}
// =========================================================================
// 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);
}
deleteProfile(username: string): boolean {
return this.profiles.delete(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);
}
deleteClan(name: string): boolean {
return this.clans.delete(name);
}
// =========================================================================
// Leaderboard Cache
// =========================================================================
/**
* Generate a cache key for leaderboard data
*/
private getLeaderboardKey(
username: string,
gamemode: string,
@@ -192,9 +405,6 @@ export class PikaCache {
return `${username}:${gamemode}:${mode}:${interval}`;
}
/**
* Get cached leaderboard data
*/
getLeaderboard(
username: string,
gamemode: string,
@@ -205,9 +415,6 @@ export class PikaCache {
return this.leaderboards.get(key);
}
/**
* Cache leaderboard data
*/
setLeaderboard(
username: string,
gamemode: string,
@@ -220,9 +427,6 @@ export class PikaCache {
this.leaderboards.set(key, data, ttl);
}
/**
* Check if leaderboard data is cached
*/
hasLeaderboard(
username: string,
gamemode: string,
@@ -233,6 +437,93 @@ export class PikaCache {
return this.leaderboards.has(key);
}
// =========================================================================
// Staff Cache
// =========================================================================
getStaff(): StaffList | null {
return this.staff.get('staff');
}
setStaff(data: StaffList, ttl?: number): void {
this.staff.set('staff', data, ttl);
}
hasStaff(): boolean {
return this.staff.has('staff');
}
// =========================================================================
// Vote Leaderboard Cache
// =========================================================================
getVotes(): VoteLeaderboard | null {
return this.votes.get('votes');
}
setVotes(data: VoteLeaderboard, ttl?: number): void {
this.votes.set('votes', data, ttl);
}
hasVotes(): boolean {
return this.votes.has('votes');
}
// =========================================================================
// Server Status Cache
// =========================================================================
getServer(ip: string): ServerStatus | null {
return this.server.get(ip);
}
setServer(ip: string, data: ServerStatus, ttl?: number): void {
this.server.set(ip, data, ttl);
}
hasServer(ip: string): boolean {
return this.server.has(ip);
}
// =========================================================================
// Punishments Cache
// =========================================================================
private getPunishmentKey(username: string, filter?: string): string {
return `${username}:${filter ?? 'all'}`;
}
getPunishments(username: string, filter?: string): Punishment[] | null {
const key = this.getPunishmentKey(username, filter);
return this.punishments.get(key);
}
setPunishments(username: string, data: Punishment[], filter?: string, ttl?: number): void {
const key = this.getPunishmentKey(username, filter);
this.punishments.set(key, data, ttl);
}
hasPunishments(username: string, filter?: string): boolean {
const key = this.getPunishmentKey(username, filter);
return this.punishments.has(key);
}
// =========================================================================
// Generic Cache (for custom data)
// =========================================================================
getGeneric<T>(key: string): T | null {
return this.generic.get(key) as T | null;
}
setGeneric<T>(key: string, data: T, ttl?: number): void {
this.generic.set(key, data, ttl);
}
hasGeneric(key: string): boolean {
return this.generic.has(key);
}
// =========================================================================
// General Methods
// =========================================================================
@@ -244,23 +535,98 @@ export class PikaCache {
this.profiles.clear();
this.clans.clear();
this.leaderboards.clear();
this.staff.clear();
this.votes.clear();
this.server.clear();
this.punishments.clear();
this.generic.clear();
}
/**
* Clear specific cache type
*/
clearType(type: 'profiles' | 'clans' | 'leaderboards' | 'staff' | 'votes' | 'server' | 'punishments' | 'generic'): void {
this[type].clear();
}
/**
* Run cleanup on all caches
*/
cleanup(): { profiles: number; clans: number; leaderboards: number } {
return {
cleanup(): {
profiles: number;
clans: number;
leaderboards: number;
staff: number;
votes: number;
server: number;
punishments: number;
generic: number;
total: number;
} {
const result = {
profiles: this.profiles.cleanup(),
clans: this.clans.cleanup(),
leaderboards: this.leaderboards.cleanup(),
staff: this.staff.cleanup(),
votes: this.votes.cleanup(),
server: this.server.cleanup(),
punishments: this.punishments.cleanup(),
generic: this.generic.cleanup(),
total: 0,
};
result.total = Object.values(result).reduce((a, b) => a + b, 0) - result.total;
return result;
}
/**
* Get cache statistics
*/
getStats(): { profiles: number; clans: number; leaderboards: number } {
getStats(): {
profiles: CacheStats;
clans: CacheStats;
leaderboards: CacheStats;
staff: CacheStats;
votes: CacheStats;
server: CacheStats;
punishments: CacheStats;
generic: CacheStats;
totalSize: number;
totalHitRate: number;
} {
const profileStats = this.profiles.getStats();
const clanStats = this.clans.getStats();
const leaderboardStats = this.leaderboards.getStats();
const staffStats = this.staff.getStats();
const voteStats = this.votes.getStats();
const serverStats = this.server.getStats();
const punishmentStats = this.punishments.getStats();
const genericStats = this.generic.getStats();
const totalHits = profileStats.hits + clanStats.hits + leaderboardStats.hits +
staffStats.hits + voteStats.hits + serverStats.hits + punishmentStats.hits + genericStats.hits;
const totalMisses = profileStats.misses + clanStats.misses + leaderboardStats.misses +
staffStats.misses + voteStats.misses + serverStats.misses + punishmentStats.misses + genericStats.misses;
const totalRequests = totalHits + totalMisses;
return {
profiles: profileStats,
clans: clanStats,
leaderboards: leaderboardStats,
staff: staffStats,
votes: voteStats,
server: serverStats,
punishments: punishmentStats,
generic: genericStats,
totalSize: profileStats.size + clanStats.size + leaderboardStats.size +
staffStats.size + voteStats.size + serverStats.size + punishmentStats.size + genericStats.size,
totalHitRate: totalRequests > 0 ? totalHits / totalRequests : 0,
};
}
/**
* Get simple stats (backwards compatibility)
*/
getSimpleStats(): { profiles: number; clans: number; leaderboards: number } {
return {
profiles: this.profiles.size,
clans: this.clans.size,
@@ -274,6 +640,7 @@ export class PikaCache {
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
this.clear();
}

View File

@@ -1,17 +1,16 @@
/**
* PikaNetwork API Client
* Modern TypeScript implementation without proxy support
* Based on pikanetwork.js but rewritten with improvements
* Full TypeScript implementation based on pikanetwork.js
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
*/
import { PikaCache } from './cache.ts';
import { PikaCache, type PikaCacheOptions } from './cache.ts';
import type {
ProfileResponse,
ClanResponse,
LeaderboardResponse,
GameMode,
Interval,
PikaAPIOptions,
BedWarsStats,
SkyWarsStats,
MinimalLeaderboardData,
@@ -25,12 +24,50 @@ import type {
TotalLeaderboardOptions,
JoinInfo,
MiscInfo,
PlayerRank,
Rank,
ClanInfo,
} from './types.ts';
// ============================================================================
// API Options
// ============================================================================
export interface PikaAPIOptions {
/** Request timeout in milliseconds (default: 10000) */
timeout?: number;
/** User agent string for requests */
userAgent?: string;
/** Rate limit delay between batch requests in ms (default: 200) */
rateLimitDelay?: number;
/** Batch size for bulk operations (default: 5) */
batchSize?: number;
/** Cache options */
cache?: PikaCacheOptions;
/** Enable debug logging */
debug?: boolean;
}
// ============================================================================
// Ratio Data Types
// ============================================================================
export interface RatioData {
killDeathRatio: number;
kdrInfo: string;
winLossRatio: number;
wlrInfo: string;
winPlayRatio: number;
wprInfo: string;
arrowsHitShotRatio: number;
ahsrInfo: string;
finalKillDeathRatio?: number;
fkdrInfo?: string;
}
/**
* PikaNetwork API Client
* Provides methods to fetch player profiles, clan information, leaderboard data,
* punishments, staff lists, vote leaderboards, and server status
* Full-featured client for PikaNetwork stats API and forum scraping
*/
export class PikaNetworkAPI {
private readonly baseUrl = 'https://stats.pika-network.net/api';
@@ -38,6 +75,9 @@ export class PikaNetworkAPI {
private readonly cache: PikaCache;
private readonly timeout: number;
private readonly userAgent: string;
private readonly rateLimitDelay: number;
private readonly batchSize: number;
private readonly debug: boolean;
// Staff roles for scraping
private readonly staffRoles = new Set([
@@ -53,10 +93,30 @@ export class PikaNetworkAPI {
ban: 'bans',
};
// Request statistics
private stats = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
totalLatency: 0,
};
constructor(options: PikaAPIOptions = {}) {
this.cache = new PikaCache(options.cacheTTL ?? 3600000); // 1 hour default
this.timeout = options.timeout ?? 10000; // 10 seconds default
this.cache = new PikaCache(options.cache ?? {});
this.timeout = options.timeout ?? 10000;
this.userAgent = options.userAgent ?? 'Elly Discord Bot/1.0';
this.rateLimitDelay = options.rateLimitDelay ?? 200;
this.batchSize = options.batchSize ?? 5;
this.debug = options.debug ?? false;
}
/**
* Log debug message
*/
private log(message: string, ...args: unknown[]): void {
if (this.debug) {
console.log(`[PikaAPI] ${message}`, ...args);
}
}
// =========================================================================
@@ -66,12 +126,18 @@ export class PikaNetworkAPI {
/**
* Make an HTTP request with timeout and error handling
*/
private async request<T>(endpoint: string): Promise<T | null> {
private async request<T>(endpoint: string, baseUrl?: string): Promise<T | null> {
const url = `${baseUrl ?? this.baseUrl}${endpoint}`;
const startTime = Date.now();
this.stats.totalRequests++;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(`${this.baseUrl}${endpoint}`, {
this.log(`Requesting: ${url}`);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': this.userAgent,
@@ -80,9 +146,12 @@ export class PikaNetworkAPI {
});
clearTimeout(timeoutId);
const latency = Date.now() - startTime;
this.stats.totalLatency += latency;
if (!response.ok) {
console.error(`[PikaAPI] Request failed: ${response.status} ${response.statusText} for ${endpoint}`);
this.stats.failedRequests++;
this.log(`Request failed: ${response.status} ${response.statusText} for ${endpoint}`);
return null;
}
@@ -91,23 +160,28 @@ export class PikaNetworkAPI {
// Handle empty responses
if (!text || text.trim() === '') {
console.warn(`[PikaAPI] Empty response for ${endpoint}`);
this.stats.failedRequests++;
this.log(`Empty response for ${endpoint}`);
return null;
}
// Try to parse JSON
try {
this.stats.successfulRequests++;
this.log(`Request successful (${latency}ms): ${endpoint}`);
return JSON.parse(text) as T;
} catch {
console.error(`[PikaAPI] Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`);
this.stats.failedRequests++;
this.log(`Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`);
return null;
}
} catch (error) {
this.stats.failedRequests++;
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.error(`[PikaAPI] Request timeout for ${endpoint}`);
this.log(`Request timeout for ${endpoint}`);
} else {
console.error(`[PikaAPI] Request error for ${endpoint}: ${error.message}`);
this.log(`Request error for ${endpoint}: ${error.message}`);
}
}
return null;
@@ -131,7 +205,10 @@ export class PikaNetworkAPI {
async getProfile(username: string): Promise<ProfileResponse | null> {
// Check cache first
const cached = this.cache.getProfile(username);
if (cached) return cached;
if (cached) {
this.log(`Cache hit for profile: ${username}`);
return cached;
}
// Fetch from API
const data = await this.request<ProfileResponse>(`/profile/${encodeURIComponent(username)}`);
@@ -152,6 +229,100 @@ export class PikaNetworkAPI {
return profile !== null;
}
/**
* 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 levelling info for a player
*/
async getLevellingInfo(username: string): Promise<PlayerRank | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return profile.rank;
}
/**
* Get guild/clan info for a player
*/
async getGuildInfo(username: string): Promise<ClanInfo | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return profile.clan;
}
/**
* Get rank info for a player (display ranks like VIP, MVP, etc.)
*/
async getRankInfo(username: string): Promise<Rank[] | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return profile.ranks;
}
/**
* 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,
};
// Try to get oldest punishment date for estimated first join
let estimatedFirstJoin: Date | null = null;
let estimatedFirstJoinFormatted = 'N/A';
try {
const punishments = await this.getPunishments(username);
if (punishments.length > 0) {
const dates = punishments
.map((p) => new Date(p.date))
.filter((d) => !isNaN(d.getTime()));
if (dates.length > 0) {
estimatedFirstJoin = new Date(Math.min(...dates.map((d) => d.getTime())));
estimatedFirstJoinFormatted = estimatedFirstJoin.toLocaleString('en-US', formatOptions);
}
}
} catch {
// Ignore punishment fetch errors
}
return {
lastJoin: profile.lastSeen,
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
estimatedFirstJoin,
estimatedFirstJoinFormatted,
};
}
// =========================================================================
// Clan Methods
// =========================================================================
@@ -200,7 +371,10 @@ export class PikaNetworkAPI {
): Promise<LeaderboardResponse | null> {
// Check cache first
const cached = this.cache.getLeaderboard(username, gamemode, mode, interval);
if (cached) return cached;
if (cached) {
this.log(`Cache hit for leaderboard: ${username}/${gamemode}/${mode}/${interval}`);
return cached;
}
// Build URL with query parameters
const params = new URLSearchParams({
@@ -221,6 +395,51 @@ export class PikaNetworkAPI {
return null;
}
/**
* Get ratio data for a player (like pikanetwork.js getRatioData)
*/
async getRatioData(
username: string,
gamemode: GameMode,
interval: Interval = 'lifetime',
mode: string = 'all_modes'
): Promise<RatioData | null> {
const data = await this.getLeaderboard(username, gamemode, interval, mode);
if (!data) return null;
const ratios: RatioData = {
killDeathRatio: this.calculateRatioFromData(data, 'Kills', 'Deaths'),
kdrInfo: 'Kills/Deaths',
winLossRatio: this.calculateRatioFromData(data, 'Wins', 'Losses'),
wlrInfo: 'Wins/Losses',
winPlayRatio: this.calculateRatioFromData(data, 'Wins', 'Games played'),
wprInfo: 'Wins/Games Played',
arrowsHitShotRatio: this.calculateRatioFromData(data, 'Arrows hit', 'Arrows shot'),
ahsrInfo: 'Arrows Hit/Arrows Shot',
};
if (gamemode === 'bedwars') {
ratios.finalKillDeathRatio = this.calculateRatioFromData(data, 'Final kills', 'Final deaths');
ratios.fkdrInfo = 'Final Kills/Final Deaths';
}
return ratios;
}
/**
* Calculate ratio from leaderboard data
*/
private calculateRatioFromData(data: LeaderboardResponse, key1: string, key2: string): number {
const val1 = this.getStatValue(data, key1);
const val2 = this.getStatValue(data, key2);
if (val1 === 0 && val2 === 0) return NaN;
if (val2 === 0) return val1;
if (val1 === 0) return 0;
return Math.round((val1 / val2) * 100) / 100;
}
/**
* Get parsed BedWars stats for a player
*/
@@ -464,77 +683,121 @@ export class PikaNetworkAPI {
}
// =========================================================================
// Profile Extended Methods
// Staff List Methods
// =========================================================================
/**
* Get friend list for a player
* Get the staff list from the forum
*/
async getFriendList(username: string): Promise<string[]> {
const profile = await this.getProfile(username);
if (!profile) return [];
return profile.friends.map((f) => f.username);
async getStaffList(): Promise<StaffList | null> {
// Check cache first
const cached = this.cache.getStaff();
if (cached) {
this.log('Cache hit for staff list');
return cached;
}
const html = await this.fetchHtml(`${this.forumUrl}/staff/`);
if (!html) return null;
const staff: StaffList = {
owner: [],
manager: [],
leaddeveloper: [],
developer: [],
admin: [],
srmod: [],
moderator: [],
helper: [],
trial: [],
};
// Parse staff from HTML using regex
// Look for patterns like: <span>Username</span><span>Role</span>
const spanPairRegex = /<span[^>]*>([^<]+)<\/span>\s*<span[^>]*>([^<]+)<\/span>/gi;
let match;
while ((match = spanPairRegex.exec(html)) !== null) {
const username = match[1].trim().replace(/\s/g, '');
const roleText = match[2].trim().toLowerCase();
if (this.staffRoles.has(roleText)) {
const role = roleText.replace(/\s/g, '') as keyof StaffList;
if (staff[role] && !staff[role].includes(username)) {
staff[role].push(username);
}
}
}
this.cache.setStaff(staff);
return staff;
}
/**
* Get guild info for a player
* Check if a player is staff
*/
async getPlayerGuild(username: string): Promise<ClanResponse | null> {
const profile = await this.getProfile(username);
if (!profile?.clan) return null;
return profile.clan as ClanResponse;
async isStaff(username: string): Promise<{ isStaff: boolean; role?: string }> {
const staffList = await this.getStaffList();
if (!staffList) return { isStaff: false };
const normalizedUsername = username.toLowerCase();
for (const [role, members] of Object.entries(staffList)) {
if (members.some((m: string) => m.toLowerCase() === normalizedUsername)) {
return { isStaff: true, role };
}
}
return { isStaff: false };
}
/**
* 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,
};
}
// =========================================================================
// Vote Leaderboard Methods
// =========================================================================
/**
* Get miscellaneous info for a player
* Get the vote leaderboard
*/
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,
};
}
async getVoteLeaderboard(): Promise<VoteLeaderboard | null> {
// Check cache first
const cached = this.cache.getVotes();
if (cached) {
this.log('Cache hit for vote leaderboard');
return cached;
}
/**
* Get join info for a player
*/
async getJoinInfo(username: string): Promise<JoinInfo | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
const html = await this.fetchHtml(`${this.forumUrl}/vote`);
if (!html) 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,
};
const voters: VoteEntry[] = [];
const runnerUps: VoteEntry[] = [];
return {
lastJoin: profile.lastSeen,
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
estimatedFirstJoin: null, // Would require punishment scraping
estimatedFirstJoinFormatted: 'N/A',
};
// Parse winning voters
const winningSection = html.match(/block-voters[\s\S]*?(?=block\.runners-up|$)/i)?.[0] ?? '';
const runnerUpSection = html.match(/block\.runners-up[\s\S]*/i)?.[0] ?? '';
// Extract voter data
const voterRegex = /class="position"[^>]*>#?(\d+)[\s\S]*?class="username"[^>]*>([^<]+)[\s\S]*?(\d+)\s*votes/gi;
let match;
while ((match = voterRegex.exec(winningSection)) !== null) {
voters.push({
position: parseInt(match[1]) || voters.length + 1,
username: match[2].trim(),
votes: parseInt(match[3]) || 0,
});
}
while ((match = voterRegex.exec(runnerUpSection)) !== null) {
runnerUps.push({
position: parseInt(match[1]) || runnerUps.length + 1,
username: match[2].trim(),
votes: parseInt(match[3]) || 0,
});
}
const result = { voters, runnerUps };
this.cache.setVotes(result);
return result;
}
// =========================================================================
@@ -719,39 +982,6 @@ export class PikaNetworkAPI {
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
// =========================================================================
@@ -763,17 +993,84 @@ export class PikaNetworkAPI {
this.cache.clear();
}
/**
* Clear specific cache type
*/
clearCacheType(type: 'profiles' | 'clans' | 'leaderboards' | 'staff' | 'votes' | 'server' | 'punishments'): void {
this.cache.clearType(type);
}
/**
* Get cache statistics
*/
getCacheStats(): { profiles: number; clans: number; leaderboards: number } {
getCacheStats() {
return this.cache.getStats();
}
/**
* Get simple cache stats (backwards compatibility)
*/
getSimpleCacheStats(): { profiles: number; clans: number; leaderboards: number } {
return this.cache.getSimpleStats();
}
/**
* Get request statistics
*/
getRequestStats(): {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
averageLatency: number;
successRate: number;
} {
const avgLatency = this.stats.totalRequests > 0
? Math.round(this.stats.totalLatency / this.stats.totalRequests)
: 0;
const successRate = this.stats.totalRequests > 0
? this.stats.successfulRequests / this.stats.totalRequests
: 0;
return {
totalRequests: this.stats.totalRequests,
successfulRequests: this.stats.successfulRequests,
failedRequests: this.stats.failedRequests,
averageLatency: avgLatency,
successRate,
};
}
/**
* Reset request statistics
*/
resetStats(): void {
this.stats = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
totalLatency: 0,
};
}
/**
* Destroy the client and cleanup resources
*/
destroy(): void {
this.cache.destroy();
}
/**
* Check if the API is reachable
*/
async healthCheck(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/profile/Technoblade`, {
method: 'HEAD',
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -1,11 +1,14 @@
/**
* PikaNetwork API Module
* Exports all API-related types and classes
* Based on pikanetwork.js but rewritten in TypeScript with improvements
* Full TypeScript implementation based on pikanetwork.js
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
*/
export { PikaNetworkAPI } from './client.ts';
export { PikaCache } from './cache.ts';
// Main exports
export { PikaNetworkAPI, type PikaAPIOptions, type RatioData } from './client.ts';
export { PikaCache, type PikaCacheOptions } from './cache.ts';
// Type exports from types.ts
export type {
// Profile types
ProfileResponse,
@@ -31,7 +34,6 @@ export type {
Interval,
BedWarsMode,
SkyWarsMode,
PikaAPIOptions,
// Batch types
BatchLeaderboardResult,
MinimalLeaderboardData,
@@ -56,6 +58,7 @@ export type {
MiscInfo,
} from './types.ts';
// Type guard exports
export {
isProfileResponse,
isClanResponse,

View File

@@ -123,8 +123,11 @@ export class EllyClient extends Client {
});
this.pikaAPI = new PikaNetworkAPI({
cacheTTL: config.api.pika_cache_ttl,
timeout: config.api.pika_request_timeout,
cache: {
profileTTL: config.api.pika_cache_ttl,
leaderboardTTL: Math.floor(config.api.pika_cache_ttl / 2), // Shorter TTL for leaderboards
},
});
this.database = new JsonDatabase(config.database.path.replace('.db', '.json'));