(Feat): Initial Commit
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal 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
41
.gitignore
vendored
Normal 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
145
README.md
Normal 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
0
config.example.toml
Normal file
34
deno.json
Normal file
34
deno.json
Normal 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
261
deno.lock
generated
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
return { isStaff: false };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user