From 470e5a13aaad465eca4cead24f2800b5b34ee19f Mon Sep 17 00:00:00 2001 From: bereck-work Date: Fri, 12 Dec 2025 16:54:00 +0000 Subject: [PATCH] (Feat): Initial Commit --- .env.example | 6 + .gitignore | 41 ++++ README.md | 145 +++++++++++ config.example.toml | 0 deno.json | 34 +++ deno.lock | 261 ++++++++++++++++++++ src/api/pika/cache.ts | 489 ++++++++++++++++++++++++++++++++----- src/api/pika/client.ts | 509 +++++++++++++++++++++++++++++++-------- src/api/pika/index.ts | 13 +- src/client/EllyClient.ts | 5 +- 10 files changed, 1330 insertions(+), 173 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 deno.json create mode 100644 deno.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23dda02 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86a4077 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e133c43 --- /dev/null +++ b/README.md @@ -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 +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 [mode] [interval]` - View BedWars stats +- `/skywars [mode] [interval]` - View SkyWars stats +- `/guild ` - View guild information + +### Applications + +- `/applications accept ` - Accept an application +- `/applications deny ` - Deny an application +- `/applications blacklist ` - Blacklist from applications + +### Suggestions + +- `/suggest <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. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..e69de29 diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..fd5f6ef --- /dev/null +++ b/deno.json @@ -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"] + } + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..785f78f --- /dev/null +++ b/deno.lock @@ -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" + ] + } +} diff --git a/src/api/pika/cache.ts b/src/api/pika/cache.ts index a445ac4..27e867b 100644 --- a/src/api/pika/cache.ts +++ b/src/api/pika/cache.ts @@ -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(); } diff --git a/src/api/pika/client.ts b/src/api/pika/client.ts index 4da4a52..db03b97 100644 --- a/src/api/pika/client.ts +++ b/src/api/pika/client.ts @@ -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; + } + } } diff --git a/src/api/pika/index.ts b/src/api/pika/index.ts index 0a10af9..057952c 100644 --- a/src/api/pika/index.ts +++ b/src/api/pika/index.ts @@ -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, diff --git a/src/client/EllyClient.ts b/src/client/EllyClient.ts index 7a6e85c..ce55699 100644 --- a/src/client/EllyClient.ts +++ b/src/client/EllyClient.ts @@ -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'));