(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
|
* 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> {
|
interface CacheEntry<T> {
|
||||||
data: T;
|
data: T;
|
||||||
expires: number;
|
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 cache = new Map<string, CacheEntry<T>>();
|
||||||
private readonly defaultTTL: number;
|
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) {
|
// Statistics
|
||||||
this.defaultTTL = defaultTTL;
|
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
|
* Check if an entry has expired
|
||||||
*/
|
*/
|
||||||
private isExpired(expires: number): boolean {
|
private isExpired(entry: CacheEntry<T>): boolean {
|
||||||
return Date.now() > expires;
|
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 a value from the cache
|
||||||
*/
|
*/
|
||||||
get(key: string): T | null {
|
get(key: string): T | null {
|
||||||
const entry = this.cache.get(key.toLowerCase());
|
const normalizedKey = key.toLowerCase();
|
||||||
if (!entry) return null;
|
const entry = this.cache.get(normalizedKey);
|
||||||
|
|
||||||
if (this.isExpired(entry.expires)) {
|
if (!entry) {
|
||||||
this.cache.delete(key.toLowerCase());
|
this.stats.misses++;
|
||||||
return null;
|
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;
|
return entry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,9 +129,20 @@ class TTLCache<T> {
|
|||||||
* Set a value in the cache
|
* Set a value in the cache
|
||||||
*/
|
*/
|
||||||
set(key: string, data: T, ttl?: number): void {
|
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,
|
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
|
* Check if a key exists and is not expired
|
||||||
*/
|
*/
|
||||||
has(key: string): boolean {
|
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 (!entry) return false;
|
||||||
|
|
||||||
if (this.isExpired(entry.expires)) {
|
if (this.isExpired(entry)) {
|
||||||
this.cache.delete(key.toLowerCase());
|
this.cache.delete(normalizedKey);
|
||||||
|
this.onEvict?.(normalizedKey, 'expired');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +168,13 @@ class TTLCache<T> {
|
|||||||
* Delete a specific key
|
* Delete a specific key
|
||||||
*/
|
*/
|
||||||
delete(key: string): boolean {
|
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 {
|
clear(): void {
|
||||||
this.cache.clear();
|
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()) {
|
for (const [key, entry] of this.cache.entries()) {
|
||||||
if (now > entry.expires) {
|
if (now > entry.expires) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
|
this.onEvict?.(key, 'expired');
|
||||||
removed++;
|
removed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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
|
* PikaNetwork API Cache
|
||||||
* Manages caching for profiles, clans, and leaderboards
|
* Manages caching for all API responses with configurable TTLs
|
||||||
*/
|
*/
|
||||||
export class PikaCache {
|
export class PikaCache {
|
||||||
private profiles: TTLCache<ProfileResponse>;
|
private profiles: AdvancedCache<ProfileResponse>;
|
||||||
private clans: TTLCache<ClanResponse>;
|
private clans: AdvancedCache<ClanResponse>;
|
||||||
private leaderboards: TTLCache<LeaderboardResponse>;
|
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 cleanupInterval: number | undefined;
|
||||||
|
private readonly options: Required<PikaCacheOptions>;
|
||||||
|
|
||||||
constructor(ttl: number = 3600000) {
|
constructor(options: PikaCacheOptions = {}) {
|
||||||
this.profiles = new TTLCache<ProfileResponse>(ttl);
|
this.options = {
|
||||||
this.clans = new TTLCache<ClanResponse>(ttl);
|
profileTTL: options.profileTTL ?? 600000, // 10 minutes
|
||||||
this.leaderboards = new TTLCache<LeaderboardResponse>(ttl);
|
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.profiles = new AdvancedCache<ProfileResponse>({
|
||||||
this.cleanupInterval = setInterval(() => this.cleanup(), 300000);
|
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
|
// Profile Cache
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a cached profile
|
|
||||||
*/
|
|
||||||
getProfile(username: string): ProfileResponse | null {
|
getProfile(username: string): ProfileResponse | null {
|
||||||
return this.profiles.get(username);
|
return this.profiles.get(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache a profile
|
|
||||||
*/
|
|
||||||
setProfile(username: string, data: ProfileResponse, ttl?: number): void {
|
setProfile(username: string, data: ProfileResponse, ttl?: number): void {
|
||||||
this.profiles.set(username, data, ttl);
|
this.profiles.set(username, data, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a profile is cached
|
|
||||||
*/
|
|
||||||
hasProfile(username: string): boolean {
|
hasProfile(username: string): boolean {
|
||||||
return this.profiles.has(username);
|
return this.profiles.has(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteProfile(username: string): boolean {
|
||||||
|
return this.profiles.delete(username);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Clan Cache
|
// Clan Cache
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a cached clan
|
|
||||||
*/
|
|
||||||
getClan(name: string): ClanResponse | null {
|
getClan(name: string): ClanResponse | null {
|
||||||
return this.clans.get(name);
|
return this.clans.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache a clan
|
|
||||||
*/
|
|
||||||
setClan(name: string, data: ClanResponse, ttl?: number): void {
|
setClan(name: string, data: ClanResponse, ttl?: number): void {
|
||||||
this.clans.set(name, data, ttl);
|
this.clans.set(name, data, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a clan is cached
|
|
||||||
*/
|
|
||||||
hasClan(name: string): boolean {
|
hasClan(name: string): boolean {
|
||||||
return this.clans.has(name);
|
return this.clans.has(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteClan(name: string): boolean {
|
||||||
|
return this.clans.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Leaderboard Cache
|
// Leaderboard Cache
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a cache key for leaderboard data
|
|
||||||
*/
|
|
||||||
private getLeaderboardKey(
|
private getLeaderboardKey(
|
||||||
username: string,
|
username: string,
|
||||||
gamemode: string,
|
gamemode: string,
|
||||||
@@ -192,9 +405,6 @@ export class PikaCache {
|
|||||||
return `${username}:${gamemode}:${mode}:${interval}`;
|
return `${username}:${gamemode}:${mode}:${interval}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached leaderboard data
|
|
||||||
*/
|
|
||||||
getLeaderboard(
|
getLeaderboard(
|
||||||
username: string,
|
username: string,
|
||||||
gamemode: string,
|
gamemode: string,
|
||||||
@@ -205,9 +415,6 @@ export class PikaCache {
|
|||||||
return this.leaderboards.get(key);
|
return this.leaderboards.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache leaderboard data
|
|
||||||
*/
|
|
||||||
setLeaderboard(
|
setLeaderboard(
|
||||||
username: string,
|
username: string,
|
||||||
gamemode: string,
|
gamemode: string,
|
||||||
@@ -220,9 +427,6 @@ export class PikaCache {
|
|||||||
this.leaderboards.set(key, data, ttl);
|
this.leaderboards.set(key, data, ttl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if leaderboard data is cached
|
|
||||||
*/
|
|
||||||
hasLeaderboard(
|
hasLeaderboard(
|
||||||
username: string,
|
username: string,
|
||||||
gamemode: string,
|
gamemode: string,
|
||||||
@@ -233,6 +437,93 @@ export class PikaCache {
|
|||||||
return this.leaderboards.has(key);
|
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
|
// General Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -244,23 +535,98 @@ export class PikaCache {
|
|||||||
this.profiles.clear();
|
this.profiles.clear();
|
||||||
this.clans.clear();
|
this.clans.clear();
|
||||||
this.leaderboards.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
|
* Run cleanup on all caches
|
||||||
*/
|
*/
|
||||||
cleanup(): { profiles: number; clans: number; leaderboards: number } {
|
cleanup(): {
|
||||||
return {
|
profiles: number;
|
||||||
|
clans: number;
|
||||||
|
leaderboards: number;
|
||||||
|
staff: number;
|
||||||
|
votes: number;
|
||||||
|
server: number;
|
||||||
|
punishments: number;
|
||||||
|
generic: number;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
const result = {
|
||||||
profiles: this.profiles.cleanup(),
|
profiles: this.profiles.cleanup(),
|
||||||
clans: this.clans.cleanup(),
|
clans: this.clans.cleanup(),
|
||||||
leaderboards: this.leaderboards.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
|
* 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 {
|
return {
|
||||||
profiles: this.profiles.size,
|
profiles: this.profiles.size,
|
||||||
clans: this.clans.size,
|
clans: this.clans.size,
|
||||||
@@ -274,6 +640,7 @@ export class PikaCache {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.cleanupInterval) {
|
if (this.cleanupInterval) {
|
||||||
clearInterval(this.cleanupInterval);
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = undefined;
|
||||||
}
|
}
|
||||||
this.clear();
|
this.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* PikaNetwork API Client
|
* PikaNetwork API Client
|
||||||
* Modern TypeScript implementation without proxy support
|
* Full TypeScript implementation based on pikanetwork.js
|
||||||
* Based on pikanetwork.js but rewritten with improvements
|
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PikaCache } from './cache.ts';
|
import { PikaCache, type PikaCacheOptions } from './cache.ts';
|
||||||
import type {
|
import type {
|
||||||
ProfileResponse,
|
ProfileResponse,
|
||||||
ClanResponse,
|
ClanResponse,
|
||||||
LeaderboardResponse,
|
LeaderboardResponse,
|
||||||
GameMode,
|
GameMode,
|
||||||
Interval,
|
Interval,
|
||||||
PikaAPIOptions,
|
|
||||||
BedWarsStats,
|
BedWarsStats,
|
||||||
SkyWarsStats,
|
SkyWarsStats,
|
||||||
MinimalLeaderboardData,
|
MinimalLeaderboardData,
|
||||||
@@ -25,12 +24,50 @@ import type {
|
|||||||
TotalLeaderboardOptions,
|
TotalLeaderboardOptions,
|
||||||
JoinInfo,
|
JoinInfo,
|
||||||
MiscInfo,
|
MiscInfo,
|
||||||
|
PlayerRank,
|
||||||
|
Rank,
|
||||||
|
ClanInfo,
|
||||||
} from './types.ts';
|
} 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
|
* PikaNetwork API Client
|
||||||
* Provides methods to fetch player profiles, clan information, leaderboard data,
|
* Full-featured client for PikaNetwork stats API and forum scraping
|
||||||
* punishments, staff lists, vote leaderboards, and server status
|
|
||||||
*/
|
*/
|
||||||
export class PikaNetworkAPI {
|
export class PikaNetworkAPI {
|
||||||
private readonly baseUrl = 'https://stats.pika-network.net/api';
|
private readonly baseUrl = 'https://stats.pika-network.net/api';
|
||||||
@@ -38,6 +75,9 @@ export class PikaNetworkAPI {
|
|||||||
private readonly cache: PikaCache;
|
private readonly cache: PikaCache;
|
||||||
private readonly timeout: number;
|
private readonly timeout: number;
|
||||||
private readonly userAgent: string;
|
private readonly userAgent: string;
|
||||||
|
private readonly rateLimitDelay: number;
|
||||||
|
private readonly batchSize: number;
|
||||||
|
private readonly debug: boolean;
|
||||||
|
|
||||||
// Staff roles for scraping
|
// Staff roles for scraping
|
||||||
private readonly staffRoles = new Set([
|
private readonly staffRoles = new Set([
|
||||||
@@ -53,10 +93,30 @@ export class PikaNetworkAPI {
|
|||||||
ban: 'bans',
|
ban: 'bans',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Request statistics
|
||||||
|
private stats = {
|
||||||
|
totalRequests: 0,
|
||||||
|
successfulRequests: 0,
|
||||||
|
failedRequests: 0,
|
||||||
|
totalLatency: 0,
|
||||||
|
};
|
||||||
|
|
||||||
constructor(options: PikaAPIOptions = {}) {
|
constructor(options: PikaAPIOptions = {}) {
|
||||||
this.cache = new PikaCache(options.cacheTTL ?? 3600000); // 1 hour default
|
this.cache = new PikaCache(options.cache ?? {});
|
||||||
this.timeout = options.timeout ?? 10000; // 10 seconds default
|
this.timeout = options.timeout ?? 10000;
|
||||||
this.userAgent = options.userAgent ?? 'Elly Discord Bot/1.0';
|
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
|
* 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 {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
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,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': this.userAgent,
|
'User-Agent': this.userAgent,
|
||||||
@@ -80,9 +146,12 @@ export class PikaNetworkAPI {
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
this.stats.totalLatency += latency;
|
||||||
|
|
||||||
if (!response.ok) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,23 +160,28 @@ export class PikaNetworkAPI {
|
|||||||
|
|
||||||
// Handle empty responses
|
// Handle empty responses
|
||||||
if (!text || text.trim() === '') {
|
if (!text || text.trim() === '') {
|
||||||
console.warn(`[PikaAPI] Empty response for ${endpoint}`);
|
this.stats.failedRequests++;
|
||||||
|
this.log(`Empty response for ${endpoint}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse JSON
|
// Try to parse JSON
|
||||||
try {
|
try {
|
||||||
|
this.stats.successfulRequests++;
|
||||||
|
this.log(`Request successful (${latency}ms): ${endpoint}`);
|
||||||
return JSON.parse(text) as T;
|
return JSON.parse(text) as T;
|
||||||
} catch {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.stats.failedRequests++;
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
console.error(`[PikaAPI] Request timeout for ${endpoint}`);
|
this.log(`Request timeout for ${endpoint}`);
|
||||||
} else {
|
} else {
|
||||||
console.error(`[PikaAPI] Request error for ${endpoint}: ${error.message}`);
|
this.log(`Request error for ${endpoint}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -131,7 +205,10 @@ export class PikaNetworkAPI {
|
|||||||
async getProfile(username: string): Promise<ProfileResponse | null> {
|
async getProfile(username: string): Promise<ProfileResponse | null> {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = this.cache.getProfile(username);
|
const cached = this.cache.getProfile(username);
|
||||||
if (cached) return cached;
|
if (cached) {
|
||||||
|
this.log(`Cache hit for profile: ${username}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch from API
|
// Fetch from API
|
||||||
const data = await this.request<ProfileResponse>(`/profile/${encodeURIComponent(username)}`);
|
const data = await this.request<ProfileResponse>(`/profile/${encodeURIComponent(username)}`);
|
||||||
@@ -152,6 +229,100 @@ export class PikaNetworkAPI {
|
|||||||
return profile !== null;
|
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
|
// Clan Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -200,7 +371,10 @@ export class PikaNetworkAPI {
|
|||||||
): Promise<LeaderboardResponse | null> {
|
): Promise<LeaderboardResponse | null> {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = this.cache.getLeaderboard(username, gamemode, mode, interval);
|
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
|
// Build URL with query parameters
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -221,6 +395,51 @@ export class PikaNetworkAPI {
|
|||||||
return null;
|
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
|
* 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[]> {
|
async getStaffList(): Promise<StaffList | null> {
|
||||||
const profile = await this.getProfile(username);
|
// Check cache first
|
||||||
if (!profile) return [];
|
const cached = this.cache.getStaff();
|
||||||
return profile.friends.map((f) => f.username);
|
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> {
|
async isStaff(username: string): Promise<{ isStaff: boolean; role?: string }> {
|
||||||
const profile = await this.getProfile(username);
|
const staffList = await this.getStaffList();
|
||||||
if (!profile?.clan) return null;
|
if (!staffList) return { isStaff: false };
|
||||||
return profile.clan as ClanResponse;
|
|
||||||
|
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
|
// Vote Leaderboard Methods
|
||||||
*/
|
// =========================================================================
|
||||||
async getRankInfo(username: string): Promise<{ level: number; percentage: number; display: string } | null> {
|
|
||||||
const profile = await this.getProfile(username);
|
|
||||||
if (!profile) return null;
|
|
||||||
return {
|
|
||||||
level: profile.rank.level,
|
|
||||||
percentage: profile.rank.percentage,
|
|
||||||
display: profile.rank.rankDisplay,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get miscellaneous info for a player
|
* Get the vote leaderboard
|
||||||
*/
|
*/
|
||||||
async getMiscInfo(username: string): Promise<MiscInfo | null> {
|
async getVoteLeaderboard(): Promise<VoteLeaderboard | null> {
|
||||||
const profile = await this.getProfile(username);
|
// Check cache first
|
||||||
if (!profile) return null;
|
const cached = this.cache.getVotes();
|
||||||
return {
|
if (cached) {
|
||||||
discordBoosting: profile.discord_boosting,
|
this.log('Cache hit for vote leaderboard');
|
||||||
discordVerified: profile.discord_verified,
|
return cached;
|
||||||
emailVerified: profile.email_verified,
|
}
|
||||||
username: profile.username,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const html = await this.fetchHtml(`${this.forumUrl}/vote`);
|
||||||
* Get join info for a player
|
if (!html) return null;
|
||||||
*/
|
|
||||||
async getJoinInfo(username: string): Promise<JoinInfo | null> {
|
|
||||||
const profile = await this.getProfile(username);
|
|
||||||
if (!profile) return null;
|
|
||||||
|
|
||||||
const lastJoinDate = new Date(profile.lastSeen);
|
const voters: VoteEntry[] = [];
|
||||||
const formatOptions: Intl.DateTimeFormatOptions = {
|
const runnerUps: VoteEntry[] = [];
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
// Parse winning voters
|
||||||
lastJoin: profile.lastSeen,
|
const winningSection = html.match(/block-voters[\s\S]*?(?=block\.runners-up|$)/i)?.[0] ?? '';
|
||||||
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
|
const runnerUpSection = html.match(/block\.runners-up[\s\S]*/i)?.[0] ?? '';
|
||||||
estimatedFirstJoin: null, // Would require punishment scraping
|
|
||||||
estimatedFirstJoinFormatted: 'N/A',
|
// 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;
|
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
|
// Cache Management
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -763,17 +993,84 @@ export class PikaNetworkAPI {
|
|||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear specific cache type
|
||||||
|
*/
|
||||||
|
clearCacheType(type: 'profiles' | 'clans' | 'leaderboards' | 'staff' | 'votes' | 'server' | 'punishments'): void {
|
||||||
|
this.cache.clearType(type);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cache statistics
|
* Get cache statistics
|
||||||
*/
|
*/
|
||||||
getCacheStats(): { profiles: number; clans: number; leaderboards: number } {
|
getCacheStats() {
|
||||||
return this.cache.getStats();
|
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 the client and cleanup resources
|
||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.cache.destroy();
|
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
|
* PikaNetwork API Module
|
||||||
* Exports all API-related types and classes
|
* Full TypeScript implementation based on pikanetwork.js
|
||||||
* Based on pikanetwork.js but rewritten in TypeScript with improvements
|
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { PikaNetworkAPI } from './client.ts';
|
// Main exports
|
||||||
export { PikaCache } from './cache.ts';
|
export { PikaNetworkAPI, type PikaAPIOptions, type RatioData } from './client.ts';
|
||||||
|
export { PikaCache, type PikaCacheOptions } from './cache.ts';
|
||||||
|
|
||||||
|
// Type exports from types.ts
|
||||||
export type {
|
export type {
|
||||||
// Profile types
|
// Profile types
|
||||||
ProfileResponse,
|
ProfileResponse,
|
||||||
@@ -31,7 +34,6 @@ export type {
|
|||||||
Interval,
|
Interval,
|
||||||
BedWarsMode,
|
BedWarsMode,
|
||||||
SkyWarsMode,
|
SkyWarsMode,
|
||||||
PikaAPIOptions,
|
|
||||||
// Batch types
|
// Batch types
|
||||||
BatchLeaderboardResult,
|
BatchLeaderboardResult,
|
||||||
MinimalLeaderboardData,
|
MinimalLeaderboardData,
|
||||||
@@ -56,6 +58,7 @@ export type {
|
|||||||
MiscInfo,
|
MiscInfo,
|
||||||
} from './types.ts';
|
} from './types.ts';
|
||||||
|
|
||||||
|
// Type guard exports
|
||||||
export {
|
export {
|
||||||
isProfileResponse,
|
isProfileResponse,
|
||||||
isClanResponse,
|
isClanResponse,
|
||||||
|
|||||||
@@ -123,8 +123,11 @@ export class EllyClient extends Client {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.pikaAPI = new PikaNetworkAPI({
|
this.pikaAPI = new PikaNetworkAPI({
|
||||||
cacheTTL: config.api.pika_cache_ttl,
|
|
||||||
timeout: config.api.pika_request_timeout,
|
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'));
|
this.database = new JsonDatabase(config.database.path.replace('.db', '.json'));
|
||||||
|
|||||||
Reference in New Issue
Block a user