54 Commits
Author SHA1 Message Date
k.eaven da01f64e5c Version increment
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 36s
CI/CD Pipeline / Security Scanning (push) Successful in 31s
CI/CD Pipeline / Integration Tests (push) Successful in 18s
CI/CD Pipeline / Build Docker Image (push) Successful in 36s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 35s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 35s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 30s
CI/CD Pipeline / Generate Test Report (push) Successful in 11s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
2026-11-03 10:38:23 +00:00
k.eaven 3743d19d9d Merge pull request 'Merge development into main: Multi-guild support + fixes' (#5) from development into main
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 1m1s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 40s
CI/CD Pipeline / Integration Tests (push) Successful in 20s
CI/CD Pipeline / Security Scanning (push) Successful in 28s
CI/CD Pipeline / Build Docker Image (push) Successful in 37s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 43s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 1m0s
Reviewed-on: k.eaven/pterodisbot#5
2026-11-03 10:25:34 +00:00
k.eaven f3f7bb9c4a Format resource usage denominators left-aligned in embeds
CI/CD Pipeline / Integration Tests (push) Successful in 24s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 24s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 32s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 26s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 23s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 30s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 2m48s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 2m46s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 2m46s
CI/CD Pipeline / Security Scanning (push) Successful in 32s
CI/CD Pipeline / Build Docker Image (push) Successful in 12m21s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 18s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 3s
- Changed format_limit() in _add_resource_fields() to use left-alignment (<8) instead of right-alignment (>8)
- Denominators (limits/∞) now align left relative to the '/' separator
- Produces cleaner visual formatting in resource usage section
2026-11-03 09:16:36 +00:00
k.eaven 204cc6704c Chore: correct errors in README
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Failing after 7s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 32s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Integration Tests (push) Skipped
CI/CD Pipeline / Code Quality & Linting (push) Successful in 28s
CI/CD Pipeline / Security Scanning (push) Successful in 27s
CI/CD Pipeline / Build Docker Image (push) Skipped
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 34s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
2026-10-30 11:06:14 +00:00
k.eaven 9a3122a259 chore: Remove stray file
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 39s
CI/CD Pipeline / Security Scanning (push) Successful in 29s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
CI/CD Pipeline / Build Docker Image (push) Successful in 33s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 39s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 35s
CI/CD Pipeline / Integration Tests (push) Successful in 23s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 21s
2026-10-30 10:37:26 +00:00
k.eaven bec06bd909 feat: Multi-guild support implementation
- Added multi-guild support with comma-separated AllowedGuildIDs config
- Per-guild embed tracking with isolated state
- Per-server action locking to prevent race conditions across guilds
- Cross-guild embed updates after power actions
- Debug logging for embed update triggers
- Fixed resource usage field formatting (brackets now properly closed)
- Updated config.ini.sample, generate_config.py for backward compatibility
- Updated README.md with multi-guild documentation
- Updated tests for multi-guild support
2026-10-30 10:37:13 +00:00
k.eaven e68b248033 Merge pull request 'Remove redundant original code (pterodisbot.py)' (#4) from development into main
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 39s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 45s
CI/CD Pipeline / Security Scanning (push) Successful in 33s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 30s
CI/CD Pipeline / Integration Tests (push) Successful in 21s
CI/CD Pipeline / Generate Test Report (push) Successful in 7s
CI/CD Pipeline / Build Docker Image (push) Successful in 44s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 30s
2026-10-26 11:43:40 +00:00
k.eaven 25cf7fed34 Remove redundant original code
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 33s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 30s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 21s
CI/CD Pipeline / Generate Test Report (push) Successful in 5s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 1m17s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 1m17s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 29s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 32s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 4s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 35s
CI/CD Pipeline / Security Scanning (push) Successful in 29s
CI/CD Pipeline / Integration Tests (push) Successful in 25s
CI/CD Pipeline / Build Docker Image (push) Successful in 55s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 1m17s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 38s
2026-10-26 11:32:51 +00:00
k.eaven 27967b8f7b Merge pull request 'Chore: Minor docstring updates for release prep' (#3) from development into main
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 46s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 41s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Build Docker Image (push) Successful in 33s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 30s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 37s
CI/CD Pipeline / Integration Tests (push) Successful in 23s
CI/CD Pipeline / Security Scanning (push) Successful in 38s
Reviewed-on: k.eaven/pterodisbot#3
2026-10-24 23:11:00 +00:00
k.eaven 2323ea5eab Chore: Minor docstring updates for release prep
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 33s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 32s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 26s
CI/CD Pipeline / Security Scanning (push) Successful in 24s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 30s
CI/CD Pipeline / Generate Test Report (push) Successful in 11s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 46s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 47s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 48s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 29s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 33s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Integration Tests (push) Successful in 18s
CI/CD Pipeline / Build Docker Image (push) Successful in 55s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 19s
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 3s
2026-10-24 23:02:16 +00:00
k.eaven 92104fb4bf Merge pull request 'Update: Major code refactor for modular extensibility' (#2) from development into main
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 53s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 35s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 28s
CI/CD Pipeline / Security Scanning (push) Successful in 29s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Build Docker Image (push) Successful in 25s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 53s
CI/CD Pipeline / Integration Tests (push) Successful in 20s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
Reviewed-on: k.eaven/pterodisbot#2
2026-10-24 15:33:41 +00:00
k.eaven 49848286d7 Redirect program entrypoint via shell script in image
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 38s
CI/CD Pipeline / Security Scanning (push) Successful in 32s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 23s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
CI/CD Pipeline / Build Docker Image (push) Successful in 55s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 33s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 26s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 33s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 33s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 3s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 47s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 35s
CI/CD Pipeline / Integration Tests (push) Successful in 27s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 31s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 33s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 2s
2026-10-24 14:52:21 +00:00
k.eaven 434eb7eea6 Fix: Update Dockerfile ENTRYPOINT to use entrypoint.sh for config generation 2026-10-24 14:34:42 +00:00
k.eaven 8105f11c32 Fix: Update entrypoint.sh to use main.py instead of pterodisbot.py
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 39s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 47s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 22s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Build Docker Image (push) Successful in 51s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 39s
CI/CD Pipeline / Security Scanning (push) Successful in 28s
CI/CD Pipeline / Integration Tests (push) Successful in 32s
2026-10-24 14:28:35 +00:00
k.eaven 451e202832 Fix: Auto-generate config.ini from environment variables with priority logic
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 26s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 33s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 33s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 33s
CI/CD Pipeline / Integration Tests (push) Successful in 31s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
CI/CD Pipeline / Build Docker Image (push) Successful in 44s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Security Scanning (push) Successful in 30s
2026-10-24 14:18:56 +00:00
k.eaven 316416bcbe Add pydantic to requirements.txt for Docker container
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 31s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 34s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 34s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 30s
CI/CD Pipeline / Security Scanning (push) Successful in 31s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Integration Tests (push) Successful in 30s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Build Docker Image (push) Successful in 7m24s
2026-10-24 13:51:23 +00:00
k.eaven 0adc7f44e8 Update CI/CD workflow for modular code structure: update test targets, linting targets, coverage modules, and add Pelican Panel test keys
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 40s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 40s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 43s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 25s
CI/CD Pipeline / Security Scanning (push) Successful in 38s
CI/CD Pipeline / Build Docker Image (push) Successful in 27s
CI/CD Pipeline / Integration Tests (push) Successful in 32s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
2026-10-24 13:08:52 +00:00
k.eaven 346e6d9604 Update: Major code refactor for modular extensibility
CI/CD Pipeline / Integration Tests (push) Successful in 21s
CI/CD Pipeline / Security Scanning (push) Successful in 2m16s
CI/CD Pipeline / Build Docker Image (push) Successful in 44s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 29s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 30s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 23s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 27s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
2026-10-24 10:58:01 +00:00
k.eaven ac59b97500 Merge pull request 'Add Pelican Panel API key support' (#1) from development into main
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 53s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 55s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 56s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Build Docker Image (push) Successful in 1m2s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 32s
CI/CD Pipeline / Integration Tests (push) Successful in 31s
CI/CD Pipeline / Security Scanning (push) Successful in 2m17s
Reviewed-on: k.eaven/pterodisbot#1
2026-10-24 08:01:48 +00:00
k.eaven f1f39f35ca Merge branch 'main' into development
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 31s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 27s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 40s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 26s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 43s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 25s
CI/CD Pipeline / Security Scanning (push) Successful in 2m23s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 25s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 2m34s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Generate Test Report (push) Successful in 9s
CI/CD Pipeline / Build Docker Image (push) Successful in 1m11s
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 4s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 33s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 26s
CI/CD Pipeline / Integration Tests (push) Successful in 24s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 9s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 9s
2026-10-24 07:46:55 +00:00
k.eaven d891b21ffd Resolve CI/CD branch conflicts
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 42s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 46s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 46s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 30s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 45s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 36s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 33s
CI/CD Pipeline / Security Scanning (push) Successful in 2m28s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Integration Tests (push) Successful in 25s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 21s
CI/CD Pipeline / Build Docker Image (push) Successful in 39s
CI/CD Pipeline / Generate Test Report (push) Successful in 9s
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 12s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 6s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 14s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 33s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 2m24s
2026-10-24 07:46:22 +00:00
k.eaven d830033b8f Update README
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 28s
CI/CD Pipeline / Integration Tests (push) Successful in 17s
CI/CD Pipeline / Security Scanning (push) Successful in 20s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 47s
CI/CD Pipeline / Build Docker Image (push) Successful in 25s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 20s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 3m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 21s
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 3s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 25s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 27s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 31s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 44s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 3m31s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
2026-10-24 07:29:19 +00:00
k.eaven ec73053ac9 Update CI/CD to run a matrix of unit tests for compatible Python versions
CI/CD Pipeline / Code Quality & Linting (push) Successful in 46s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 3m40s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 4m17s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Build Docker Image (push) Successful in 34s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 21s
CI/CD Pipeline / Security Scanning (push) Successful in 19s
CI/CD Pipeline / Integration Tests (push) Successful in 18s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
2026-10-24 06:56:43 +00:00
k.eaven 448d1d1448 Add Pelican Panel API key support: accept pacc_ (client) and papp_ (application) prefixes alongside Pterodactyl's ptlc_ and ptla_
CI/CD Pipeline / Code Quality & Linting (push) Successful in 58s
CI/CD Pipeline / Security Scanning (push) Successful in 1m13s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 4m16s
CI/CD Pipeline / Build Docker Image (push) Successful in 54s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
2026-10-24 06:51:57 +00:00
k.eaven 338ebe0836 Update CI/CD unit test matrix
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 38s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 39s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 41s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 25s
CI/CD Pipeline / Integration Tests (push) Successful in 28s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
CI/CD Pipeline / Security Scanning (push) Successful in 2m20s
CI/CD Pipeline / Build Docker Image (push) Successful in 11m39s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
2026-10-23 22:05:56 +00:00
k.eaven 64e8acf697 Update CI/CD runner workflow
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 1m36s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 26s
CI/CD Pipeline / Security Scanning (push) Successful in 2m17s
CI/CD Pipeline / Unit Tests (Python 3.10) (push) Failing after 11s
CI/CD Pipeline / Integration Tests (push) Skipped
CI/CD Pipeline / Build Docker Image (push) Skipped
CI/CD Pipeline / Generate Test Report (push) Successful in 2s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 1m45s
2026-10-23 21:59:40 +00:00
k.eaven 1f0a63067b Update all dependencies to latest stable versions: Python 3.12, discord.py 2.7.1, aiohttp 3.14.5, matplotlib 3.11.2, and all test dependencies
CI/CD Pipeline / Security Scanning (push) Successful in 28s
CI/CD Pipeline / Generate Test Report (push) Successful in 14s
CI/CD Pipeline / Build Docker Image (push) Successful in 1m11s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 46s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 23s
CI/CD Pipeline / Integration Tests (push) Successful in 30s
2026-10-23 20:52:18 +00:00
k.eaven 872b016707 Fix Gitea CI/CD checkout: use fetch-depth: 50 with git protocol config to resolve 'bad pack header' error
CI/CD Pipeline / Unit Tests (Python 3.10) (push) Successful in 6m40s
CI/CD Pipeline / Unit Tests (Python 3.9) (push) Successful in 6m40s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 54s
CI/CD Pipeline / Security Scanning (push) Successful in 24s
CI/CD Pipeline / Integration Tests (push) Successful in 3m30s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 6m38s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Build Docker Image (push) Successful in 16m2s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 3s
2026-10-21 08:32:00 +00:00
k.eaven 8e7a68d6a2 Fix Gitea CI/CD checkout: add fetch-depth: 0 to resolve 'bad pack header' error 2026-10-21 08:02:59 +00:00
k.eaven 9d092514d9 Add: Kubernetes guide to README 2026-10-21 07:05:21 +00:00
k.eaven 4bd84e2c7d Improved README 2025-11-06 16:14:51 +00:00
k.eaven 7fd2e5e89b Update README.md
Simplifying main README in preparation for full wiki documentation of project
2025-11-06 13:26:04 +00:00
k.eaven 8fa5f18d83 README back in project root directory 2025-11-04 13:07:32 +00:00
k.eaven 21208897b0 Add: README directory 2025-11-04 11:54:52 +00:00
k.eaven f80e253401 Update: CI/CD repo branches 2025-10-28 14:08:03 +00:00
k.eaven 1447d770c9 Merge pull request 'experimental' (#9) from experimental into main
Reviewed-on: k.eaven/pterodactyl-discord-bot#9
2025-10-27 16:24:44 +00:00
k.eaven bec3efcdd0 Update: Code formatting
CI/CD Pipeline / Unit Tests (Python 3.10) (push) Successful in 28s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 23s
CI/CD Pipeline / Security Scanning (push) Successful in 20s
CI/CD Pipeline / Integration Tests (push) Successful in 14s
CI/CD Pipeline / Generate Test Report (push) Successful in 20s
CI/CD Pipeline / Build Docker Image (push) Successful in 17m7s
CI/CD Pipeline / Unit Tests (Python 3.9) (push) Successful in 22s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 49s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
2025-10-27 09:43:24 +00:00
k.eaven 5e61c838d5 Update: Code formatting 2025-10-26 13:26:49 +00:00
k.eaven 11ee1447de Fix: Unit test async mock 2025-10-24 14:22:00 +00:00
k.eaven 08dee3db99 Add: CI/CD testing automation 2025-10-23 13:20:39 +00:00
k.eaven 102814420b Merge pull request 'Fix: Update container image dependencies' (#7) from experimental into main
Docker Build and Push (Multi-architecture) / build-and-push (push) Successful in 16m41s
Reviewed-on: k.eaven/pterodactyl-discord-bot#7
2025-09-29 05:17:26 +00:00
k.eaven 55971496c8 Fix: Matplotlib permission error 2025-09-29 05:07:24 +00:00
k.eaven 135d596119 Fix: Dependency update for Docker image 2025-09-29 04:52:17 +00:00
k.eaven 205c8eb9b7 Fix: Dependency update for Docker image 2025-09-29 04:40:19 +00:00
k.eaven f5528dcc9c Merge pull request 'Feature Add: Resource usage graphs' (#6) from experimental into main
Reviewed-on: k.eaven/pterodactyl-discord-bot#6
2025-09-29 04:25:20 +00:00
k.eaven ce77639a47 Add: Dynamic graph scaling for multi vCPU 2025-09-29 04:07:33 +00:00
k.eaven ce4887bae3 Fix: Delete graph image if server not running 2025-09-28 18:41:50 +00:00
k.eaven ca9e88f1e2 Improve formatting in embeds 2025-09-28 17:10:03 +00:00
k.eaven 4b400fea1f Add server metrics graphing feature 2025-09-28 16:11:43 +00:00
k.eaven 4260948c1c Merge pull request 'Update embed text formatting' (#4) from experimental into main
Reviewed-on: k.eaven/pterodactyl-discord-bot#4
2025-09-26 12:40:59 +00:00
k.eaven cbb951d121 Update embed text formatting 2025-09-26 12:38:47 +00:00
k.eaven a546540c45 Merge pull request 'Redesign server embed' (#3) from experimental into main
Reviewed-on: k.eaven/pterodactyl-discord-bot#3
2025-09-26 11:18:59 +00:00
k.eaven 174c27c933 Redesign server embed 2025-09-26 10:43:03 +00:00
k.eaven 1f7f211e36 Add server uptime metric to embed 2025-09-26 08:52:41 +00:00
26 changed files with 7439 additions and 2372 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
[flake8]
max-line-length = 140
+456
View File
@@ -0,0 +1,456 @@
name: CI/CD Pipeline
on:
push:
branches: [ main, development, experimental ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ main ]
workflow_dispatch:
inputs:
skip_tests:
description: 'Skip tests'
required: false
default: 'false'
type: boolean
image_tag:
description: 'Custom tag for Docker image'
required: false
default: 'latest'
type: string
jobs:
# ==========================================
# TESTING STAGE
# ==========================================
unit-tests:
name: Unit Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
if: ${{ !inputs.skip_tests }}
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Configure Git
run: |
git config --global protocol.version 2
git config --global http.postBuffer 524288000
git config --global http.lowSpeedLimit 0
git config --global http.lowSpeedTime 999999
- name: Set up uv & Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
activate-environment: true
enable-cache: true
cache-dependency-glob: |
requirements.txt
requirements-test.txt
- name: Install dependencies
run: |
uv pip install --upgrade setuptools wheel
uv pip install -r requirements.txt
uv pip install -r requirements-test.txt
- name: Create test configuration
run: |
mkdir -p embed logs
cat > config.ini << EOF
[Pterodactyl]
PanelURL = https://panel.example.com
ClientAPIKey = ptlc_test_client_key_123456789
ApplicationAPIKey = ptla_test_app_key_987654321
# Pelican Panel test keys
# ClientAPIKey = pacc_test_client_key_123456789
# ApplicationAPIKey = papp_test_app_key_987654321
[Discord]
Token = test_discord_token_placeholder
AllowedGuildID = 123456789
EOF
- name: Run unit tests with coverage
run: |
python -m pytest test_pterodisbot.py \
-v \
--tb=short \
--cov=config \
--cov=api \
--cov=embeds \
--cov=views \
--cov=bot \
--cov=metrics \
--cov=models \
--cov-report=xml \
--cov-report=term \
--cov-report=html \
--junitxml=test-results-${{ matrix.python-version }}.xml
- name: Upload coverage to artifacts
uses: actions/upload-artifact@v3
with:
name: coverage-report-py${{ matrix.python-version }}
path: |
coverage.xml
htmlcov/
test-results-${{ matrix.python-version }}.xml
code-quality:
name: Code Quality & Linting
runs-on: ubuntu-latest
if: ${{ !inputs.skip_tests }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Configure Git
run: |
git config --global protocol.version 2
git config --global http.postBuffer 524288000
git config --global http.lowSpeedLimit 0
git config --global http.lowSpeedTime 999999
- name: Set up uv & Python
uses: astral-sh/setup-uv@v5
with:
python-version: '3.12'
activate-environment: true
enable-cache: true
- name: Install linting tools
run: |
uv pip install --upgrade setuptools wheel
uv pip install flake8 pylint black isort mypy
- name: Run flake8
run: |
flake8 config.py api.py embeds.py views.py bot.py metrics.py models.py \
--max-line-length=140 \
--ignore=E501,W503,E203 \
--exclude=venv,__pycache__,build,dist \
--statistics \
--output-file=flake8-report.txt
continue-on-error: true
- name: Run pylint
run: |
pylint config.py api.py embeds.py views.py bot.py metrics.py models.py \
--disable=C0111,C0103,R0913,R0914,R0915,W0718 \
--max-line-length=140 \
--output-format=text \
--reports=y > pylint-report.txt || true
continue-on-error: true
- name: Check code formatting with black
run: |
black --check --line-length=140 --diff config.py api.py embeds.py views.py bot.py metrics.py models.py | tee black-report.txt
continue-on-error: true
- name: Check import ordering
run: |
isort --check-only --profile black --line-length=140 config.py api.py embeds.py views.py bot.py metrics.py models.py
continue-on-error: true
- name: Type checking with mypy
run: |
mypy config.py api.py embeds.py views.py bot.py metrics.py models.py --ignore-missing-imports > mypy-report.txt || true
continue-on-error: true
- name: Upload linting reports
uses: actions/upload-artifact@v3
with:
name: code-quality-reports
path: |
flake8-report.txt
pylint-report.txt
black-report.txt
mypy-report.txt
security-scan:
name: Security Scanning
runs-on: ubuntu-latest
if: ${{ !inputs.skip_tests }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Configure Git
run: |
git config --global protocol.version 2
git config --global http.postBuffer 524288000
git config --global http.lowSpeedLimit 0
git config --global http.lowSpeedTime 999999
- name: Set up uv & Python
uses: astral-sh/setup-uv@v5
with:
python-version: '3.12'
activate-environment: true
enable-cache: true
- name: Install security tools
run: |
uv pip install --upgrade setuptools wheel
uv pip install bandit safety pip-audit
- name: Run bandit security scan
run: |
bandit -r config.py api.py embeds.py views.py bot.py metrics.py models.py \
-f json \
-o bandit-report.json \
-ll \
--exclude ./venv,./test_*.py,./tests
continue-on-error: true
- name: Run safety dependency check
run: |
uv pip install -r requirements.txt
safety check --json --output safety-report.json || true
continue-on-error: true
- name: Run pip-audit
run: |
pip-audit --desc --format json --output pip-audit-report.json || true
continue-on-error: true
- name: Upload security reports
uses: actions/upload-artifact@v3
with:
name: security-reports
path: |
bandit-report.json
safety-report.json
pip-audit-report.json
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
needs: [unit-tests]
if: ${{ !inputs.skip_tests }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Configure Git
run: |
git config --global protocol.version 2
git config --global http.postBuffer 524288000
git config --global http.lowSpeedLimit 0
git config --global http.lowSpeedTime 999999
- name: Set up uv & Python
uses: astral-sh/setup-uv@v5
with:
python-version: '3.12'
activate-environment: true
enable-cache: true
cache-dependency-glob: |
requirements.txt
requirements-test.txt
- name: Install dependencies
run: |
uv pip install --upgrade setuptools wheel
uv pip install -r requirements.txt
uv pip install -r requirements-test.txt
- name: Create test configuration
run: |
mkdir -p embed logs
cat > config.ini << EOF
[Pterodactyl]
PanelURL = https://panel.example.com
ClientAPIKey = ptlc_test_client_key_123456789
ApplicationAPIKey = ptla_test_app_key_987654321
# Pelican Panel test keys
# ClientAPIKey = pacc_test_client_key_123456789
# ApplicationAPIKey = papp_test_app_key_987654321
[Discord]
Token = test_discord_token_placeholder
AllowedGuildID = 123456789
EOF
- name: Run integration tests
run: |
python -m pytest test_pterodisbot.py::TestIntegration \
-v \
--tb=short \
--timeout=60
# ==========================================
# BUILD STAGE
# ==========================================
docker-build:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [unit-tests, code-quality, security-scan]
if: |
always() &&
(needs.unit-tests.result == 'success' || inputs.skip_tests) &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Configure Git
run: |
git config --global protocol.version 2
git config --global http.postBuffer 524288000
git config --global http.lowSpeedLimit 0
git config --global http.lowSpeedTime 999999
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
platforms: linux/amd64,linux/arm64
driver-opts: |
image=moby/buildkit:latest
- name: Log in to registry
uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Generate Docker image tags
id: tags
run: |
IMAGE_NAME="${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}"
if [ -n "${{ github.event.inputs.image_tag }}" ]; then
PRIMARY_TAG="${{ github.event.inputs.image_tag }}"
elif [[ ${{ github.ref }} == refs/tags/v* ]]; then
PRIMARY_TAG="${GITHUB_REF#refs/tags/}"
elif [[ ${{ github.ref }} == refs/heads/main ]]; then
PRIMARY_TAG="latest"
elif [[ ${{ github.ref }} == refs/heads/development ]]; then
PRIMARY_TAG="development"
elif [[ ${{ github.ref }} == refs/heads/experimental ]]; then
PRIMARY_TAG="experimental"
else
PRIMARY_TAG="latest"
fi
TAGS="$IMAGE_NAME:$PRIMARY_TAG,$IMAGE_NAME:${{ github.sha }}"
if [[ ${{ github.ref }} == refs/tags/v* ]]; then
MAJOR_MINOR_TAG=$(echo "$PRIMARY_TAG" | sed -E 's/^v([0-9]+\.[0-9]+)\.[0-9]+.*$/v\1/')
if [[ "$MAJOR_MINOR_TAG" != "$PRIMARY_TAG" ]]; then
TAGS="$TAGS,$IMAGE_NAME:$MAJOR_MINOR_TAG"
fi
MAJOR_TAG=$(echo "$PRIMARY_TAG" | sed -E 's/^v([0-9]+)\.[0-9]+\.[0-9]+.*$/v\1/')
if [[ "$MAJOR_TAG" != "$PRIMARY_TAG" ]]; then
TAGS="$TAGS,$IMAGE_NAME:$MAJOR_TAG"
fi
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "Generated tags: $TAGS"
- name: Build and push multi-arch image
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=registry,ref=${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}:cache
cache-to: type=registry,ref=${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}:cache,mode=max
tags: ${{ steps.tags.outputs.tags }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
# ==========================================
# REPORTING STAGE
# ==========================================
test-report:
name: Generate Test Report
runs-on: ubuntu-latest
needs: [unit-tests, code-quality, security-scan, integration-tests]
if: always() && !inputs.skip_tests
steps:
- name: Download all artifacts
uses: actions/download-artifact@v3
- name: Generate test summary
run: |
echo "## 🧪 Test Results Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Job Status:" >> $GITHUB_STEP_SUMMARY
echo "- ✅ Unit Tests: \`${{ needs.unit-tests.result }}\`" >> $GITHUB_STEP_SUMMARY
echo "- 🎨 Code Quality: \`${{ needs.code-quality.result }}\`" >> $GITHUB_STEP_SUMMARY
echo "- 🔒 Security Scan: \`${{ needs.security-scan.result }}\`" >> $GITHUB_STEP_SUMMARY
echo "- 🔗 Integration Tests: \`${{ needs.integration-tests.result }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Artifacts Generated:" >> $GITHUB_STEP_SUMMARY
echo "- Coverage reports (HTML & XML)" >> $GITHUB_STEP_SUMMARY
echo "- Code quality reports (flake8, pylint, black)" >> $GITHUB_STEP_SUMMARY
echo "- Security scan reports (bandit, safety)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "**Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "**Triggered by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
final-status:
name: CI/CD Pipeline Status
runs-on: ubuntu-latest
needs: [test-report, docker-build]
if: always()
steps:
- name: Check pipeline status
run: |
echo "## 🚀 CI/CD Pipeline Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.docker-build.result }}" == "success" ]]; then
echo "✅ **Docker image built and pushed successfully**" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.docker-build.result }}" == "skipped" ]]; then
echo "⏭️ **Docker build skipped**" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Docker build failed**" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Pipeline run:** ${{ github.run_number }}" >> $GITHUB_STEP_SUMMARY
echo "**Workflow:** ${{ github.workflow }}" >> $GITHUB_STEP_SUMMARY
- name: Fail if critical jobs failed
if: |
(needs.unit-tests.result == 'failure' && !inputs.skip_tests) ||
needs.docker-build.result == 'failure'
run: exit 1
-89
View File
@@ -1,89 +0,0 @@
name: Docker Build and Push (Multi-architecture)
on:
push:
branches: [ main, experimental ]
tags: [ 'v*.*.*' ]
workflow_dispatch:
inputs:
image_tag:
description: 'Custom tag for the Docker image'
required: true
default: 'latest'
type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
platforms: linux/amd64,linux/arm64
driver-opts: |
image=moby/buildkit:latest
- name: Log in to registry
uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Generate Docker image tags
id: tags
run: |
# Base image name
IMAGE_NAME="${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}"
# Determine primary tag
if [ -n "${{ github.event.inputs.image_tag }}" ]; then
PRIMARY_TAG="${{ github.event.inputs.image_tag }}"
elif [[ ${{ github.ref }} == refs/tags/v* ]]; then
PRIMARY_TAG="${GITHUB_REF#refs/tags/}"
elif [[ ${{ github.ref }} == refs/heads/main ]]; then
PRIMARY_TAG="latest"
elif [[ ${{ github.ref }} == refs/heads/experimental ]]; then
PRIMARY_TAG="experimental"
else
PRIMARY_TAG="latest"
fi
# Start with primary tag and SHA tag
TAGS="$IMAGE_NAME:$PRIMARY_TAG,$IMAGE_NAME:${{ github.sha }}"
# Add version tags for releases
if [[ ${{ github.ref }} == refs/tags/v* ]]; then
# Add major.minor tag (e.g., v1.2 for v1.2.3)
MAJOR_MINOR_TAG=$(echo "$PRIMARY_TAG" | sed -E 's/^v([0-9]+\.[0-9]+)\.[0-9]+.*$/v\1/')
if [[ "$MAJOR_MINOR_TAG" != "$PRIMARY_TAG" ]]; then
TAGS="$TAGS,$IMAGE_NAME:$MAJOR_MINOR_TAG"
fi
# Add major tag (e.g., v1 for v1.2.3)
MAJOR_TAG=$(echo "$PRIMARY_TAG" | sed -E 's/^v([0-9]+)\.[0-9]+\.[0-9]+.*$/v\1/')
if [[ "$MAJOR_TAG" != "$PRIMARY_TAG" ]]; then
TAGS="$TAGS,$IMAGE_NAME:$MAJOR_TAG"
fi
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "Generated tags: $TAGS"
- name: Build and push multi-arch image
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=registry,ref=${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}:cache
cache-to: type=registry,ref=${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}:cache,mode=max
tags: ${{ steps.tags.outputs.tags }}
+30 -46
View File
@@ -4,6 +4,14 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# C extensions # C extensions
*.so *.so
@@ -37,20 +45,33 @@ MANIFEST
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
# Unit test / coverage reports # Testing
htmlcov/ __pycache__/
.tox/ *.py[cod]
.nox/ *$py.class
*.so
.pytest_cache/
.coverage .coverage
.coverage.* .coverage.*
.cache htmlcov/
nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .tox/
cover/ .nox/
# Test reports
test-results*.xml
junit*.xml
*-report.txt
*-report.json
bandit-report.json
safety-report.json
pip-audit-report.json
flake8-report.txt
pylint-report.txt
black-report.txt
mypy-report.txt
# Translations # Translations
*.mo *.mo
@@ -83,37 +104,7 @@ target/
profile_default/ profile_default/
ipython_config.py ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm # pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml .pdm.toml
.pdm-python .pdm-python
.pdm-build/ .pdm-build/
@@ -161,13 +152,6 @@ dmypy.json
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/
+535 -130
View File
@@ -1,4 +1,4 @@
# Pterodactyl Discord Bot # Pterodisbot the Pterodactyl Discord Bot
![Pterodactyl X Discord](md_images/Pterodisbot_README_Header.png) ![Pterodactyl X Discord](md_images/Pterodisbot_README_Header.png)
@@ -6,10 +6,11 @@ A feature-rich Discord bot for monitoring and managing Pterodactyl game servers
## Table of Contents ## Table of Contents
- [Features](#features) - [Features](#features)
- [Architecture Overview](#architecture-overview)
- [Installation](#installation) - [Installation](#installation)
- [Discord Bot Setup](#discord-bot-setup)
- [Traditional Installation](#traditional-installation) - [Traditional Installation](#traditional-installation)
- [Docker Installation](#docker-installation) - [Docker Installation](#docker-installation)
- [Kubernetes Installation](#kubernetes-installation)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Environment Variables](#environment-variables) - [Environment Variables](#environment-variables)
- [Configuration File](#configuration-file) - [Configuration File](#configuration-file)
@@ -20,58 +21,161 @@ A feature-rich Discord bot for monitoring and managing Pterodactyl game servers
- [Permissions](#permissions) - [Permissions](#permissions)
- [Logging System](#logging-system) - [Logging System](#logging-system)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Contributing](#contributing)
- [License](#license) - [License](#license)
## Features ## Features
### Core Functionality ### 🎮 Server Management
- **Real-time Server Monitoring**: Auto-updating embeds showing server status (online/offline) and detailed resource usage - **Interactive Server Selection**: Choose servers from an intuitive dropdown menu showing all available game servers
- **Power Management**: Start, stop, and restart servers directly from Discord with button controls - **Real-time Power Controls**: Start, stop, and restart servers with color-coded button controls
- **Connection Information**: One-click display of server addresses and ports - 🟢 Green Start button
- **Multi-channel Support**: Status embeds can be placed in any channel with proper permissions - 🔴 Red Stop button
- 🔵 Blue Restart button
- **Connection Information**: Instantly view server IP addresses and ports with the "Show Address" button
- **Multi-channel Embeds**: Deploy server status displays across any channels where the bot has permissions
### Intelligent Updates ### 📊 Server Monitoring
- **State Change Detection**: Embeds update immediately when server power state changes - **Live Status Tracking**: Auto-updating embeds display current server state (online/offline/starting)
- **Resource Thresholds**: CPU usage changes >50% trigger updates - **Resource Usage Metrics**: Detailed monitoring of:
- **Optimized API Calls**: Minimizes requests to the Discord API - CPU usage with dynamic scaling for multi-core servers
- Memory consumption in real-time
- Disk space utilization
- Network activity (incoming/outgoing traffic)
- Server uptime counter with human-readable formatting
- **Visual Metrics Graphs**:
- Combined CPU and memory usage graphs over the last minute
- 6 data points captured at 10-second intervals
- Discord dark theme-optimized styling
- Automatic graph generation and embedding for running servers
- Dynamic CPU scaling (100% increments) for accurate multi-vCPU visualization
- Trend indicators (increasing/decreasing/stable) for quick status assessment
### Access Control ### 🔄 Intelligent Update System
- **Guild Restriction**: Operates only in specified Discord server - **Smart Update Triggers**: Embeds refresh automatically when:
- **Role-based Permissions**: "Game Server User" role required for interactions - Server power state changes (offline ↔ starting ↔ running)
- **Admin Commands**: Special commands restricted to server administrators - CPU usage shifts by more than 50%
- Server is detected for the first time
- Running servers reach 10-minute intervals (for uptime accuracy)
- **Rate Limit Protection**: Built-in delays prevent Discord API throttling
- **Optimized API Calls**: Minimizes unnecessary requests through change detection
- **Background Task Management**: Asynchronous update loop runs every 10 seconds
### Reliability Features ### 🔒 Security & Access Control
- **Persistent Storage**: Remembers embed locations between bot restarts - **Multi-Guild Support**: Bot operates across multiple designated Discord servers simultaneously
- **Error Handling**: Comprehensive error recovery and logging - **Per-Guild Server Tracking**: Each guild tracks servers independently with isolated embeds
- **Rate Limit Protection**: Built-in delays between API calls - **Role-based Permissions**: "Game Server User" role required for all server interactions (per guild)
- **Administrator Commands**: Special management commands restricted to server administrators (per guild)
- **Interaction Validation**: Every button press and command verified for proper authorization within its guild
## Architecture Overview ### 💾 Data Persistence
- **Embed Location Storage**: Automatically remembers where embeds are posted (survives bot restarts)
- **JSON-based Storage**: Human-readable embed tracking in `embed_locations.json`
- **Metrics History**: Maintains 1 minute of server metrics data (6 data points per server)
- **Automatic Cleanup**: Removes tracking for deleted embeds and inactive servers
The bot is built with several key components: ### 🛡️ Reliability Features
- **Comprehensive Error Handling**: Graceful recovery from API failures and network issues
- **Rotating Log Files**: Automatic log rotation (5MB max, 3 backup files)
- **Debug-level Logging**: Detailed operation logs for troubleshooting
- **Graceful Shutdown**: Proper cleanup on SIGINT/SIGTERM signals (Docker-friendly)
- **Configuration Validation**: Startup verification of all required settings
- **API Key Validation**: Checks for correct Pterodactyl API key formats
1. **PterodactylAPI Class**: Handles all communication with the Pterodactyl Panel API ### 🎨 User Interface
2. **ServerStatusView Class**: Manages the interactive Discord UI with control buttons - **Color-coded Status**: Visual indicators for server states
3. **PterodactylBot Class**: Main bot class that orchestrates all operations - 🔵 Blue for online servers
4. **Background Task**: Regularly checks server status and updates embeds when needed - 🔴 Red for offline servers
5. **Configuration System**: Validates and manages all settings from config.ini - **Ephemeral Interactions**: Private responses visible only to command users
- **Real-time Feedback**: Instant confirmation messages for all actions
- **Persistent Controls**: Server control buttons remain functional indefinitely
- **Responsive Design**: Clean, Discord-optimized embed layouts
The system uses aiohttp for asynchronous HTTP requests and discord.py for Discord interactions. ### 🔧 Administrator Tools
- **Bulk Embed Refresh**: Force-refresh all server status embeds with one command
- **Complete Purge System**: Remove all bot embeds from the server with detailed statistics
- **Progress Tracking**: Real-time progress updates during multi-embed operations
- **Comprehensive Logging**: All administrative actions logged with user attribution
## Installation ## Installation
### Discord Bot Setup
Before installing the bot software, you need to create a Discord application and bot account.
#### 1. Create Discord Application
1. Navigate to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click the **"New Application"** button in the top-right corner
3. Enter a name for your application (e.g., "Pterodactyl Bot")
4. Accept the Terms of Service and click **"Create"**
#### 2. Configure Bot Settings
1. In the left sidebar, click on **"Bot"**
2. Click **"Add Bot"** and confirm by clicking **"Yes, do it!"**
3. Under the bot's username, you'll see a **Token** section:
- Click **"Reset Token"** and then **"Copy"** to get your bot token
- ⚠️ **Keep this token secret!** Never share it publicly or commit it to version control
4. Scroll down to **"Privileged Gateway Intents"** and enable:
- ✅ **Message Content Intent** (required for the bot to read message content)
5. Click **"Save Changes"** at the bottom
#### 3. Set Bot Permissions
1. In the left sidebar, click on **"OAuth2"** → **"URL Generator"**
2. Under **"Scopes"**, select:
- ✅ `bot`
- ✅ `applications.commands`
3. Under **"Bot Permissions"**, select:
- ✅ **View Channels** (allows bot to see channels)
- ✅ **Send Messages** (allows bot to post embeds)
- ✅ **Embed Links** (required for rich embeds)
- ✅ **Attach Files** (required for metrics graphs)
- ✅ **Read Message History** (allows bot to edit existing messages)
- ✅ **Use Slash Commands** (required for `/server_status` commands)
#### 4. Invite Bot to Your Server
1. Copy the **Generated URL** at the bottom of the OAuth2 URL Generator page
2. Paste this URL into your browser and press Enter
3. Select the Discord server where you want to add the bot
4. Click **"Authorize"** and complete the CAPTCHA if prompted
5. The bot should now appear in your server (offline until you start the application)
#### 5. Get Your Guild ID
1. In Discord, enable **Developer Mode**:
- Click the ⚙️ gear icon (User Settings)
- Navigate to **"Advanced"** under "App Settings"
- Toggle on **"Developer Mode"**
2. Right-click on your server icon in the left sidebar
3. Click **"Copy Server ID"** at the bottom of the menu
4. Save this ID - you'll need it for the bot configuration
#### 6. Create Server Role
1. In your Discord server, click the server name at the top to open the dropdown menu
2. Select **"Server Settings"** → **"Roles"**
3. Click **"Create Role"**
4. Name the role exactly: `Game Server User`
5. The role doesn't need any special permissions - it's only used for bot access control
6. Click **"Save Changes"**
7. Assign this role to any users who should be able to control game servers
### Prerequisites ### Prerequisites
- Python 3.8 or higher **or** Docker - Python 3.8 or higher **or** Docker
- Discord bot token with proper permissions - Discord bot token (from setup above)
- Discord server (guild) ID (from setup above)
- Pterodactyl panel with API access - Pterodactyl panel with API access
- Server with both Client and Application API keys - Pterodactyl Client API key (starts with `ptlc_`)
- Pterodactyl Application API key (starts with `ptla_`)
### Traditional Installation ### Traditional Installation
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://git.serendipity.systems/k.eaven/pterodactyl-discord-bot.git git clone https://git.serendipity.systems/k.eaven/pterodisbot.git
cd pterodactyl-discord-bot cd pterodactyl-discord-bot
``` ```
@@ -91,7 +195,7 @@ The system uses aiohttp for asynchronous HTTP requests and discord.py for Discor
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://git.serendipity.systems/k.eaven/pterodactyl-discord-bot.git git clone https://git.serendipity.systems/k.eaven/pterodisbot.git
cd pterodactyl-discord-bot cd pterodactyl-discord-bot
``` ```
@@ -121,13 +225,13 @@ The system uses aiohttp for asynchronous HTTP requests and discord.py for Discor
pterodisbot: pterodisbot:
build: . build: .
environment: environment:
# Remmove brackets and spaces in environment variables # Remove brackets and spaces in environment variables
# Ideally, use .env file to assign values to these instead # Ideally, use .env file to assign values to these instead
- DISCORD_TOKEN={Your Discord bot token} - DISCORD_TOKEN={Your Discord bot token from Developer Portal}
- ALLOWED_GUILD_ID={The Discord server ID where the bot should operate} - ALLOWED_GUILD_ID={Your Discord server ID from step 5 above}
- PANEL_URL={Your Pterodactyl panel URL (must include http:// or https://)} - PANEL_URL={Your Pterodactyl panel URL with https://}
- CLIENT_API_KEY={Pterodactyl client API key (starts with `ptlc_`)} - CLIENT_API_KEY={Pterodactyl client API key starting with ptlc_}
- APPLICATION_API_KEY={Pterodactyl application API key (starts with `ptla_`)} - APPLICATION_API_KEY={Pterodactyl application API key starting with ptla_}
volumes: volumes:
- bot_logs:/app/logs - bot_logs:/app/logs
- bot_embed:/app/embed - bot_embed:/app/embed
@@ -142,18 +246,206 @@ The system uses aiohttp for asynchronous HTTP requests and discord.py for Discor
docker-compose up -d docker-compose up -d
``` ```
### Kubernetes Installation
1. Clone the repository:
```bash
git clone https://git.serendipity.systems/k.eaven/pterodisbot.git
cd pterodactyl-discord-bot
```
2. Build and push the Docker image to your container registry:
```bash
docker build -t your-registry/pterodisbot:latest .
docker push your-registry/pterodisbot:latest
```
3. Create a Kubernetes namespace (optional but recommended):
```bash
kubectl create namespace pterodisbot
```
4. Create a Kubernetes Secret with your configuration:
```bash
kubectl create secret generic pterodisbot-config \
--from-literal=DISCORD_TOKEN='your-discord-bot-token' \
--from-literal=ALLOWED_GUILD_ID='your-discord-guild-id' \
--from-literal=PANEL_URL='https://your.pterodactyl.panel' \
--from-literal=CLIENT_API_KEY='ptlc_your-client-api-key' \
--from-literal=APPLICATION_API_KEY='ptla_your-application-api-key' \
-n pterodisbot
```
5. Create a PersistentVolumeClaim for logs and embed data:
```yaml
# pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pterodisbot-data
namespace: pterodisbot
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
```bash
kubectl apply -f pvc.yaml
```
6. Create the Deployment:
```yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pterodisbot
namespace: pterodisbot
labels:
app: pterodisbot
spec:
replicas: 1
selector:
matchLabels:
app: pterodisbot
template:
metadata:
labels:
app: pterodisbot
spec:
containers:
- name: pterodisbot
image: your-registry/pterodisbot:latest
imagePullPolicy: Always
envFrom:
- secretRef:
name: pterodisbot-config
volumeMounts:
- name: pterodisbot-data
mountPath: /app/logs
subPath: logs
- name: pterodisbot-data
mountPath: /app/embed
subPath: embed
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
exec:
command: ["python", "-c", "import sys; sys.exit(0)"]
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
exec:
command: ["python", "-c", "import sys; sys.exit(0)"]
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: pterodisbot-data
persistentVolumeClaim:
claimName: pterodisbot-data
```
```bash
kubectl apply -f deployment.yaml
```
7. (Optional) Create a Service if you need to expose metrics or health checks:
```yaml
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: pterodisbot
namespace: pterodisbot
labels:
app: pterodisbot
spec:
selector:
app: pterodisbot
ports:
- name: http
port: 8080
targetPort: 8080
type: ClusterIP
```
```bash
kubectl apply -f service.yaml
```
8. (Optional) Create a HorizontalPodAutoscaler for automatic scaling:
```yaml
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: pterodisbot
namespace: pterodisbot
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: pterodisbot
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
```
```bash
kubectl apply -f hpa.yaml
```
9. Verify the deployment:
```bash
kubectl get pods -n pterodisbot
kubectl logs -n pterodisbot -l app=pterodisbot -f
```
10. For production, consider using a Helm chart or Kustomize for better configuration management. Create a `kustomization.yaml`:
```yaml
# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: pterodisbot
resources:
- pvc.yaml
- deployment.yaml
- service.yaml
- hpa.yaml
images:
- name: pterodisbot
newTag: latest
commonLabels:
app: pterodisbot
```
```bash
kubectl apply -k .
```
## Configuration ## Configuration
### Environment Variables ### Environment Variables
When using Docker or generating the config automatically, these environment variables are required: When using Docker or generating the config automatically, these environment variables are required:
- `DISCORD_TOKEN`: Your Discord bot token - `DISCORD_TOKEN`: Your Discord bot token (from Discord Developer Portal)
- `ALLOWED_GUILD_ID`: The Discord server ID where the bot should operate - `ALLOWED_GUILD_IDS`: Comma-separated list of Discord server IDs where the bot should operate (e.g., `123456789,987654321`)
- `PANEL_URL`: Your Pterodactyl panel URL (must include http:// or https://) - `PANEL_URL`: Your Pterodactyl panel URL (must include http:// or https://)
- `CLIENT_API_KEY`: Pterodactyl client API key (starts with `ptlc_`) - `CLIENT_API_KEY`: Pterodactyl client API key (starts with `ptlc_`)
- `APPLICATION_API_KEY`: Pterodactyl application API key (starts with `ptla_`) - `APPLICATION_API_KEY`: Pterodactyl application API key (starts with `ptla_`)
> **Note**: The legacy `ALLOWED_GUILD_ID` (singular) environment variable is still supported for backward compatibility.
### Configuration File ### Configuration File
The bot uses a `config.ini` file with this structure: The bot uses a `config.ini` file with this structure:
@@ -166,7 +458,7 @@ ApplicationAPIKey = ptla_yourapplicationkey
[Discord] [Discord]
Token = your.discord.bot.token Token = your.discord.bot.token
AllowedGuildID = 123456789012345678 AllowedGuildIDs = 123456789012345678,987654321098765432
``` ```
To generate the config file from environment variables: To generate the config file from environment variables:
@@ -178,142 +470,255 @@ python generate_config.py
### First Run Setup ### First Run Setup
1. Invite your bot to your Discord server with these permissions: 1. Ensure your bot is invited to your Discord server with proper permissions (see [Discord Bot Setup](#discord-bot-setup))
- View Channels
- Send Messages
- Embed Links
- Manage Messages (for embed updates)
- Use Slash Commands
2. Create a "Game Server User" role for users who should control servers 2. Verify the "Game Server User" role exists in your server
3. Use the `/server_status` command to create your first status embed 3. Start the bot using your preferred installation method
4. The bot should appear online in your server
5. Use the `/server_status` command to create your first status embed
### Bot Commands ### Bot Commands
All commands are guild-scoped - they only affect the guild (server) where they are invoked.
#### `/server_status` #### `/server_status`
Display an interactive context menu containing all available servers which can be selected to create an embed for a given server. Display an interactive dropdown menu containing all available servers from your Pterodactyl panel. Select a server to create a permanent status embed in the current channel with real-time monitoring and control buttons.
**Features:**
- Shows total server count and online/offline statistics
- Lists all servers with descriptions
- Creates persistent, auto-updating embeds
- Automatically removes old embeds when recreating for the same server **in this guild**
#### `/refresh_embeds` (Admin only) #### `/refresh_embeds` (Admin only)
Force a refresh of all server status embeds. This is useful if embeds become out of sync. Force a complete refresh of all server status embeds **in the current guild**. This command:
- Recreates all tracked embeds with fresh data
- Deletes old embed messages to prevent duplication
- Updates embed tracking storage
- Provides statistics on deleted and created embeds
**Use cases:**
- Embeds appear out of sync with actual server states
- After bot configuration changes
- Following Discord API issues
#### `/purge_embeds` (Admin only) #### `/purge_embeds` (Admin only)
Delete all embeds from the Discord guild. Permanently delete all server status embeds from **the current Discord server**. This command:
- Removes all tracked embed messages
- Clears the embed tracking file for this guild
- Provides real-time progress updates
- Shows detailed statistics (deleted, missing, errors)
**Use cases:**
- Starting fresh with new embed layout
- Removing bot from server
- Cleaning up after testing
### Embed System ### Embed System
Status embeds include: Status embeds dynamically display:
- Server name and current state (color-coded)
- Resource usage (when online):
- CPU percentage
- Memory usage
- Disk usage
- Network activity
- Interactive buttons:
- Start (green)
- Stop (red)
- Restart (blue)
- Show Address (grey)
Embed locations are stored in `embed_locations.json` and persist between bot restarts. **Header Information:**
- Server name with current state (color-coded)
- Server description
- Last updated timestamp
**Status Fields:**
- 🆔 Server ID (Pterodactyl identifier)
- ℹ️ Status (Active or Suspended)
- ⏱️ Uptime (for running servers)
**Resource Usage (when online):**
- CPU percentage with dynamic scaling
- Memory usage (current vs. allocated)
- Disk usage (current vs. allocated)
- Network traffic (incoming/outgoing)
**Metrics Visualization (when running):**
- Combined CPU and memory graph
- Last 1 minute of data (6 data points)
- Trend indicators
- Data point counter
**Interactive Buttons:**
- 🟢 **Start** - Powers on the server
- 🔴 **Stop** - Gracefully shuts down the server
- 🔵 **Restart** - Reboots the server
- ⚪ **Show Address** - Displays connection information (IP:Port)
**Automatic Updates:**
Embeds refresh when server state changes, including:
- Power state transitions
- Significant resource usage changes
- Periodic updates for uptime accuracy
Embed locations are stored in `embed/embed_locations.json` and persist between bot restarts.
## Permissions ## Permissions
The bot implements two levels of access control: The bot implements multi-guild access control with per-guild isolation:
1. **Guild Restriction**: Only works in the Discord server specified in `AllowedGuildID` ### 1. Guild Allowlist
2. **Role Requirement**: Users must have the "Game Server User" role to interact with server controls The bot only responds to commands and interactions from Discord servers whose IDs are listed in `AllowedGuildIDs`. This prevents unauthorized use if the bot is accidentally added to other servers.
Administrators can use the `/refresh_embeds` and `/purge_embeds` command. ### 2. Per-Guild Role-Based Access
Users must have the **"Game Server User"** role (exact name match) **in the specific guild** where they are interacting to:
- Use server control buttons (Start/Stop/Restart)
- View server addresses
- Interact with any bot features
Each guild maintains its own "Game Server User" role independently.
### 3. Per-Guild Administrator Access
Server administrators (users with Discord's Administrator permission) **in a specific guild** can additionally:
- Use `/refresh_embeds` command (affects only that guild)
- Use `/purge_embeds` command (affects only that guild)
- No special role required - based on Discord permissions in that guild
**Note:** The `/server_status` command can be used by anyone in an allowed guild, but only users with the "Game Server User" role in that guild can interact with the resulting embeds.
## Logging System ## Logging System
The bot maintains comprehensive logs in two locations: The bot maintains comprehensive logs in two locations:
1. **File Logs**: Rotating log files (5MB max, 3 backups) in `pterodactyl_bot.log` ### 1. File Logs
2. **Console Output**: Real-time logging to stdout - **Location**: `logs/pterodisbot.log`
- **Rotation**: Automatic rotation when file reaches 5MB
- **Backups**: Keeps 3 previous log files (`pterodisbot.log.1`, `.2`, `.3`)
- **Format**: `YYYY-MM-DD HH:MM:SS - LEVEL - MESSAGE`
Log levels: ### 2. Console Output
- **DEBUG**: Detailed operational information (API calls, state changes) - **Real-time**: Logs appear in stdout/stderr
- **INFO**: General operational messages (bot startup, command usage) - **Format**: Same as file logs
- **WARNING**: Potential issues (missing embeds, API warnings) - **Level**: INFO and above (less verbose than file logs)
- **ERROR**: Operational failures (API errors, permission issues)
- **CRITICAL**: Critical failures (configuration errors, startup failures) ### Log Levels
- **DEBUG**: Detailed operational information (API calls, state changes, cache operations)
- **INFO**: General operational messages (bot startup, command usage, successful operations)
- **WARNING**: Potential issues (missing embeds, API warnings, permission issues)
- **ERROR**: Operational failures (API errors, Discord API issues, failed operations)
- **CRITICAL**: Critical failures (configuration errors, startup failures, unrecoverable errors)
### Example Log Entries
```
2025-09-29 03:51:13,743 - INFO - Initialized logging system with file and console output
2025-09-29 03:51:16,828 - INFO - Starting full refresh of all server embeds
2025-09-29 03:51:16,829 - INFO - Bot connected as Petrodisbot (ID: 1419694924458360964)
2025-09-29 03:52:10,481 - INFO - Start button pressed for Minecraft Server by username
2025-09-29 03:52:14,406 - DEBUG - Power state changed for Minecraft Server: offline -> starting
```
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues
**Embeds not updating:** #### Embeds not updating
- Verify the bot has proper channel permissions **Symptoms**: Embeds show outdated information or don't change when server state changes
- Check API keys have correct permissions
- Use `/refresh_embeds` to reset all embeds
**Buttons not working:** **Solutions:**
- Confirm users have the "Game Server User" role - Verify the bot has proper channel permissions (View Channel, Send Messages, Embed Links)
- Ensure the bot has these permissions: - Check that both API keys are valid and have correct permissions
- Review logs for API errors or rate limiting
- Use `/refresh_embeds` to force-recreate all embeds
- Ensure the bot hasn't been rate-limited by Discord (check logs for HTTP 429 errors)
#### Buttons not working
**Symptoms**: Clicking Start/Stop/Restart buttons shows permission errors
**Solutions:**
- Confirm users have the "Game Server User" role (exact name match)
- Verify the role is properly assigned to users
- Check bot permissions:
- View Channel - View Channel
- Send Messages - Send Messages
- Embed Links - Embed Links
- Use Slash Commands - Use Slash Commands
- Ensure the interaction is happening in the correct Discord server (guild)
**API errors:** #### Commands not appearing
- Double-check your Pterodactyl panel URL **Symptoms**: Slash commands don't show up when typing `/`
**Solutions:**
- Wait 5-10 minutes after bot startup (command sync takes time)
- Verify bot has "Use Slash Commands" permission
- Check that the bot is in the correct guild (server)
- Review logs for command sync errors
- Try kicking and re-inviting the bot with proper OAuth2 URL
#### API errors
**Symptoms**: Bot logs show Pterodactyl API failures
**Solutions:**
- Double-check your Pterodactyl panel URL (must include `https://`)
- Verify API keys are correct and not expired - Verify API keys are correct and not expired
- Confirm the server exists in Pterodactyl - Confirm the server exists in Pterodactyl panel
- Check API key permissions:
- Client key needs: server resource access, power control
- Application key needs: server list access, server details
- Test API keys directly using Pterodactyl API documentation
#### Graphs not appearing
**Symptoms**: Embeds show "Usage Trends" section but no graph image
**Solutions:**
- Wait at least 20 seconds after server starts (needs 2 data points minimum)
- Check that the server is in "running" state
- Verify matplotlib is installed correctly (`pip install matplotlib`)
- Review logs for graph generation errors
- Ensure `/tmp/matplotlib` directory has write permissions (Docker)
#### Bot crashes on startup
**Symptoms**: Bot exits immediately with configuration errors
**Solutions:**
- Verify all required fields in `config.ini`:
- No empty values
- Correct format for all URLs (must include protocol)
- API keys have correct prefixes (`ptlc_` and `ptla_`)
- Guild ID is a valid integer
- Check file permissions on `config.ini`
- Review startup logs for specific validation errors
- Test configuration validation: `python -c "from pterodisbot import validate_config; validate_config()"`
### Checking Logs ### Checking Logs
For real-time log monitoring: For real-time log monitoring:
```bash ```bash
tail -f pterodactyl_bot.log # Traditional installation
tail -f logs/pterodisbot.log
# Docker installation
docker logs -f pterodisbot
# Docker Compose
docker-compose logs -f pterodisbot
``` ```
## Development For searching logs:
### Requirements
- Python 3.8+
- Poetry (recommended) or pip
### Setup
1. Clone the repository
2. Install dependencies:
```bash
poetry install
```
or
```bash
pip install -r requirements.txt -r requirements-dev.txt
```
3. Create a `.env` file with your development environment variables
4. Run in development mode:
```bash
python pterodisbot.py
```
### Testing
The project includes unit tests (to be expanded). Run with:
```bash ```bash
pytest # Find all ERROR level messages
grep "ERROR" logs/pterodisbot.log
# Find logs for specific server
grep "Server_Name" logs/pterodisbot.log
# View last 100 lines
tail -n 100 logs/pterodisbot.log
``` ```
## Contributing ### Getting Help
Contributions are welcome! Please follow these guidelines: If issues persist after trying these solutions:
1. Fork the repository 1. Enable DEBUG logging by reviewing recent log files
2. Create a feature branch (`git checkout -b feature/your-feature`) 2. Check the [Git Issues](https://git.serendipity.systems/k.eaven/pterodisbot/issues) for similar problems
3. Commit your changes (`git commit -am 'Add some feature'`) 3. Verify your setup matches all requirements in [Prerequisites](#prerequisites)
4. Push to the branch (`git push origin feature/your-feature`) 4. Test each component individually (Discord bot, Pterodactyl API, permissions)
5. Open a pull request
### Coding Standards
- Follow PEP 8 style guidelines
- Include type hints for all function signatures
- Document public methods with docstrings
- Write tests for new functionality
## License ## License
@@ -321,4 +726,4 @@ This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE)
--- ---
**Note**: This bot is designed for single Pterodactyl domain and single Discord guild use. For multi-instance support, significant modifications would be required. **Note**: This bot supports a singular Pterodactyl or Pelican domain with multiple Discord guilds. Each guild tracks servers independently while sharing the same Pterodactyl/Pelican infrastructure. Users from multiple Discord guilds can view and control the same servers running on a given Pterodaactyl/Pelican domain simultaneously.
+44
View File
@@ -0,0 +1,44 @@
"""
Pterodisbot
A feature-rich Discord bot for monitoring and managing Pterodactyl/Pelican Panel
game servers through Discord, providing real-time status monitoring and control
capabilities with an intuitive interface.
Modules:
- config: Configuration management and validation
- api: Panel API client (Pterodactyl/Pelican)
- models: Pydantic data models
- metrics: Server metrics tracking and graph generation
- embeds: Discord embed management and persistence
- views: Discord UI components (buttons, dropdowns)
- bot: Main bot class and command definitions
- exceptions: Custom exception classes
"""
__version__ = "1.3.0"
__author__ = "Eaven Kimura"
from config import Config, load_config, generate_config_from_env
from exceptions import (
PterodisBotError,
ConfigValidationError,
APIError,
ServerNotFoundError,
PermissionError,
EmbedNotFoundError,
MetricsError,
)
__all__ = [
"Config",
"load_config",
"generate_config_from_env",
"PterodisBotError",
"ConfigValidationError",
"APIError",
"ServerNotFoundError",
"PermissionError",
"EmbedNotFoundError",
"MetricsError",
]
+341
View File
@@ -0,0 +1,341 @@
"""
Pterodactyl/Pelican Panel API Client Module
Provides a clean interface for interacting with Pterodactyl and Pelican Panel APIs.
Supports both client API (server operations) and application API (admin operations).
"""
import asyncio
import logging
from typing import Any, Dict, List, Optional
import aiohttp
from config import Config
from exceptions import APIError, ServerNotFoundError
logger = logging.getLogger("pterodisbot")
class PanelAPI:
"""
Unified API client for Pterodactyl and Pelican Panel.
Handles all HTTP requests to the panel API with proper authentication,
error handling, and rate limiting protection.
Features:
- Automatic session management
- Dual API key support (client + application)
- Concurrent request protection via asyncio.Lock
- Comprehensive error handling and logging
- Response parsing with structured error information
Args:
config: Validated Config instance containing panel credentials
"""
def __init__(self, config: Config) -> None:
"""
Initialize the Panel API client.
Args:
config: Validated Config instance with panel URL and API keys
"""
self.config = config
self.panel_url = config.pterodactyl_url.rstrip("/")
self.client_api_key = config.client_api_key
self.application_api_key = config.application_api_key
self.session: Optional[aiohttp.ClientSession] = None
self._lock = asyncio.Lock()
self._is_initialized = False
logger.info(f"Initialized {config.get_panel_display_name()} API client")
async def initialize(self) -> None:
"""Initialize the aiohttp client session."""
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession()
logger.debug("Created new aiohttp ClientSession")
self._is_initialized = True
async def close(self) -> None:
"""Cleanly close the aiohttp session."""
if self.session and not self.session.closed:
await self.session.close()
logger.debug("Closed aiohttp ClientSession")
self._is_initialized = False
async def _request(
self,
method: str,
endpoint: str,
data: Optional[Dict[str, Any]] = None,
use_application_key: bool = False,
) -> Dict[str, Any]:
"""
Make an authenticated request to the panel API.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint (e.g., 'application/servers')
data: Optional JSON payload for POST/PUT requests
use_application_key: Whether to use application API key (admin endpoints)
Returns:
Dictionary containing API response data
Raises:
APIError: For network errors, HTTP errors, or parsing failures
"""
if not self._is_initialized or self.session is None or self.session.closed:
await self.initialize()
url = f"{self.panel_url}/api/{endpoint}"
api_key_type = "Application" if use_application_key else "Client"
logger.debug(f"Preparing {method} request to {endpoint} using {api_key_type} API key")
api_key = self.application_api_key if use_application_key else self.client_api_key
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"Content-Type": "application/json",
}
try:
async with self._lock:
logger.debug(f"Acquired lock for API request to {endpoint}")
async with self.session.request(
method, url, headers=headers, json=data
) as response:
if response.status == 204:
logger.debug(f"Received 204 No Content from {endpoint}")
return {"status": "success"}
response_data = await response.json()
logger.debug(
f"Received response from {endpoint} with status {response.status}"
)
if response.status >= 400:
error_msg = self._extract_error_message(response_data)
logger.error(
f"API request to {endpoint} failed with status "
f"{response.status}: {error_msg}"
)
raise APIError(
message=error_msg,
endpoint=endpoint,
status_code=response.status,
)
return response_data
except aiohttp.ClientError as e:
logger.error(f"Network error during API request to {endpoint}: {str(e)}")
raise APIError(
message=f"Network error: {str(e)}",
endpoint=endpoint,
)
except Exception as e:
logger.error(f"Unexpected error during API request to {endpoint}: {str(e)}")
raise APIError(
message=f"Unexpected error: {str(e)}",
endpoint=endpoint,
)
def _extract_error_message(self, response_data: Dict[str, Any]) -> str:
"""
Extract human-readable error message from API response.
Args:
response_data: Parsed JSON response from API
Returns:
Formatted error message string
"""
if "errors" in response_data and isinstance(response_data["errors"], list):
errors = response_data["errors"]
if errors and isinstance(errors[0], dict):
return errors[0].get("detail", "Unknown API error")
return str(errors[0]) if errors else "Unknown API error"
return response_data.get("message", "Unknown error")
# ============================================================
# Server Management Methods (Client API)
# ============================================================
async def get_server_resources(self, server_id: str) -> Dict[str, Any]:
"""
Get resource usage for a specific server.
Args:
server_id: The panel server identifier
Returns:
Dictionary containing server resource usage and state
Raises:
ServerNotFoundError: If server doesn't exist
APIError: For other API errors
"""
logger.debug(f"Fetching resource usage for server {server_id}")
try:
response = await self._request(
"GET", f"client/servers/{server_id}/resources"
)
return response
except APIError as e:
if e.status_code == 404:
raise ServerNotFoundError(f"Server {server_id} not found")
raise
async def send_power_action(self, server_id: str, action: str) -> Dict[str, Any]:
"""
Send a power action to a server.
Args:
server_id: The panel server identifier
action: Power action (start, stop, restart)
Returns:
Dictionary containing API response status
Raises:
ValueError: If action is invalid
APIError: For API errors
"""
valid_actions = ["start", "stop", "restart"]
if action not in valid_actions:
raise ValueError(
f"Invalid power action: {action}. Must be one of: {valid_actions}"
)
logger.info(f"Sending {action} command to server {server_id}")
result = await self._request(
"POST", f"client/servers/{server_id}/power", {"signal": action}
)
if result.get("status") == "success":
logger.info(f"Successfully executed {action} on server {server_id}")
else:
logger.error(f"Failed to execute {action} on server {server_id}")
return result
# ============================================================
# Server Information Methods (Application API)
# ============================================================
async def get_servers(self) -> List[Dict[str, Any]]:
"""
Get a list of all servers from the panel.
Uses application API key as this is an admin endpoint.
Returns:
List of server dictionaries with full attributes
"""
logger.info("Fetching list of all servers from panel")
response = await self._request("GET", "application/servers", use_application_key=True)
servers = response.get("data", [])
logger.info(f"Retrieved {len(servers)} servers from panel")
return servers
async def get_server_details(self, server_id: str) -> Dict[str, Any]:
"""
Get detailed server information including allocations.
Uses application API key as this is an admin endpoint.
Args:
server_id: The panel server identifier
Returns:
Dictionary containing detailed server information
"""
logger.debug(f"Fetching detailed information for server {server_id}")
return await self._request(
"GET", f"application/servers/{server_id}", use_application_key=True
)
async def get_server_allocations(self, server_id: str) -> Dict[str, Any]:
"""
Get allocation information for a server (IP addresses and ports).
Uses application API key as this is an admin endpoint.
Args:
server_id: The panel server identifier
Returns:
Dictionary containing server allocation information
"""
logger.debug(f"Fetching allocation information for server {server_id}")
return await self._request(
"GET",
f"application/servers/{server_id}/allocations",
use_application_key=True,
)
async def get_client_server_details(self, server_id: str) -> Dict[str, Any]:
"""
Get server details using client API (for connection info).
Args:
server_id: The panel server identifier
Returns:
Dictionary containing server details with allocations
"""
logger.debug(f"Fetching client server details for {server_id}")
return await self._request("GET", f"client/servers/{server_id}")
# ============================================================
# Convenience Methods
# ============================================================
async def get_server_default_allocation(
self, server_id: str
) -> tuple[str, str]:
"""
Get the default allocation IP and port for a server.
Args:
server_id: The panel server identifier
Returns:
Tuple of (ip_alias, port)
Raises:
ServerNotFoundError: If server or allocations not found
APIError: For API errors
"""
details = await self.get_client_server_details(server_id)
if details.get("status") == "error":
raise ServerNotFoundError(
details.get("message", "Failed to get server details")
)
attributes = details.get("attributes", {})
relationships = attributes.get("relationships", {})
allocations = relationships.get("allocations", {}).get("data", [])
if not allocations:
raise ServerNotFoundError("No allocations found for this server")
# Find default allocation or fallback to first
default_allocation = next(
(
alloc
for alloc in allocations
if alloc.get("attributes", {}).get("is_default", False)
),
allocations[0],
)
allocation_attrs = default_allocation.get("attributes", {})
ip_alias = allocation_attrs.get("ip_alias", "Unknown")
port = str(allocation_attrs.get("port", "Unknown"))
return ip_alias, port
+877
View File
@@ -0,0 +1,877 @@
"""
Main Bot Module
Core bot class and command definitions.
Manages the bot lifecycle, background tasks, and Discord interactions.
Supports multi-guild deployments with independent server tracking per guild.
"""
import asyncio
import logging
import signal
import sys
import types
from collections import defaultdict
from datetime import datetime
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
import discord
from discord.ext import commands, tasks
from api import PanelAPI
from config import Config
from embeds import EmbedManager
from exceptions import ConfigValidationError
from metrics import ServerMetricsManager
from models import ServerData, ServerResources
if TYPE_CHECKING:
from views import ServerStatusView
logger = logging.getLogger("pterodisbot")
# Singleton instance holder
_bot_instance: Optional["PterodactylBot"] = None
def get_bot_instance() -> Optional["PterodactylBot"]:
"""Get the singleton bot instance."""
return _bot_instance
class PterodactylBot(commands.Bot):
"""
Main bot class for Pterodactyl/Pelican Panel server management.
Handles Discord interactions, embed management, and background tasks.
Manages server status embeds and user commands across multiple guilds.
Features:
- Background status update task with smart update detection per guild
- Persistent embed tracking with JSON storage per guild
- Real-time server monitoring with metrics graphs
- Role-based access control per guild
- Multi-guild support with independent server tracking
- Per-server action locking to prevent race conditions
- Graceful shutdown handling
"""
def __init__(self, config: Config, *args, **kwargs) -> None:
"""
Initialize the Pterodactyl bot instance.
Args:
config: Validated application configuration
*args: Additional arguments for commands.Bot
**kwargs: Additional keyword arguments for commands.Bot
"""
global _bot_instance
_bot_instance = self
super().__init__(*args, **kwargs)
self.config = config
self.api = PanelAPI(config)
self.metrics_manager = ServerMetricsManager()
self.embed_manager = EmbedManager(config, self.api, self.metrics_manager)
# Server cache from panel (shared across guilds, read-mostly)
self.server_cache: Dict[str, dict] = {}
self._server_cache_lock = asyncio.Lock()
# Concurrency control for background update task
self.update_lock = asyncio.Lock()
# Per-server action locks to serialize concurrent power actions across guilds
# Key: server_id, Value: asyncio.Lock
self._server_action_locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
# State tracking for smart updates per guild
# Format: {guild_id: {server_id: (state, cpu_usage, last_force_update)}}
self.previous_states: Dict[int, Dict[str, Tuple[str, float, Optional[float]]]] = defaultdict(dict)
logger.info(f"Initialized PterodactylBot instance with multi-guild support for {len(config.allowed_guild_ids)} guild(s)")
@classmethod
def get_instance(cls) -> Optional["PterodactylBot"]:
"""Get the singleton bot instance."""
return _bot_instance
def get_server_action_lock(self, server_id: str) -> asyncio.Lock:
"""
Get or create a per-server lock for serializing power actions.
This prevents race conditions when users from multiple guilds
press buttons for the same server simultaneously.
Args:
server_id: Panel server identifier
Returns:
asyncio.Lock for the specified server
"""
return self._server_action_locks[server_id]
async def trigger_embed_update_for_server(self, server_id: str) -> None:
"""
Trigger an immediate embed update for a server across all guilds tracking it.
Called after a successful power action to ensure all guilds see the state change promptly.
Bypasses smart update detection since we know the state changed.
Args:
server_id: Panel server identifier
"""
if server_id not in self.server_cache:
logger.warning(f"Cannot trigger update for unknown server {server_id}")
return
server_data = self.server_cache[server_id]
server_name = server_data["attributes"]["name"]
try:
# Fetch fresh resources
resources = await self.api.get_server_resources(server_id)
current_state = resources["attributes"]["current_state"]
cpu_usage = round(
resources["attributes"]["resources"].get("cpu_absolute", 0), 2
)
# Collect metrics for running servers
if current_state == "running":
memory_usage = round(
resources["attributes"]["resources"].get("memory_bytes", 0)
/ (1024**2),
2,
)
self.metrics_manager.add_server_data(
server_id, server_name, cpu_usage, memory_usage
)
# Convert to models for embed creation
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
current_time = datetime.now().timestamp()
# Update in all guilds that track this server
for guild_id in self.config.allowed_guild_ids:
guild_locations = self.embed_manager.get_guild_locations(guild_id)
if server_id not in guild_locations:
continue
location = guild_locations[server_id]
# Force update (bypass smart detection)
success = await self.embed_manager.update_single_embed(
guild_id=guild_id,
server_id=server_id,
location=location,
server_data=server_obj,
resources=resources_obj,
current_state=current_state,
cpu_usage=cpu_usage,
)
if success:
logger.debug(
f"Cross-guild embed update triggered for {server_name} ({server_id}) in guild {guild_id}: power action"
)
# Update state tracking for this guild
guild_states = self.previous_states[guild_id]
guild_states[server_id] = (current_state, cpu_usage, current_time)
logger.debug(f"Force-updated embed for {server_name} in guild {guild_id}")
else:
logger.warning(f"Failed to force-update embed for {server_name} in guild {guild_id}")
# Small delay between guilds to respect rate limits
await asyncio.sleep(0.3)
except Exception as e:
logger.error(f"Failed to trigger cross-guild update for server {server_id}: {str(e)}")
async def setup_hook(self) -> None:
"""
Bot setup routine called when the bot is starting.
Initializes API client, loads saved data, and starts background tasks.
"""
logger.info("Running bot setup hook")
# Initialize API client
await self.api.initialize()
logger.info(f"Initialized {self.config.get_panel_display_name()} API client")
# Load saved embed locations
await self.embed_manager.load_locations()
# Start background update task
self.update_status.start()
logger.info("Started background status update task")
@tasks.loop(seconds=10) # UPDATE_INTERVAL = 10
async def update_status(self) -> None:
"""
Background task to update server status embeds across all guilds.
For each guild, updates embeds when:
1. Server power state changes (started/stopped/restarted)
2. Significant CPU usage change (>50% difference)
3. First time seeing the server in that guild
4. Server has been running for 10 minutes (force update for uptime)
This minimizes API calls to Discord while maintaining
real-time awareness of important server changes.
"""
logger.info("Starting multi-guild server status update cycle")
async with self.update_lock:
try:
# Fetch current server list from panel (once per cycle, shared across guilds)
servers = await self.api.get_servers()
if not servers:
logger.warning("No servers found in panel during update")
return
# Update our local cache with fresh server data
async with self._server_cache_lock:
self.server_cache = {s["attributes"]["identifier"]: s for s in servers}
logger.debug(f"Updated server cache with {len(servers)} servers")
# Clean up metrics for servers that no longer exist
active_server_ids = list(self.server_cache.keys())
self.metrics_manager.cleanup_old_servers(active_server_ids)
current_time = datetime.now().timestamp()
# Process each guild's embeds
total_updated = 0
total_skipped = 0
total_missing = 0
total_errors = 0
for guild_id in self.config.allowed_guild_ids:
u, s, m, e = await self._update_guild_embeds(guild_id, current_time)
total_updated += u
total_skipped += s
total_missing += m
total_errors += e
# Log summary of this update cycle
logger.info(
f"Multi-guild update cycle complete: "
f"{total_updated} updated, "
f"{total_skipped} skipped, "
f"{total_missing} missing, "
f"{total_errors} errors across {len(self.config.allowed_guild_ids)} guild(s)"
)
except Exception as e:
logger.error(f"Error in update_status task: {str(e)}")
await asyncio.sleep(5)
async def _update_guild_embeds(
self, guild_id: int, current_time: float
) -> Tuple[int, int, int, int]:
"""
Update embeds for a specific guild.
Args:
guild_id: Discord guild ID
current_time: Current timestamp for force-update calculations
Returns:
Tuple of (updated, skipped, missing, errors) counts
"""
guild_locations = self.embed_manager.get_guild_locations(guild_id)
if not guild_locations:
return 0, 0, 0, 0
guild_prev_states = self.previous_states[guild_id]
update_count = 0
skipped_count = 0
missing_count = 0
error_count = 0
for server_id, location in list(guild_locations.items()):
# Skip if server no longer exists in panel
if server_id not in self.server_cache:
logger.warning(f"Server {server_id} not found in cache, skipping update for guild {guild_id}")
continue
server_data = self.server_cache[server_id]
server_name = server_data["attributes"]["name"]
try:
logger.debug(f"Checking status for server {server_name} ({server_id}) in guild {guild_id}")
# Get current server resource usage
resources = await self.api.get_server_resources(server_id)
current_state = resources["attributes"]["current_state"]
cpu_usage = round(
resources["attributes"]["resources"].get("cpu_absolute", 0), 2
)
# Collect metrics data for running servers (shared across guilds)
if current_state == "running":
memory_usage = round(
resources["attributes"]["resources"].get("memory_bytes", 0)
/ (1024**2),
2,
)
self.metrics_manager.add_server_data(
server_id, server_name, cpu_usage, memory_usage
)
logger.debug(
f"Added metrics data for {server_name}: "
f"CPU={cpu_usage}%, Memory={memory_usage}MB"
)
# Retrieve previous recorded state for THIS guild
prev_state, prev_cpu, last_force_update = guild_prev_states.get(
server_id, (None, 0, None)
)
# DECISION LOGIC: Should we update the embed?
needs_update, trigger_reason = self._should_update_embed(
current_state, prev_state, cpu_usage, prev_cpu,
last_force_update, current_time
)
# PERFORM UPDATE IF NEEDED
if needs_update:
logger.debug(
f"Embed update triggered for {server_name} ({server_id}) in guild {guild_id}: {trigger_reason}"
)
# Convert to models for embed creation
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
# Update the embed
success = await self.embed_manager.update_single_embed(
guild_id=guild_id,
server_id=server_id,
location=location,
server_data=server_obj,
resources=resources_obj,
current_state=current_state,
cpu_usage=cpu_usage,
)
if success:
update_count += 1
# Update state tracking for this guild
new_last_force_update = current_time if (
current_state == "running" and
(last_force_update is None or current_time - last_force_update >= 600)
) else last_force_update
guild_prev_states[server_id] = (
current_state,
cpu_usage,
new_last_force_update,
)
else:
# No significant changes, but update tracking with current state
guild_prev_states[server_id] = (
current_state,
cpu_usage,
last_force_update,
)
skipped_count += 1
logger.debug(f"No changes detected for {server_name} in guild {guild_id}, skipping update")
except discord.NotFound:
# Embed message was deleted - clean up tracking for this guild
logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking")
await self.embed_manager.untrack_embed(guild_id, server_id)
missing_count += 1
except Exception as e:
logger.error(f"Failed to update status for server {server_id} in guild {guild_id}: {str(e)}")
error_count += 1
# Small delay between servers to avoid rate limits
await asyncio.sleep(0.5)
return update_count, skipped_count, missing_count, error_count
def _should_update_embed(
self,
current_state: str,
prev_state: Optional[str],
cpu_usage: float,
prev_cpu: float,
last_force_update: Optional[float],
current_time: float,
) -> Tuple[bool, str]:
"""
Determine if an embed needs updating based on smart detection criteria.
This logic is shared between the background task and cross-guild triggers.
Args:
current_state: Current server power state
prev_state: Previously recorded state (or None)
cpu_usage: Current CPU usage percentage
prev_cpu: Previously recorded CPU usage
last_force_update: Timestamp of last forced update (or None)
current_time: Current timestamp
Returns:
Tuple of (should_update: bool, reason: str)
"""
# 1. Check if power state changed (most important)
if current_state != prev_state:
return True, f"power state changed: {prev_state} -> {current_state}"
# 2. Check for significant CPU change (only if server is running)
if current_state == "running" and abs(cpu_usage - prev_cpu) > 50:
return True, f"significant CPU change: {prev_cpu}% -> {cpu_usage}%"
# 3. First time we're seeing this server (initial update)
if prev_state is None:
return True, "first detection (initial update)"
# 4. Force update every 10 minutes for running servers (for uptime counter)
if current_state == "running" and (
last_force_update is None
or current_time - last_force_update >= 600
): # 10 minutes = 600 seconds
return True, f"10-minute force update (last: {last_force_update})"
return False, "no significant changes"
@update_status.before_loop
async def before_update_status(self) -> None:
"""Wait for bot to be ready before starting update task, then do initial refresh."""
logger.debug("Waiting for bot readiness before starting update task")
await self.wait_until_ready()
# Initial refresh for all guilds
for guild_id in self.config.allowed_guild_ids:
await self.embed_manager.refresh_all_embeds(guild_id)
@update_status.after_loop
async def after_update_status(self) -> None:
"""Handle update task stopping."""
if self.update_status.is_being_cancelled():
logger.info("Server status update task was cancelled")
elif self.update_status.failed():
logger.error("Server status update task failed")
async def close(self) -> None:
"""Cleanup when bot is shutting down."""
await self.embed_manager.save_locations()
if self.api:
await self.api.close()
await super().close()
async def check_allowed_guild(interaction: discord.Interaction, config: Config) -> bool:
"""
Verify that an interaction is coming from an allowed guild.
Args:
interaction: Discord interaction object
config: Application configuration
Returns:
bool: True if interaction is allowed, False otherwise
"""
if interaction.guild_id not in config.allowed_guild_ids:
logger.warning(
f"Command attempted from unauthorized guild {interaction.guild_id} "
f"by {interaction.user.name}"
)
await interaction.response.send_message(
"This bot is not available in this server.", ephemeral=True
)
return False
return True
async def setup_commands(bot: PterodactylBot) -> None:
"""
Set up all slash commands and event handlers for the bot.
Args:
bot: The bot instance
"""
config = bot.config
# ==============================================
# EVENT HANDLERS
# ==============================================
@bot.event
async def on_interaction(interaction: discord.Interaction) -> None:
"""Global interaction handler to check guild before processing any interaction."""
if interaction.guild_id not in config.allowed_guild_ids:
logger.debug(f"Ignoring interaction from unauthorized guild {interaction.guild_id}")
await interaction.response.send_message(
"This bot is not available in this server.", ephemeral=True
)
return
@bot.event
async def on_ready() -> None:
"""Called when the bot successfully connects to Discord."""
logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})")
try:
# Sync commands to ALL allowed guilds
for guild_id in bot.config.allowed_guild_ids:
guild = discord.Object(id=guild_id)
bot.tree.copy_global_to(guild=guild)
synced = await bot.tree.sync(guild=guild)
logger.info(
f"Successfully synced {len(synced)} command(s) to guild {guild_id}: "
f"{[cmd.name for cmd in synced]}"
)
except Exception as e:
logger.error(f"Command sync failed: {str(e)}")
# ==============================================
# SLASH COMMANDS
# ==============================================
@bot.tree.command(
name="server_status", description="Get a list of available game servers to control"
)
async def server_status(interaction: discord.Interaction) -> None:
"""
Slash command to display server status dashboard with interactive dropdown selection.
Workflow:
1. Validates guild permissions and defers ephemeral response
2. Refreshes server cache from Panel API
3. Calculates online/offline statistics by checking each server's state
4. Creates statistics embed with visual server status breakdown
5. Generates dropdown menu with all servers (name + description)
6. Handles user selection via ephemeral dropdown interaction
7. Creates permanent status embed in channel upon selection
8. Manages embed tracking and cleanup of previous embeds (per guild)
"""
if not await check_allowed_guild(interaction, config):
return
guild_id = interaction.guild_id
logger.info(f"Server status command invoked by {interaction.user.name} in guild {guild_id}")
await interaction.response.defer(ephemeral=True)
try:
# Refresh server cache with current data from panel
servers = await bot.api.get_servers()
if not servers:
logger.warning("No servers found in panel")
await interaction.followup.send(
"No servers found in the panel.", ephemeral=True
)
return
async with bot._server_cache_lock:
bot.server_cache = {s["attributes"]["identifier"]: s for s in servers}
logger.debug(f"Refreshed server cache with {len(servers)} servers")
# Count online/offline servers
online_count = 0
offline_count = 0
for server_id, server_data in bot.server_cache.items():
resources = await bot.api.get_server_resources(server_id)
current_state = resources.get("attributes", {}).get("current_state", "offline")
if current_state == "running":
online_count += 1
else:
offline_count += 1
# Create statistics embed
stats_embed = discord.Embed(
title="🏗️ Server Status Dashboard",
description="Select a server from the dropdown below to view its detailed status and controls.",
color=discord.Color.blue(),
timestamp=datetime.now(),
)
stats_embed.add_field(
name="📊 Server Statistics",
value=f"**Total Servers:** {len(servers)}\n"
f"✅ **Online:** {online_count}\n"
f"❌ **Offline:** {offline_count}",
inline=False,
)
stats_embed.add_field(
name="ℹ️ How to Use",
value="Use the dropdown menu below to select a server. "
"The server's status embed will be posted in this channel.",
inline=False,
)
stats_embed.set_footer(text="Server status will update automatically")
# Create dropdown menu options
from views import ServerDropdown, ServerDropdownView
server_options = []
for server_id, server_data in bot.server_cache.items():
server_name = server_data["attributes"]["name"]
server_description = server_data["attributes"].get("description", "No description")
# Truncate description if too long for dropdown constraints
if len(server_description) > 50:
server_description = server_description[:47] + "..."
server_options.append(
discord.SelectOption(
label=server_name, value=server_id, description=server_description
)
)
# Send the initial dashboard embed with dropdown (ephemeral)
await interaction.followup.send(
embed=stats_embed,
view=ServerDropdownView(server_options),
ephemeral=True,
)
logger.info(f"Sent server status dashboard to {interaction.user.name} in guild {guild_id} with {len(server_options)} servers")
except Exception as e:
logger.error(f"Server status command failed: {str(e)}")
await interaction.followup.send(
f"❌ Failed to load server status: {str(e)}", ephemeral=True
)
@bot.tree.command(
name="refresh_embeds", description="Refresh all server status embeds in this guild (admin only)"
)
async def refresh_embeds(interaction: discord.Interaction) -> None:
"""Slash command to refresh all server embeds in the current guild."""
if not await check_allowed_guild(interaction, config):
return
guild_id = interaction.guild_id
logger.info(f"Refresh embeds command invoked by {interaction.user.name} in guild {guild_id}")
await interaction.response.defer(ephemeral=True)
# Require administrator permissions
if not interaction.user.guild_permissions.administrator:
logger.warning(f"Unauthorized refresh attempt by {interaction.user.name} in guild {guild_id}")
await interaction.followup.send(
"You need administrator permissions to refresh all embeds.", ephemeral=True
)
return
try:
logger.info(f"Starting full embed refresh per admin request for guild {guild_id}")
deleted, created = await bot.embed_manager.refresh_all_embeds(guild_id)
await interaction.followup.send(
f"Refreshed all embeds in this guild. Deleted {deleted} old embeds, created {created} new ones.",
ephemeral=True,
)
logger.info(f"Embed refresh completed for guild {guild_id}: {deleted} deleted, {created} created")
except Exception as e:
logger.error(f"Embed refresh failed for guild {guild_id}: {str(e)}")
await interaction.followup.send(f"Failed to refresh embeds: {str(e)}", ephemeral=True)
@bot.tree.command(
name="purge_embeds",
description="Permanently delete all server status embeds in this guild (admin only)",
)
async def purge_embeds(interaction: discord.Interaction) -> None:
"""
Slash command to permanently purge all server status embeds from the current guild.
Workflow:
1. Validates administrator permissions
2. Checks if any embeds are currently tracked in this guild
3. Sends initial progress embed
4. Processes each embed sequentially with error handling
5. Updates progress embed in real-time
6. Saves cleared tracking data to disk
7. Sends final results with comprehensive statistics
"""
if not await check_allowed_guild(interaction, config):
return
guild_id = interaction.guild_id
logger.info(f"Purge embeds command invoked by {interaction.user.name} in guild {guild_id}")
await interaction.response.defer(ephemeral=True)
# Require administrator permissions
if not interaction.user.guild_permissions.administrator:
logger.warning(f"Unauthorized purge attempt by {interaction.user.name} in guild {guild_id}")
await interaction.followup.send(
"You need administrator permissions to purge all embeds.", ephemeral=True
)
return
try:
logger.info(f"Starting embed purge per admin request for guild {guild_id}")
guild_locations = bot.embed_manager.get_guild_locations(guild_id)
total_embeds = len(guild_locations)
if total_embeds == 0:
await interaction.followup.send(
"No embeds are currently being tracked in this guild. Nothing to purge.", ephemeral=True
)
return
deleted_count = 0
not_found_count = 0
error_count = 0
# Create progress embed
progress_embed = discord.Embed(
title="🔄 Purging Server Embeds",
description=f"Processing {total_embeds} embeds in this guild...",
color=discord.Color.orange(),
)
progress_embed.add_field(name="Deleted", value="0", inline=True)
progress_embed.add_field(name="Not Found", value="0", inline=True)
progress_embed.add_field(name="Errors", value="0", inline=True)
progress_embed.set_footer(text="This may take a while...")
progress_message = await interaction.followup.send(embed=progress_embed, ephemeral=True)
# Process each tracked embed in this guild
for server_id, location in list(guild_locations.items()):
try:
channel = bot.get_channel(int(location["channel_id"]))
if channel:
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {location['channel_id']} not in guild {guild_id}, skipping")
not_found_count += 1
else:
try:
message = await channel.fetch_message(int(location["message_id"]))
await message.delete()
deleted_count += 1
logger.debug(f"Successfully purged embed for server {server_id} in guild {guild_id}")
except discord.NotFound:
not_found_count += 1
logger.debug(f"Embed for server {server_id} in guild {guild_id} already deleted")
except discord.Forbidden:
error_count += 1
logger.error(f"Permission denied when deleting embed for server {server_id} in guild {guild_id}")
except Exception as e:
error_count += 1
logger.error(f"Error deleting embed for server {server_id} in guild {guild_id}: {str(e)}")
else:
not_found_count += 1
logger.warning(f"Channel not found for server {server_id} in guild {guild_id}")
# Remove from tracking immediately
await bot.embed_manager.untrack_embed(guild_id, server_id)
# Update progress every 5 embeds or for the last one
processed = deleted_count + not_found_count + error_count
if processed % 5 == 0 or processed == total_embeds:
progress_embed.description = f"Processed {processed}/{total_embeds} embeds"
progress_embed.set_field_at(0, name="Deleted", value=str(deleted_count), inline=True)
progress_embed.set_field_at(1, name="Not Found", value=str(not_found_count), inline=True)
progress_embed.set_field_at(2, name="Errors", value=str(error_count), inline=True)
await progress_message.edit(embed=progress_embed)
# Small delay to avoid rate limits
await asyncio.sleep(0.3)
except Exception as e:
error_count += 1
logger.error(f"Unexpected error processing server {server_id} in guild {guild_id}: {str(e)}")
# Create results embed
result_embed = discord.Embed(
title="✅ Embed Purge Complete",
color=discord.Color.green(),
timestamp=datetime.now(),
)
result_embed.add_field(name="Total Tracked", value=str(total_embeds), inline=True)
result_embed.add_field(name="✅ Successfully Deleted", value=str(deleted_count), inline=True)
result_embed.add_field(name="❌ Already Missing", value=str(not_found_count), inline=True)
result_embed.add_field(name="⚠️ Errors", value=str(error_count), inline=True)
result_embed.add_field(
name="📊 Success Rate",
value=f"{((deleted_count + not_found_count) / total_embeds * 100):.1f}%",
inline=True,
)
result_embed.set_footer(text="Embed tracking for this guild has been cleared")
await progress_message.edit(embed=result_embed)
logger.info(f"Embed purge completed for guild {guild_id}: {deleted_count} deleted, {not_found_count} not found, {error_count} errors")
except Exception as e:
logger.error(f"Embed purge failed for guild {guild_id}: {str(e)}")
await interaction.followup.send(f"❌ Failed to purge embeds: {str(e)}", ephemeral=True)
# ==============================================
# SYSTEM SIGNAL HANDLERS
# ==============================================
def handle_sigint(signum: int, frame: types.FrameType) -> None:
"""
Handle SIGINT signals (Ctrl+C) by initiating graceful shutdown.
Args:
signum: The signal number (signal.SIGINT)
frame: Current stack frame (unused but required by signal handler signature)
"""
logger.info("Received SIGINT (Ctrl+C), initiating graceful shutdown...")
raise KeyboardInterrupt
def handle_sigterm(signum: int, frame: types.FrameType) -> None:
"""
Handle SIGTERM signals (container stop) by initiating graceful shutdown.
Args:
signum: The signal number (signal.SIGTERM)
frame: Current stack frame (unused but required by signal handler signature)
"""
logger.info("Received SIGTERM (container stop), initiating graceful shutdown...")
raise KeyboardInterrupt
# ==============================================
# BOT STARTUP
# ==============================================
async def run_bot(config: Config) -> None:
"""
Main async entry point for the bot application.
Args:
config: Validated application configuration
"""
logger.info("Starting bot initialization")
# Create bot instance
intents = discord.Intents.default()
intents.message_content = True
bot = PterodactylBot(config, command_prefix="!", intents=intents)
# Set up commands and events
await setup_commands(bot)
# Register signal handlers
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
logger.info("System signal handlers registered")
try:
await bot.start(config.discord_token)
except KeyboardInterrupt:
logger.info("Bot shutting down")
except Exception as e:
logger.error(f"Bot crashed: {str(e)}")
sys.exit(1)
finally:
logger.info("Bot shutdown initiated - performing cleanup")
await bot.close()
logger.info("Bot shutdown complete")
+1 -1
View File
@@ -5,4 +5,4 @@ ApplicationAPIKey = ptla_yourpterodactylapplicationapikey
[Discord] [Discord]
Token = yourdiscordbottoken Token = yourdiscordbottoken
AllowedGuildID = yourdiscordguildid AllowedGuildIDs = yourdiscordguildid1,yourdiscordguildid2
+244
View File
@@ -0,0 +1,244 @@
"""
Configuration Management Module
Handles loading, validation, and access to application configuration.
Supports both config.ini files and environment variable overrides.
"""
import configparser
import logging
import os
from pathlib import Path
from typing import List, Optional
from exceptions import ConfigValidationError
logger = logging.getLogger("pterodisbot")
class Config:
"""
Application configuration manager.
Loads configuration from config.ini file, validates all required values,
and provides typed access to configuration parameters.
Supports both Pterodactyl (ptlc_/ptla_) and Pelican Panel (pacc_/papp_)
API key formats.
Attributes:
pterodactyl_url: URL of the panel (must include http:// or https://)
client_api_key: Client API key for server operations
application_api_key: Application API key for admin operations
discord_token: Discord bot token
allowed_guild_ids: List of Discord server IDs where bot operates
allowed_guild_id: First guild ID (backward compatibility)
required_role: Role name required for server control permissions
update_interval: Background task update interval in seconds
embed_storage_path: Path to embed locations JSON file
"""
def __init__(self, config_path: str = "config.ini") -> None:
"""
Initialize configuration from file.
Args:
config_path: Path to config.ini file
Raises:
ConfigValidationError: If required values are missing or invalid
"""
self._config = configparser.ConfigParser()
self._config_path = Path(config_path)
if not self._config_path.exists():
raise ConfigValidationError(f"Configuration file not found: {config_path}")
self._config.read(self._config_path)
self._validate()
# Validated configuration values
self.pterodactyl_url: str = self._config.get("Pterodactyl", "PanelURL").rstrip("/")
self.client_api_key: str = self._config.get("Pterodactyl", "ClientAPIKey")
self.application_api_key: str = self._config.get("Pterodactyl", "ApplicationAPIKey")
self.discord_token: str = self._config.get("Discord", "Token")
self.allowed_guild_ids: List[int] = self._parse_allowed_guild_ids()
# Backward compatibility: first guild ID
self.allowed_guild_id: int = self.allowed_guild_ids[0] if self.allowed_guild_ids else 0
self.required_role: str = "Game Server User"
self.update_interval: int = 10
self.embed_storage_path: Path = Path("./embed/embed_locations.json")
logger.info("Configuration loaded and validated successfully")
def _validate(self) -> None:
"""
Validate all required configuration values.
Checks:
- Required sections exist
- All required keys are present
- AllowedGuildID is a positive integer
- API keys have correct prefixes (ptlc_/pacc_ and ptla_/papp_)
- PanelURL has valid protocol
Raises:
ConfigValidationError: If any validation fails
"""
errors = []
# Validate Pterodactyl section
if not self._config.has_section("Pterodactyl"):
errors.append("Missing [Pterodactyl] section in config.ini")
else:
required_ptero = ["PanelURL", "ClientAPIKey", "ApplicationAPIKey"]
for key in required_ptero:
if not self._config.get("Pterodactyl", key, fallback=None):
errors.append(f"Missing required Pterodactyl config value: {key}")
# Validate Discord section
if not self._config.has_section("Discord"):
errors.append("Missing [Discord] section in config.ini")
else:
required_discord = ["Token"] # Guild ID validated separately below
for key in required_discord:
if not self._config.get("Discord", key, fallback=None):
errors.append(f"Missing required Discord config value: {key}")
# Validate AllowedGuildIDs (comma-separated list)
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="")
# Backward compatibility: also check for singular AllowedGuildID
if not allowed_guild_ids_str:
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="")
if not allowed_guild_ids_str:
errors.append("Missing required Discord config value: AllowedGuildIDs (or AllowedGuildID)")
else:
try:
guild_ids = [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()]
if not guild_ids:
errors.append("AllowedGuildIDs must contain at least one guild ID")
for gid in guild_ids:
if gid <= 0:
errors.append(f"Invalid guild ID: {gid} (must be positive integer)")
except ValueError:
errors.append("AllowedGuildIDs must be comma-separated integers")
# Validate API keys have correct prefixes (Pterodactyl and Pelican Panel)
client_key = self._config.get("Pterodactyl", "ClientAPIKey", fallback="")
if client_key and not (client_key.startswith("ptlc_") or client_key.startswith("pacc_")):
errors.append("ClientAPIKey should start with 'ptlc_' (Pterodactyl) or 'pacc_' (Pelican Panel)")
app_key = self._config.get("Pterodactyl", "ApplicationAPIKey", fallback="")
if app_key and not (app_key.startswith("ptla_") or app_key.startswith("papp_")):
errors.append("ApplicationAPIKey should start with 'ptla_' (Pterodactyl) or 'papp_' (Pelican Panel)")
# Validate PanelURL is a valid URL
panel_url = self._config.get("Pterodactyl", "PanelURL", fallback="")
if panel_url and not (panel_url.startswith("http://") or panel_url.startswith("https://")):
errors.append("PanelURL must start with http:// or https://")
if errors:
error_msg = "Configuration validation failed:\n- " + "\n- ".join(errors)
logger.error(error_msg)
raise ConfigValidationError(error_msg)
logger.info("Configuration validation passed")
@property
def is_pelican_panel(self) -> bool:
"""Check if using Pelican Panel API keys (pacc_/papp_ prefixes)."""
return (
self.client_api_key.startswith("pacc_")
or self.application_api_key.startswith("papp_")
)
def _parse_allowed_guild_ids(self) -> List[int]:
"""Parse comma-separated guild IDs from config.
Supports both new 'AllowedGuildIDs' and legacy 'AllowedGuildID' keys.
Returns:
List of guild IDs as integers
"""
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="")
if not allowed_guild_ids_str:
# Backward compatibility
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="")
if not allowed_guild_ids_str:
return []
return [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()]
@property
def panel_type(self) -> str:
"""Return the detected panel type."""
return "Pelican Panel" if self.is_pelican_panel else "Pterodactyl"
def get_panel_display_name(self) -> str:
"""Get display name for the panel type."""
return "Pelican Panel" if self.is_pelican_panel else "Pterodactyl"
@classmethod
def get_instance(cls) -> Optional["Config"]:
"""Get the singleton config instance."""
return cls._instance
# Singleton instance for access from views
Config._instance = None
def load_config(config_path: str = "config.ini") -> Config:
"""
Load and validate configuration from file.
Args:
config_path: Path to config.ini file
Returns:
Config: Validated configuration instance
Raises:
ConfigValidationError: If configuration is invalid
"""
config = Config(config_path)
Config._instance = config
return config
def generate_config_from_env(output_path: str = "config.ini") -> None:
"""
Generate config.ini from environment variables.
Useful for Docker/container deployments where config comes from env vars.
Required environment variables:
DISCORD_TOKEN, ALLOWED_GUILD_IDS (or ALLOWED_GUILD_ID), PANEL_URL,
CLIENT_API_KEY, APPLICATION_API_KEY
Args:
output_path: Path to write generated config.ini
"""
config = configparser.ConfigParser()
config["Pterodactyl"] = {
"PanelURL": os.environ.get("PANEL_URL", ""),
"ClientAPIKey": os.environ.get("CLIENT_API_KEY", ""),
"ApplicationAPIKey": os.environ.get("APPLICATION_API_KEY", ""),
}
# Support both new ALLOWED_GUILD_IDS and legacy ALLOWED_GUILD_ID
allowed_guild_ids = os.environ.get("ALLOWED_GUILD_IDS", "")
if not allowed_guild_ids:
allowed_guild_ids = os.environ.get("ALLOWED_GUILD_ID", "")
config["Discord"] = {
"Token": os.environ.get("DISCORD_TOKEN", ""),
"AllowedGuildIDs": allowed_guild_ids,
}
with open(output_path, "w") as f:
config.write(f)
logger.info(f"Generated config.ini from environment variables at {output_path}")
+13 -7
View File
@@ -1,5 +1,5 @@
# Builder stage using slim Python image # Builder stage using slim Python image
FROM python:3.11-slim as builder FROM python:3.12-slim as builder
# Install system dependencies for building # Install system dependencies for building
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@@ -18,13 +18,13 @@ COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt pip install --no-cache-dir -r requirements.txt
# Final stage - using smaller base image # Final stage - using slim
FROM python:3.11-alpine3.18 FROM python:3.12-slim
# Install minimal runtime dependencies # Install minimal runtime dependencies
RUN apk add --no-cache \ RUN apt-get update && apt-get install -y --no-install-recommends \
tini \ tini \
&& rm -rf /var/cache/apk/* && rm -rf /var/lib/apt/lists/*
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -34,18 +34,22 @@ COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" ENV PATH="/opt/venv/bin:$PATH"
# Create a non-root user # Create a non-root user
RUN addgroup -S bot && adduser -S bot -G bot RUN groupadd -r bot && useradd -r -g bot bot
# Copy necessary files # Copy necessary files
COPY --chown=bot:bot *.py ./ COPY --chown=bot:bot *.py ./
COPY --chown=bot:bot entrypoint.sh ./ COPY --chown=bot:bot entrypoint.sh ./
# Add other necessary directories/files as needed
# Create directories for persistent storage # Create directories for persistent storage
RUN mkdir -p logs embed && \ RUN mkdir -p logs embed && \
chown -R bot:bot /app logs embed && \ chown -R bot:bot /app logs embed && \
chmod -R 777 /app logs embed chmod -R 777 /app logs embed
# Create and set permissions for matplotlib config directory
RUN mkdir -p /tmp/matplotlib && \
chown -R bot:bot /tmp/matplotlib && \
chmod -R 777 /tmp/matplotlib
# Switch to non root user # Switch to non root user
USER bot USER bot
@@ -54,6 +58,8 @@ ENV PYTHONUNBUFFERED=1
ENV CONFIG_PATH=/app/config.ini ENV CONFIG_PATH=/app/config.ini
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONPYCACHEPREFIX=/tmp ENV PYTHONPYCACHEPREFIX=/tmp
ENV MPLCONFIGDIR=/tmp/matplotlib
ENV MPLBACKEND=Agg
# Run the bot using tini and entrypoint script # Run the bot using tini and entrypoint script
ENTRYPOINT ["tini", "--", "/bin/sh", "entrypoint.sh"] ENTRYPOINT ["tini", "--", "/bin/sh", "entrypoint.sh"]
+523
View File
@@ -0,0 +1,523 @@
"""
Embed Management Module
Handles creation, tracking, and persistence of Discord server status embeds.
Manages embed locations storage and provides utilities for embed lifecycle.
Supports multi-guild deployments where each guild tracks servers independently.
"""
import json
import logging
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import discord
from api import PanelAPI
from config import Config
from exceptions import EmbedNotFoundError
from metrics import ServerMetricsManager
from models import ServerData, ServerResources
logger = logging.getLogger("pterodisbot")
class EmbedManager:
"""
Manages server status embeds lifecycle across multiple guilds.
Handles:
- Embed creation with real-time server data
- Persistent storage of embed locations per guild (guild_id -> server_id -> location)
- Embed refresh and recreation per guild
- Cleanup of deleted/missing embeds
- Rate limit protection during bulk operations
- Migration from legacy single-guild storage format
Attributes:
config: Application configuration
api: Panel API client
metrics_manager: Metrics manager for graphs
storage_path: Path to embed locations JSON file
"""
def __init__(
self,
config: Config,
api: PanelAPI,
metrics_manager: ServerMetricsManager,
) -> None:
"""
Initialize the embed manager.
Args:
config: Validated application configuration
api: Initialized PanelAPI instance
metrics_manager: ServerMetricsManager instance
"""
self.config = config
self.api = api
self.metrics_manager = metrics_manager
self.storage_path = config.embed_storage_path
# Structure: {guild_id: {server_id: {"channel_id": str, "message_id": str}}}
self.embed_locations: Dict[int, Dict[str, Dict[str, str]]] = defaultdict(dict)
# Previous states tracking moved to bot.py for multi-guild support
# Ensure storage directory exists
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
logger.info("Initialized EmbedManager with multi-guild support")
async def load_locations(self) -> None:
"""Load saved embed locations from JSON storage file with migration support."""
logger.debug("Loading embed locations from storage")
if not self.storage_path.exists():
logger.info("No existing embed locations file found")
return
try:
with open(self.storage_path, "r") as f:
data = json.load(f)
# Detect format:
# New format: {guild_id: {server_id: location}} where values are dicts with channel_id/message_id
# Old format: {server_id: location} where location has channel_id/message_id directly
is_new_format = False
if data:
first_key = next(iter(data.keys()))
first_value = data[first_key]
# New format: value is a dict of server locations
# Old format: value is a location dict with channel_id/message_id
if isinstance(first_value, dict) and "channel_id" not in first_value:
# Value is {server_id: {channel_id, message_id}} - new format
is_new_format = True
elif isinstance(first_value, dict) and "channel_id" in first_value:
# Value is {channel_id, message_id} - old format
is_new_format = False
# If first_key is numeric and >= 17 digits, also treat as new format
elif first_key.isdigit() and len(first_key) >= 17:
is_new_format = True
if is_new_format:
# New format: {guild_id: {server_id: location}}
self.embed_locations = defaultdict(dict)
for guild_id_str, servers in data.items():
guild_id = int(guild_id_str)
self.embed_locations[guild_id] = servers
total_embeds = sum(len(s) for s in self.embed_locations.values())
logger.info(f"Loaded embed locations for {len(self.embed_locations)} guilds ({total_embeds} total embeds)")
else:
# Legacy format: {server_id: location} - migrate to first allowed guild
legacy_guild_id = self.config.allowed_guild_ids[0] if self.config.allowed_guild_ids else 0
if legacy_guild_id:
self.embed_locations = defaultdict(dict)
self.embed_locations[legacy_guild_id] = data
logger.info(f"Migrated legacy embed locations to guild {legacy_guild_id}")
# Save in new format immediately
await self.save_locations()
else:
logger.warning("No allowed guilds configured, cannot migrate legacy embed locations")
except Exception as e:
logger.error(f"Failed to load embed locations: {str(e)}")
async def save_locations(self) -> None:
"""Save current embed locations to JSON storage file."""
logger.debug("Saving embed locations to storage")
try:
# Convert defaultdict to regular dict for JSON serialization
# Guild IDs as strings for JSON compatibility
data = {str(gid): servers for gid, servers in self.embed_locations.items()}
with open(self.storage_path, "w") as f:
json.dump(data, f, indent=2)
logger.debug("Successfully saved embed locations")
except Exception as e:
logger.error(f"Failed to save embed locations: {str(e)}")
def get_guild_locations(self, guild_id: int) -> Dict[str, Dict[str, str]]:
"""
Get all tracked embeds for a specific guild.
Args:
guild_id: Discord guild ID
Returns:
Dictionary mapping server_id to location info
"""
return self.embed_locations.get(guild_id, {})
def get_all_tracked_servers(self) -> List[Tuple[int, str]]:
"""
Get all (guild_id, server_id) pairs currently being tracked.
Returns:
List of (guild_id, server_id) tuples
"""
result = []
for guild_id, servers in self.embed_locations.items():
for server_id in servers:
result.append((guild_id, server_id))
return result
async def track_embed(self, guild_id: int, server_id: str, message: discord.Message) -> None:
"""
Track a newly created embed in storage for a specific guild.
Args:
guild_id: Discord guild ID
server_id: The server's panel identifier
message: Discord message containing the embed
"""
logger.debug(f"Tracking new embed for server {server_id} in guild {guild_id}, channel {message.channel.id}")
self.embed_locations[guild_id][server_id] = {
"channel_id": str(message.channel.id),
"message_id": str(message.id),
}
await self.save_locations()
async def untrack_embed(self, guild_id: int, server_id: str) -> None:
"""
Remove a server's embed from tracking for a specific guild.
Args:
guild_id: Discord guild ID
server_id: The server's panel identifier
"""
self.embed_locations[guild_id].pop(server_id, None)
# Clean up empty guild entries
if not self.embed_locations[guild_id]:
del self.embed_locations[guild_id]
await self.save_locations()
async def create_server_embed(
self, server_data: ServerData, resources: ServerResources, guild_id: int
) -> Tuple[discord.Embed, discord.ui.View]:
"""
Create a status embed and interactive view for a server.
Args:
server_data: Server information from panel
resources: Current resource usage data
guild_id: Discord guild ID this embed belongs to
Returns:
Tuple of (embed, view) ready for display
"""
attributes = server_data.attributes
identifier = attributes.identifier
name = attributes.name
description = attributes.description or "No description available"
logger.debug(f"Building status embed for server {name} ({identifier}) in guild {guild_id}")
# Parse resource data
current_state = resources.attributes.current_state.title()
is_suspended = attributes.suspended
# Create embed with appropriate color based on status
embed = discord.Embed(
title=f"{name} - {current_state}",
description=description,
color=(
discord.Color.blue()
if current_state.lower() == "running"
else discord.Color.red()
),
timestamp=datetime.now(),
)
embed.add_field(name="🆔 Server ID", value=f"`{identifier}`", inline=True)
if is_suspended:
embed.add_field(name="ℹ️ Status", value="⛔ `Suspended`", inline=True)
else:
embed.add_field(name="ℹ️ Status", value="✅ `Active`", inline=True)
# Add resource usage if server is running
if current_state.lower() != "offline":
embed = self._add_resource_fields(embed, resources, attributes)
embed = self._add_graph_fields(embed, identifier)
embed.set_footer(text="Last updated")
# Create interactive view with control buttons (pass guild_id for validation)
from views import ServerStatusView
view = ServerStatusView(
server_id=identifier,
server_name=name,
api=self.api,
server_data=server_data.model_dump(),
guild_id=guild_id,
)
logger.debug(f"Successfully built status components for {name} in guild {guild_id}")
return embed, view
def _add_resource_fields(
self, embed: discord.Embed, resources: ServerResources, attributes: Any
) -> discord.Embed:
"""Add resource usage fields to embed."""
resource_attrs = resources.attributes.resources
limits = attributes.limits
# Current usage
cpu_usage = round(resource_attrs.cpu_absolute, 2)
memory_usage = round(resource_attrs.memory_bytes / (1024**2), 2)
disk_usage = round(resource_attrs.disk_bytes / (1024**2), 2)
network_rx = round(resource_attrs.network_rx_bytes / (1024**2), 2)
network_tx = round(resource_attrs.network_tx_bytes / (1024**2), 2)
# Maximum allocated resources
cpu_limit = limits.cpu
memory_limit = limits.memory
disk_limit = limits.disk
# Format limit values - display ∞ for unlimited (0 limit)
def format_limit(value: int) -> str:
if value == 0:
return f"{'∞':<8}"
return f"{value:<8}"
# Format uptime
uptime_ms = resource_attrs.uptime
if uptime_ms > 0:
uptime_seconds = uptime_ms // 1000
if uptime_seconds < 60:
uptime_text = f"`{uptime_seconds}s`"
elif uptime_seconds < 3600:
uptime_text = f"`{uptime_seconds // 60}m {uptime_seconds % 60}s`"
elif uptime_seconds < 86400:
hours = uptime_seconds // 3600
minutes = (uptime_seconds % 3600) // 60
uptime_text = f"`{hours}h {minutes}m`"
else:
days = uptime_seconds // 86400
hours = (uptime_seconds % 86400) // 3600
uptime_text = f"`{days}d {hours}h`"
else:
uptime_text = "`Just started`"
embed.add_field(name="⏱️ Uptime", value=uptime_text, inline=True)
# Resource usage text box
usage_text = (
f"```properties\n"
f"CPU : [{cpu_usage:>8} / {format_limit(cpu_limit)}] %\n"
f"Memory : [{memory_usage:>8} / {format_limit(memory_limit)}] MiB\n"
f"Disk : [{disk_usage:>8} / {format_limit(disk_limit)}] MiB\n"
f"```"
)
embed.add_field(name="📊 Resource Usage", value=usage_text, inline=False)
embed.add_field(name="Network In", value=f"📥 `{network_rx} MiB`", inline=True)
embed.add_field(name="Network Out", value=f"📤 `{network_tx} MiB`", inline=True)
return embed
def _add_graph_fields(
self, embed: discord.Embed, server_id: str
) -> discord.Embed:
"""Add metrics graph fields to embed if data available."""
server_graphs = self.metrics_manager.get_server_graphs(server_id)
if server_graphs and server_graphs.has_sufficient_data:
summary = server_graphs.get_data_summary()
graph_description = (
f">>> `Data points: {summary['point_count']}/6`\n"
f"`CPU trend: {summary['cpu_trend']} • Memory trend: {summary['memory_trend']}`"
)
embed.add_field(
name="📈 Usage Trends (Last Minute)",
value=graph_description,
inline=False,
)
embed.set_image(url=f"attachment://metrics_graph_{server_id}.png")
return embed
async def refresh_all_embeds(self, guild_id: int) -> Tuple[int, int]:
"""
Perform a complete refresh of all server status embeds for a specific guild.
Creates new embeds and deletes old ones to prevent duplication.
Args:
guild_id: Discord guild ID to refresh embeds for
Returns:
Tuple of (deleted_count, created_count)
"""
logger.info(f"Starting full refresh of all server embeds for guild {guild_id}")
from bot import PterodactylBot # Avoid circular import
bot = PterodactylBot.get_instance()
deleted_count = 0
created_count = 0
skipped_count = 0
guild_locations = self.get_guild_locations(guild_id)
if not guild_locations:
logger.info(f"No embeds tracked for guild {guild_id}, nothing to refresh")
return 0, 0
try:
# Get current server list
servers = await self.api.get_servers()
if not servers:
logger.warning("No servers found in panel during refresh")
return 0, 0
server_cache = {s["attributes"]["identifier"]: s for s in servers}
# Create new embeds
new_embeds = {}
for server_id, server_data in server_cache.items():
if server_id not in guild_locations:
skipped_count += 1
continue
channel_id = guild_locations[server_id]["channel_id"]
channel = bot.get_channel(int(channel_id))
if not channel:
logger.warning(f"Channel {channel_id} not found for server {server_id} in guild {guild_id}")
continue
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {channel_id} not in guild {guild_id}, skipping")
continue
try:
resources = await self.api.get_server_resources(server_id)
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
embed, view = await self.create_server_embed(server_obj, resources_obj, guild_id)
message = await channel.send(embed=embed, view=view)
new_embeds[server_id] = {
"channel_id": str(channel.id),
"message_id": str(message.id),
}
created_count += 1
logger.info(f"Created new embed for server {server_data['attributes']['name']} in guild {guild_id}")
await asyncio.sleep(1) # Rate limit protection
except Exception as e:
logger.error(f"Failed to create new embed for server {server_id} in guild {guild_id}: {str(e)}")
if not new_embeds:
logger.warning(f"No new embeds created during refresh for guild {guild_id}")
return 0, 0
# Delete old embeds
for server_id, location in list(guild_locations.items()):
try:
channel = bot.get_channel(int(location["channel_id"]))
if channel:
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {location['channel_id']} not in guild {guild_id}, skipping delete")
continue
try:
message = await channel.fetch_message(int(location["message_id"]))
await message.delete()
deleted_count += 1
logger.debug(f"Deleted old embed for server {server_id} in guild {guild_id}")
await asyncio.sleep(0.5)
except discord.NotFound:
logger.debug(f"Old embed for server {server_id} in guild {guild_id} already deleted")
except Exception as e:
logger.error(f"Failed to delete old embed for server {server_id} in guild {guild_id}: {str(e)}")
except Exception as e:
logger.error(f"Error processing old embed for server {server_id} in guild {guild_id}: {str(e)}")
logger.info(f"Guild {guild_id}: Deleted {deleted_count} old embeds, created {created_count} new embeds")
# Update storage for this guild only
self.embed_locations[guild_id] = new_embeds
await self.save_locations()
return deleted_count, created_count
except Exception as e:
logger.error(f"Critical error during embed refresh for guild {guild_id}: {str(e)}")
raise
async def update_single_embed(
self,
guild_id: int,
server_id: str,
location: Dict[str, str],
server_data: ServerData,
resources: ServerResources,
current_state: str,
cpu_usage: float,
) -> bool:
"""
Update a single server's status embed for a specific guild.
Args:
guild_id: Discord guild ID
server_id: Server identifier
location: Embed location dict with channel_id and message_id
server_data: Current server data
resources: Current resource data
current_state: Current power state
cpu_usage: Current CPU usage
Returns:
True if embed was updated, False otherwise
"""
from bot import PterodactylBot
bot = PterodactylBot.get_instance()
try:
embed, view = await self.create_server_embed(server_data, resources, guild_id)
channel = bot.get_channel(int(location["channel_id"]))
if not channel:
logger.warning(f"Channel {location['channel_id']} not found for server {server_id} in guild {guild_id}")
return False
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {location['channel_id']} not in guild {guild_id}")
return False
message = await channel.fetch_message(int(location["message_id"]))
# Handle graph attachments
files = []
server_graphs = self.metrics_manager.get_server_graphs(server_id)
if current_state == "running" and server_graphs and server_graphs.has_sufficient_data:
combined_graph = server_graphs.generate_combined_graph()
if combined_graph:
files.append(
discord.File(
combined_graph,
filename=f"metrics_graph_{server_id}.png",
)
)
# Empty files list removes existing attachments
await message.edit(embed=embed, view=view, attachments=files)
logger.debug(f"Updated status for {server_data.attributes.name} in guild {guild_id}")
return True
except discord.NotFound:
logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking")
await self.untrack_embed(guild_id, server_id)
return False
except Exception as e:
logger.error(f"Failed to update status for server {server_id} in guild {guild_id}: {str(e)}")
return False
# Import at bottom to avoid circular imports
import asyncio
Regular → Executable
+1 -1
View File
@@ -5,4 +5,4 @@ if ! python generate_config.py; then
exit 1 exit 1
fi fi
exec python pterodisbot.py exec python main.py
+84
View File
@@ -0,0 +1,84 @@
"""
Custom Exception Classes
Defines application-specific exceptions for better error handling
and clearer error messages throughout the codebase.
"""
class PterodisBotError(Exception):
"""Base exception class for all PterodisBot errors."""
pass
class ConfigValidationError(PterodisBotError):
"""
Raised when configuration validation fails.
Contains detailed error messages for all validation failures.
"""
pass
class APIError(PterodisBotError):
"""
Raised when an API request fails.
Attributes:
endpoint: The API endpoint that failed
status_code: HTTP status code if available
message: Error message from the API
"""
def __init__(
self,
message: str,
endpoint: str = "",
status_code: int = 0,
) -> None:
super().__init__(message)
self.endpoint = endpoint
self.status_code = status_code
self.message = message
class ServerNotFoundError(PterodisBotError):
"""
Raised when a requested server is not found in the panel.
"""
pass
class PermissionError(PterodisBotError):
"""
Raised when a user lacks required permissions for an action.
"""
pass
class EmbedNotFoundError(PterodisBotError):
"""
Raised when a tracked embed message is not found in Discord.
"""
pass
class MetricsError(PterodisBotError):
"""
Raised when metrics graph generation fails.
"""
pass
class ConfigurationError(PterodisBotError):
"""
Raised when there's a configuration issue not caught during startup validation.
"""
pass
+106 -26
View File
@@ -3,47 +3,127 @@ import configparser
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
def validate_env_vars():
"""Validate that required environment variables are set and non-empty.""" REQUIRED_VARS = [
required_vars = [
'DISCORD_TOKEN', 'DISCORD_TOKEN',
'ALLOWED_GUILD_ID', 'ALLOWED_GUILD_ID', # Legacy
'ALLOWED_GUILD_IDS', # New
'PANEL_URL', 'PANEL_URL',
'CLIENT_API_KEY', 'CLIENT_API_KEY',
'APPLICATION_API_KEY' 'APPLICATION_API_KEY'
] ]
missing_vars = [var for var in required_vars if not os.getenv(var)]
def validate_env_vars():
"""Validate that required environment variables are set and non-empty."""
# Check for either legacy or new guild ID variable
has_guild_id = bool(os.getenv('ALLOWED_GUILD_ID'))
has_guild_ids = bool(os.getenv('ALLOWED_GUILD_IDS'))
if not has_guild_id and not has_guild_ids:
raise ValueError(
"Missing required environment variable: ALLOWED_GUILD_IDS (or legacy ALLOWED_GUILD_ID)"
)
# Check other required vars
missing_vars = [
var for var in ['DISCORD_TOKEN', 'PANEL_URL', 'CLIENT_API_KEY', 'APPLICATION_API_KEY']
if not os.getenv(var)
]
if missing_vars: if missing_vars:
raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}") raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
def get_env_config():
"""Extract configuration from environment variables."""
# Support both new ALLOWED_GUILD_IDS and legacy ALLOWED_GUILD_ID
allowed_guild_ids = os.getenv('ALLOWED_GUILD_IDS', '')
if not allowed_guild_ids:
allowed_guild_ids = os.getenv('ALLOWED_GUILD_ID', '')
return {
'Discord': {
'Token': os.getenv('DISCORD_TOKEN'),
'AllowedGuildIDs': allowed_guild_ids
},
'Pterodactyl': {
'PanelURL': os.getenv('PANEL_URL'),
'ClientAPIKey': os.getenv('CLIENT_API_KEY'),
'ApplicationAPIKey': os.getenv('APPLICATION_API_KEY')
}
}
def configs_match(existing_config, env_config):
"""Compare existing config with environment variables."""
for section, values in env_config.items():
if not existing_config.has_section(section):
return False
for key, env_value in values.items():
if env_value is None:
# Environment variable not set, don't require match
continue
# For AllowedGuildIDs, also check legacy AllowedGuildID key
check_keys = [key]
if key == 'AllowedGuildIDs':
check_keys.append('AllowedGuildID')
matched = False
for check_key in check_keys:
existing_value = existing_config.get(section, check_key, fallback=None)
if existing_value == env_value:
matched = True
break
if not matched:
return False
return True
def generate_config(): def generate_config():
"""Generate config.ini from environment variables.""" """
# Validate environment variables first Generate config.ini from environment variables if:
validate_env_vars() 1. No config file exists, OR
2. Config file exists but values don't match environment variables
config = configparser.ConfigParser() Environment variables always take priority.
"""
# Discord section - ensure all values are strings
config['Discord'] = {
'Token': str(os.getenv('DISCORD_TOKEN')),
'AllowedGuildID': str(os.getenv('ALLOWED_GUILD_ID'))
}
# Pterodactyl section - ensure all values are strings
config['Pterodactyl'] = {
'PanelURL': str(os.getenv('PANEL_URL')),
'ClientAPIKey': str(os.getenv('CLIENT_API_KEY')),
'ApplicationAPIKey': str(os.getenv('APPLICATION_API_KEY'))
}
# Write to file
config_path = Path(os.getenv('CONFIG_PATH', 'config.ini')) config_path = Path(os.getenv('CONFIG_PATH', 'config.ini'))
# Check if config file exists
if not config_path.exists():
print(f"No config file found at {config_path}, generating from environment variables...")
validate_env_vars()
_write_config(config_path, get_env_config())
return
# Config file exists, read it and compare with environment
existing_config = configparser.ConfigParser()
existing_config.read(config_path)
env_config = get_env_config()
if not configs_match(existing_config, env_config):
print(f"Config file at {config_path} differs from environment variables, regenerating...")
# Validate that required env vars are present for regeneration
validate_env_vars()
_write_config(config_path, env_config)
return
print(f"Config file at {config_path} matches environment variables, using existing config.")
def _write_config(config_path, config_dict):
"""Write config dictionary to file."""
config = configparser.ConfigParser()
for section, values in config_dict.items():
config[section] = {k: str(v) for k, v in values.items() if v is not None}
config_path.parent.mkdir(exist_ok=True, parents=True) config_path.parent.mkdir(exist_ok=True, parents=True)
with open(config_path, 'w') as configfile: with open(config_path, 'w') as configfile:
config.write(configfile) config.write(configfile)
print(f"Config file generated at {config_path}") print(f"Config file written to {config_path}")
if __name__ == "__main__": if __name__ == "__main__":
load_dotenv() load_dotenv()
+66
View File
@@ -0,0 +1,66 @@
"""
Logging Configuration Module
Sets up application-wide logging with:
- Rotating file handler (5MB max, 3 backups) at DEBUG level
- Console handler at INFO level
- Consistent formatting for both handlers
"""
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
def setup_logging(log_dir: str = "logs", log_file: str = "pterodisbot.log") -> logging.Logger:
"""
Configure application-wide logging.
Args:
log_dir: Directory for log files (relative to this file)
log_file: Name of the log file
Returns:
Configured logger instance
"""
# Create logs directory if it doesn't exist
logs_path = Path(__file__).parent / log_dir
logs_path.mkdir(parents=True, exist_ok=True)
# Get or create the application logger
logger = logging.getLogger("pterodisbot")
logger.setLevel(logging.DEBUG)
# Avoid adding duplicate handlers if called multiple times
if logger.handlers:
return logger
# File handler for logs (rotates when reaching 5MB, keeps 3 backups)
file_handler = RotatingFileHandler(
filename=logs_path / log_file,
maxBytes=5 * 1024 * 1024, # 5 MiB max log file size
backupCount=3, # Rotate through 3 files
encoding="utf-8",
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
)
logger.addHandler(file_handler)
# Console handler for real-time output (INFO level and above)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(
logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
)
logger.addHandler(console_handler)
logger.info("Initialized logging system with file and console output")
return logger
def get_logger() -> logging.Logger:
"""Get the configured logger instance."""
return logging.getLogger("pterodisbot")
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Main entry point for the bot.
Loads configuration and starts the modular bot application.
"""
import asyncio
import logging
import sys
from bot import run_bot
from config import load_config, ConfigValidationError
from exceptions import PterodisBotError
from logging_config import setup_logging
# Configure application logging
setup_logging()
logger = logging.getLogger("pterodisbot")
def main() -> int:
"""
Synchronous entry point for the bot application.
Returns:
Exit code (0 for success, 1 for error)
"""
try:
# Load and validate configuration
logger.info("Loading configuration...")
config = load_config("config.ini")
logger.info(f"Configuration loaded - Panel type: {config.get_panel_display_name()}")
# Run the bot
asyncio.run(run_bot(config))
return 0
except ConfigValidationError as e:
logger.critical(f"Configuration error: {str(e)}")
return 1
except PterodisBotError as e:
logger.critical(f"Application error: {str(e)}")
return 1
except KeyboardInterrupt:
pass # Already logged by signal handler
except Exception as e:
logger.critical(f"Unexpected error during startup: {str(e)}", exc_info=True)
return 1
if __name__ == "__main__":
sys.exit(main())
+538
View File
@@ -0,0 +1,538 @@
"""
Server Metrics Graphs Module
Provides graphing capabilities for server CPU and memory usage.
Generates line graphs as PNG images for embedding in Discord messages.
"""
import io
import logging
import math
from collections import deque
from datetime import datetime
from typing import Any, Dict, Optional
import matplotlib
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
matplotlib.use("Agg") # Use non-interactive backend for server environments
logger = logging.getLogger("pterodisbot")
class ServerMetricsGraphs:
"""
Manages CPU and memory usage graphs for individual servers.
Features:
- Stores last 6 data points (1 minute of history at 10-second intervals)
- Generates PNG images of line graphs for Discord embedding
- Automatic data rotation (FIFO queue with max 6 points)
- Separate tracking for CPU percentage and memory MB usage
- Dynamic CPU scaling in 100% increments for multi-vCPU servers
- Clean graph styling optimized for Discord dark theme
"""
def __init__(self, server_id: str, server_name: str) -> None:
"""
Initialize metrics tracking for a server.
Args:
server_id: Panel server identifier
server_name: Human-readable server name
"""
self.server_id = server_id
self.server_name = server_name
# Use deque with maxlen=6 for automatic FIFO rotation
# Each entry is a tuple: (timestamp, cpu_percent, memory_mb)
self.data_points = deque(maxlen=6)
# Track if we have enough data for meaningful graphs (at least 2 points)
self.has_sufficient_data = False
logger.debug(f"Initialized metrics tracking for server {server_name} ({server_id})")
def add_data_point(
self, cpu_percent: float, memory_mb: float, timestamp: Optional[datetime] = None
) -> None:
"""
Add a new data point to the metrics history.
Args:
cpu_percent: Current CPU usage percentage
memory_mb: Current memory usage in megabytes
timestamp: Optional timestamp, defaults to current time
"""
if timestamp is None:
timestamp = datetime.now()
# Add new data point (automatically rotates old data due to maxlen=6)
self.data_points.append((timestamp, cpu_percent, memory_mb))
# Update sufficient data flag
self.has_sufficient_data = len(self.data_points) >= 2
logger.debug(
f"Added metrics data point for {self.server_name}: "
f"CPU={cpu_percent}%, Memory={memory_mb}MB"
)
def _calculate_cpu_scale_limit(self, max_cpu_value: float) -> int:
"""
Calculate appropriate CPU scale limit in 100% increments.
Args:
max_cpu_value: Maximum CPU value in the dataset
Returns:
Scale limit rounded up to nearest 100% increment
"""
if max_cpu_value <= 100:
return 100
# Round up to nearest 100% increment
# e.g., 150% -> 200%, 250% -> 300%, 350% -> 400%
return math.ceil(max_cpu_value / 100) * 100
def generate_cpu_graph(self) -> Optional[io.BytesIO]:
"""
Generate a CPU usage line graph as a PNG image.
Returns:
BytesIO object containing PNG image data, or None if insufficient data
"""
if not self.has_sufficient_data:
logger.debug(f"Insufficient data for CPU graph generation: {self.server_name}")
return None
try:
# Extract timestamps and CPU data
timestamps = [point[0] for point in self.data_points]
cpu_values = [point[1] for point in self.data_points]
# Calculate dynamic CPU scale limit
max_cpu = max(cpu_values)
cpu_scale_limit = self._calculate_cpu_scale_limit(max_cpu)
# Create figure with dark theme styling
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(8, 4), dpi=100)
fig.patch.set_facecolor("#2f3136") # Discord dark theme background
ax.set_facecolor("#36393f") # Slightly lighter for graph area
# Plot CPU line with gradient fill
ax.fill_between(timestamps, cpu_values, alpha=0.3, color="#7289da")
# Customize axes with dynamic scaling
ax.set_ylabel("CPU Usage (%)", color="#ffffff", fontsize=10)
ax.set_ylim(0, cpu_scale_limit)
# Add horizontal grid lines at 100% increments for better readability
for i in range(100, cpu_scale_limit + 1, 100):
ax.axhline(y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8)
# Format time axis
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
ax.xaxis.set_major_locator(mdates.SecondLocator(interval=20))
plt.setp(
ax.xaxis.get_majorticklabels(),
rotation=45,
ha="right",
color="#ffffff",
fontsize=8,
)
# Style the graph
ax.tick_params(colors="#ffffff", labelsize=8)
ax.grid(True, alpha=0.3, color="#ffffff")
ax.spines["bottom"].set_color("#ffffff")
ax.spines["left"].set_color("#ffffff")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
# Add title with scale info for multi-vCPU servers
title = f"{self.server_name} - CPU Usage"
if cpu_scale_limit > 100:
estimated_vcpus = cpu_scale_limit // 100
title += f" (~{estimated_vcpus} vCPU cores)"
ax.set_title(title, color="#ffffff", fontsize=12, pad=20)
# Tight layout to prevent label cutoff
plt.tight_layout()
# Save to BytesIO
img_buffer = io.BytesIO()
plt.savefig(
img_buffer,
format="png",
facecolor="#2f3136",
edgecolor="none",
bbox_inches="tight",
dpi=100,
)
img_buffer.seek(0)
# Clean up matplotlib resources
plt.close(fig)
logger.debug(f"Generated CPU graph for {self.server_name} (scale: 0-{cpu_scale_limit}%)")
return img_buffer
except Exception as e:
logger.error(f"Failed to generate CPU graph for {self.server_name}: {str(e)}")
plt.close("all") # Clean up any remaining figures
return None
def generate_memory_graph(self) -> Optional[io.BytesIO]:
"""
Generate a memory usage line graph as a PNG image.
Returns:
BytesIO object containing PNG image data, or None if insufficient data
"""
if not self.has_sufficient_data:
logger.debug(f"Insufficient data for memory graph generation: {self.server_name}")
return None
try:
# Extract timestamps and memory data
timestamps = [point[0] for point in self.data_points]
memory_values = [point[2] for point in self.data_points]
# Create figure with dark theme styling
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(8, 4), dpi=100)
fig.patch.set_facecolor("#2f3136") # Discord dark theme background
ax.set_facecolor("#36393f") # Slightly lighter for graph area
# Plot memory line with gradient fill
ax.fill_between(timestamps, memory_values, alpha=0.3, color="#43b581")
# Customize axes
ax.set_ylabel("Memory Usage (MB)", color="#ffffff", fontsize=10)
ax.set_ylim(0, max(memory_values) * 1.1) # Dynamic scaling with 10% padding
# Format time axis
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
ax.xaxis.set_major_locator(mdates.SecondLocator(interval=20))
plt.setp(
ax.xaxis.get_majorticklabels(),
rotation=45,
ha="right",
color="#ffffff",
fontsize=8,
)
# Style the graph
ax.tick_params(colors="#ffffff", labelsize=8)
ax.grid(True, alpha=0.3, color="#ffffff")
ax.spines["bottom"].set_color("#ffffff")
ax.spines["left"].set_color("#ffffff")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
# Add title
ax.set_title(
f"{self.server_name} - Memory Usage",
color="#ffffff",
fontsize=12,
pad=20,
)
# Tight layout to prevent label cutoff
plt.tight_layout()
# Save to BytesIO
img_buffer = io.BytesIO()
plt.savefig(
img_buffer,
format="png",
facecolor="#2f3136",
edgecolor="none",
bbox_inches="tight",
dpi=100,
)
img_buffer.seek(0)
# Clean up matplotlib resources
plt.close(fig)
logger.debug(f"Generated memory graph for {self.server_name}")
return img_buffer
except Exception as e:
logger.error(f"Failed to generate memory graph for {self.server_name}: {str(e)}")
plt.close("all") # Clean up any remaining figures
return None
def generate_combined_graph(self) -> Optional[io.BytesIO]:
"""
Generate a combined CPU and memory usage graph as a PNG image.
Returns:
BytesIO object containing PNG image data, or None if insufficient data
"""
if not self.has_sufficient_data:
logger.debug(f"Insufficient data for combined graph generation: {self.server_name}")
return None
try:
# Extract data
timestamps = [point[0] for point in self.data_points]
cpu_values = [point[1] for point in self.data_points]
memory_values = [point[2] for point in self.data_points]
# Calculate dynamic CPU scale limit
max_cpu = max(cpu_values)
cpu_scale_limit = self._calculate_cpu_scale_limit(max_cpu)
# Create figure with two subplots
plt.style.use("dark_background")
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), dpi=100, sharex=True)
fig.patch.set_facecolor("#2f3136")
# CPU subplot
ax1.set_facecolor("#36393f")
ax1.plot(
timestamps,
cpu_values,
color="#7289da",
linewidth=2.5,
marker="o",
markersize=4,
)
ax1.fill_between(timestamps, cpu_values, alpha=0.3, color="#7289da")
ax1.set_ylabel("CPU Usage (%)", color="#ffffff", fontsize=10)
ax1.set_ylim(0, cpu_scale_limit)
ax1.tick_params(colors="#ffffff", labelsize=8)
ax1.grid(True, alpha=0.3, color="#ffffff")
# Add horizontal grid lines at 100% increments for CPU subplot
for i in range(100, cpu_scale_limit + 1, 100):
ax1.axhline(y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8)
# Title with vCPU info if applicable
title = f"{self.server_name} - Resource Usage"
if cpu_scale_limit > 100:
estimated_vcpus = cpu_scale_limit // 100
title += f" (~{estimated_vcpus} vCPU cores)"
ax1.set_title(title, color="#ffffff", fontsize=12)
# Memory subplot
ax2.set_facecolor("#36393f")
ax2.plot(
timestamps,
memory_values,
color="#43b581",
linewidth=2.5,
marker="o",
markersize=4,
)
ax2.fill_between(timestamps, memory_values, alpha=0.3, color="#43b581")
ax2.set_ylabel("Memory (MB)", color="#ffffff", fontsize=10)
ax2.set_ylim(0, max(memory_values) * 1.1)
ax2.tick_params(colors="#ffffff", labelsize=8)
ax2.grid(True, alpha=0.3, color="#ffffff")
# Format time axis (only on bottom subplot)
ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
ax2.xaxis.set_major_locator(mdates.SecondLocator(interval=20))
plt.setp(
ax2.xaxis.get_majorticklabels(),
rotation=45,
ha="right",
color="#ffffff",
fontsize=8,
)
# Style both subplots
for ax in [ax1, ax2]:
ax.spines["bottom"].set_color("#ffffff")
ax.spines["left"].set_color("#ffffff")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
# Save to BytesIO
img_buffer = io.BytesIO()
plt.savefig(
img_buffer,
format="png",
facecolor="#2f3136",
edgecolor="none",
bbox_inches="tight",
dpi=100,
)
img_buffer.seek(0)
plt.close(fig)
logger.debug(f"Generated combined graph for {self.server_name} (CPU scale: 0-{cpu_scale_limit}%)")
return img_buffer
except Exception as e:
logger.error(f"Failed to generate combined graph for {self.server_name}: {str(e)}")
plt.close("all")
return None
def get_data_summary(self) -> Dict[str, Any]:
"""
Get summary statistics for the current data points.
Returns:
Dictionary containing data point count, latest values, and trends
"""
if not self.data_points:
return {
"point_count": 0,
"has_data": False,
"latest_cpu": 0,
"latest_memory": 0,
}
# Get latest values
latest_point = self.data_points[-1]
latest_cpu = latest_point[1]
latest_memory = latest_point[2]
# Calculate CPU scale info
max_cpu = max(point[1] for point in self.data_points)
cpu_scale_limit = self._calculate_cpu_scale_limit(max_cpu)
estimated_vcpus = cpu_scale_limit // 100
# Calculate trends if we have multiple points
cpu_trend = "stable"
memory_trend = "stable"
if len(self.data_points) >= 2:
first_point = self.data_points[0]
cpu_change = latest_cpu - first_point[1]
memory_change = latest_memory - first_point[2]
# Determine trends (>5% change considered significant)
if abs(cpu_change) > 5:
cpu_trend = "increasing" if cpu_change > 0 else "decreasing"
if abs(memory_change) > 50: # 50MB change threshold
memory_trend = "increasing" if memory_change > 0 else "decreasing"
return {
"point_count": len(self.data_points),
"has_data": self.has_sufficient_data,
"latest_cpu": latest_cpu,
"latest_memory": latest_memory,
"cpu_trend": cpu_trend,
"memory_trend": memory_trend,
"cpu_scale_limit": cpu_scale_limit,
"estimated_vcpus": estimated_vcpus,
"time_span_minutes": len(self.data_points) * 10 / 60, # Convert to minutes
}
class ServerMetricsManager:
"""
Global manager for all server metrics graphs.
Handles:
- Creation and cleanup of ServerMetricsGraphs instances
- Bulk operations across all tracked servers
- Memory management for graph storage
"""
def __init__(self) -> None:
"""Initialize the metrics manager."""
self.server_graphs: Dict[str, ServerMetricsGraphs] = {}
logger.info("Initialized ServerMetricsManager")
def get_or_create_server_graphs(self, server_id: str, server_name: str) -> ServerMetricsGraphs:
"""
Get existing ServerMetricsGraphs instance or create a new one.
Args:
server_id: Panel server identifier
server_name: Human-readable server name
Returns:
ServerMetricsGraphs instance for the specified server
"""
if server_id not in self.server_graphs:
self.server_graphs[server_id] = ServerMetricsGraphs(server_id, server_name)
logger.debug(f"Created new metrics graphs for server {server_name}")
return self.server_graphs[server_id]
def add_server_data(
self, server_id: str, server_name: str, cpu_percent: float, memory_mb: float
) -> None:
"""
Add data point to a server's metrics tracking.
Args:
server_id: Panel server identifier
server_name: Human-readable server name
cpu_percent: Current CPU usage percentage
memory_mb: Current memory usage in megabytes
"""
graphs = self.get_or_create_server_graphs(server_id, server_name)
graphs.add_data_point(cpu_percent, memory_mb)
def remove_server(self, server_id: str) -> None:
"""
Remove a server from metrics tracking.
Args:
server_id: Panel server identifier to remove
"""
if server_id in self.server_graphs:
del self.server_graphs[server_id]
logger.debug(f"Removed metrics tracking for server {server_id}")
def get_server_graphs(self, server_id: str) -> Optional[ServerMetricsGraphs]:
"""
Get ServerMetricsGraphs instance for a specific server.
Args:
server_id: Panel server identifier
Returns:
ServerMetricsGraphs instance or None if not found
"""
return self.server_graphs.get(server_id)
def cleanup_old_servers(self, active_server_ids: list) -> None:
"""
Remove tracking for servers that no longer exist.
Args:
active_server_ids: List of currently active server IDs
"""
servers_to_remove = []
for server_id in self.server_graphs:
if server_id not in active_server_ids:
servers_to_remove.append(server_id)
for server_id in servers_to_remove:
self.remove_server(server_id)
if servers_to_remove:
logger.info(f"Cleaned up metrics for {len(servers_to_remove)} inactive servers")
def get_summary(self) -> Dict[str, Any]:
"""
Get summary of all tracked servers.
Returns:
Dictionary with tracking statistics
"""
return {
"total_servers": len(self.server_graphs),
"servers_with_data": sum(
1 for graphs in self.server_graphs.values() if graphs.has_sufficient_data
),
"total_data_points": sum(
len(graphs.data_points) for graphs in self.server_graphs.values()
),
}
+118
View File
@@ -0,0 +1,118 @@
"""
Data Models Module
Pydantic models for type-safe data handling throughout the application.
Provides validation and serialization for API responses and internal data.
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class ResourceAttributes(BaseModel):
"""Current resource usage attributes from panel API."""
current_state: str
resources: "ResourceUsage"
class ResourceUsage(BaseModel):
"""Individual resource metrics."""
cpu_absolute: float = 0.0
memory_bytes: int = 0
disk_bytes: int = 0
network_rx_bytes: int = 0
network_tx_bytes: int = 0
uptime: int = 0
class ServerAttributes(BaseModel):
"""Server attributes from panel API."""
identifier: str
name: str
description: Optional[str] = None
suspended: bool = False
limits: "ServerLimits" = Field(default_factory=lambda: ServerLimits())
class ServerLimits(BaseModel):
"""Server resource limits."""
cpu: int = 0
memory: int = 0
disk: int = 0
class ServerRelationships(BaseModel):
"""Server relationships data."""
allocations: Optional["AllocationsData"] = None
class AllocationsData(BaseModel):
"""Allocations data container."""
data: List["Allocation"] = Field(default_factory=list)
class Allocation(BaseModel):
"""Individual allocation (IP/port)."""
attributes: "AllocationAttributes"
class AllocationAttributes(BaseModel):
"""Allocation attributes."""
ip_alias: str = "Unknown"
port: int = 0
is_default: bool = False
class ServerData(BaseModel):
"""Complete server data from panel API."""
attributes: ServerAttributes
relationships: Optional[ServerRelationships] = None
class ServerResources(BaseModel):
"""Server resources response from panel API."""
attributes: ResourceAttributes
class APIResponse(BaseModel):
"""Generic API response wrapper."""
status: str = "success"
data: Any = None
message: Optional[str] = None
errors: Optional[List[Dict[str, Any]]] = None
class ServerListResponse(BaseModel):
"""Server list response from panel API."""
data: List[Dict[str, Any]] = Field(default_factory=list)
meta: Optional[Dict[str, Any]] = None
class EmbedLocation(BaseModel):
"""Embed location tracking data with guild context."""
guild_id: int
channel_id: str
message_id: str
# Composite key for storage
@property
def storage_key(self) -> str:
return f"{self.guild_id}:{self.channel_id}:{self.message_id}"
# Rebuild models to resolve forward references
ResourceAttributes.model_rebuild()
ServerAttributes.model_rebuild()
+1759 -629
View File
File diff suppressed because it is too large. Load diff
-1439
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
# Testing Dependencies for Pterodactyl Discord Bot
# Core testing framework
pytest>=9.1.1
pytest-asyncio>=1.4.0
pytest-cov>=7.1.0
pytest-mock>=3.17.0
pytest-timeout>=2.4.0
# Code quality and linting
flake8>=7.4.1
pylint>=4.1.3
black>=26.10.1
isort>=9.0.2
# Security scanning
bandit>=1.9.4
safety>=3.8.1
# Mocking and fixtures
pytest-fixtures>=0.1.0
freezegun>=1.5.5
# Coverage reporting
coverage>=7.17.0
coverage-badge>=1.1.2
+6 -4
View File
@@ -1,4 +1,6 @@
discord.py>=2.3.0 discord.py>=2.7.1
aiohttp>=3.8.0 aiohttp>=3.14.5
configparser>=5.3.0 configparser>=7.3.0
python-dotenv python-dotenv>=1.2.4
matplotlib>=3.11.2
pydantic>=2.14.1
+528
View File
@@ -0,0 +1,528 @@
"""
Server Metrics Graphs Module for Pterodactyl Discord Bot
This module provides graphing capabilities for server CPU and memory usage.
Generates line graphs as PNG images for embedding in Discord messages.
"""
import io
import logging
import math
from collections import deque
from datetime import datetime
from typing import Dict, Optional
import matplotlib
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
matplotlib.use("Agg") # Use non-interactive backend for server environments
# Get the logger from the main bot module
logger = logging.getLogger("pterodisbot")
class ServerMetricsGraphs:
"""
Manages CPU and memory usage graphs for individual servers.
Features:
- Stores last 6 data points (1 minute of history at 10-second intervals)
- Generates PNG images of line graphs for Discord embedding
- Automatic data rotation (FIFO queue with max 6 points)
- Separate tracking for CPU percentage and memory MB usage
- Dynamic CPU scaling in 100% increments for multi-vCPU servers
- Clean graph styling optimized for Discord dark theme
"""
def __init__(self, server_id: str, server_name: str):
"""
Initialize metrics tracking for a server.
Args:
server_id: Pterodactyl server identifier
server_name: Human-readable server name
"""
self.server_id = server_id
self.server_name = server_name
# Use deque with maxlen=6 for automatic FIFO rotation
# Each entry is a tuple: (timestamp, cpu_percent, memory_mb)
self.data_points = deque(maxlen=6)
# Track if we have enough data for meaningful graphs (at least 2 points)
self.has_sufficient_data = False
logger.debug(f"Initialized metrics tracking for server {server_name} ({server_id})")
def add_data_point(self, cpu_percent: float, memory_mb: float, timestamp: Optional[datetime] = None):
"""
Add a new data point to the metrics history.
Args:
cpu_percent: Current CPU usage percentage
memory_mb: Current memory usage in megabytes
timestamp: Optional timestamp, defaults to current time
"""
if timestamp is None:
timestamp = datetime.now()
# Add new data point (automatically rotates old data due to maxlen=6)
self.data_points.append((timestamp, cpu_percent, memory_mb))
# Update sufficient data flag
self.has_sufficient_data = len(self.data_points) >= 2
logger.debug(f"Added metrics data point for {self.server_name}: CPU={cpu_percent}%, Memory={memory_mb}MB")
def _calculate_cpu_scale_limit(self, max_cpu_value: float) -> int:
"""
Calculate appropriate CPU scale limit in 100% increments.
Args:
max_cpu_value: Maximum CPU value in the dataset
Returns:
Scale limit rounded up to nearest 100% increment
"""
if max_cpu_value <= 100:
return 100
# Round up to nearest 100% increment
# e.g., 150% -> 200%, 250% -> 300%, 350% -> 400%
return math.ceil(max_cpu_value / 100) * 100
def generate_cpu_graph(self) -> Optional[io.BytesIO]:
"""
Generate a CPU usage line graph as a PNG image.
Returns:
BytesIO object containing PNG image data, or None if insufficient data
"""
if not self.has_sufficient_data:
logger.debug(f"Insufficient data for CPU graph generation: {self.server_name}")
return None
try:
# Extract timestamps and CPU data
timestamps = [point[0] for point in self.data_points]
cpu_values = [point[1] for point in self.data_points]
# Calculate dynamic CPU scale limit
max_cpu = max(cpu_values)
cpu_scale_limit = self._calculate_cpu_scale_limit(max_cpu)
# Create figure with dark theme styling
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(8, 4), dpi=100)
fig.patch.set_facecolor("#2f3136") # Discord dark theme background
ax.set_facecolor("#36393f") # Slightly lighter for graph area
# Plot CPU line with gradient fill
ax.fill_between(timestamps, cpu_values, alpha=0.3, color="#7289da")
# Customize axes with dynamic scaling
ax.set_ylabel("CPU Usage (%)", color="#ffffff", fontsize=10)
ax.set_ylim(0, cpu_scale_limit)
# Add horizontal grid lines at 100% increments for better readability
for i in range(100, cpu_scale_limit + 1, 100):
ax.axhline(y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8)
# Format time axis
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
ax.xaxis.set_major_locator(mdates.SecondLocator(interval=20))
plt.setp(
ax.xaxis.get_majorticklabels(),
rotation=45,
ha="right",
color="#ffffff",
fontsize=8,
)
# Style the graph
ax.tick_params(colors="#ffffff", labelsize=8)
ax.grid(True, alpha=0.3, color="#ffffff")
ax.spines["bottom"].set_color("#ffffff")
ax.spines["left"].set_color("#ffffff")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
# Add title with scale info for multi-vCPU servers
title = f"{self.server_name} - CPU Usage"
if cpu_scale_limit > 100:
estimated_vcpus = cpu_scale_limit // 100
title += f" (~{estimated_vcpus} vCPU cores)"
ax.set_title(title, color="#ffffff", fontsize=12, pad=20)
# Tight layout to prevent label cutoff
plt.tight_layout()
# Save to BytesIO
img_buffer = io.BytesIO()
plt.savefig(
img_buffer,
format="png",
facecolor="#2f3136",
edgecolor="none",
bbox_inches="tight",
dpi=100,
)
img_buffer.seek(0)
# Clean up matplotlib resources
plt.close(fig)
logger.debug(f"Generated CPU graph for {self.server_name} (scale: 0-{cpu_scale_limit}%)")
return img_buffer
except Exception as e:
logger.error(f"Failed to generate CPU graph for {self.server_name}: {str(e)}")
plt.close("all") # Clean up any remaining figures
return None
def generate_memory_graph(self) -> Optional[io.BytesIO]:
"""
Generate a memory usage line graph as a PNG image.
Returns:
BytesIO object containing PNG image data, or None if insufficient data
"""
if not self.has_sufficient_data:
logger.debug(f"Insufficient data for memory graph generation: {self.server_name}")
return None
try:
# Extract timestamps and memory data
timestamps = [point[0] for point in self.data_points]
memory_values = [point[2] for point in self.data_points]
# Create figure with dark theme styling
plt.style.use("dark_background")
fig, ax = plt.subplots(figsize=(8, 4), dpi=100)
fig.patch.set_facecolor("#2f3136") # Discord dark theme background
ax.set_facecolor("#36393f") # Slightly lighter for graph area
# Plot memory line with gradient fill
ax.fill_between(timestamps, memory_values, alpha=0.3, color="#43b581")
# Customize axes
ax.set_ylabel("Memory Usage (MB)", color="#ffffff", fontsize=10)
ax.set_ylim(0, max(memory_values) * 1.1) # Dynamic scaling with 10% padding
# Format time axis
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
ax.xaxis.set_major_locator(mdates.SecondLocator(interval=20))
plt.setp(
ax.xaxis.get_majorticklabels(),
rotation=45,
ha="right",
color="#ffffff",
fontsize=8,
)
# Style the graph
ax.tick_params(colors="#ffffff", labelsize=8)
ax.grid(True, alpha=0.3, color="#ffffff")
ax.spines["bottom"].set_color("#ffffff")
ax.spines["left"].set_color("#ffffff")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
# Add title
ax.set_title(
f"{self.server_name} - Memory Usage",
color="#ffffff",
fontsize=12,
pad=20,
)
# Tight layout to prevent label cutoff
plt.tight_layout()
# Save to BytesIO
img_buffer = io.BytesIO()
plt.savefig(
img_buffer,
format="png",
facecolor="#2f3136",
edgecolor="none",
bbox_inches="tight",
dpi=100,
)
img_buffer.seek(0)
# Clean up matplotlib resources
plt.close(fig)
logger.debug(f"Generated memory graph for {self.server_name}")
return img_buffer
except Exception as e:
logger.error(f"Failed to generate memory graph for {self.server_name}: {str(e)}")
plt.close("all") # Clean up any remaining figures
return None
def generate_combined_graph(self) -> Optional[io.BytesIO]:
"""
Generate a combined CPU and memory usage graph as a PNG image.
Returns:
BytesIO object containing PNG image data, or None if insufficient data
"""
if not self.has_sufficient_data:
logger.debug(f"Insufficient data for combined graph generation: {self.server_name}")
return None
try:
# Extract data
timestamps = [point[0] for point in self.data_points]
cpu_values = [point[1] for point in self.data_points]
memory_values = [point[2] for point in self.data_points]
# Calculate dynamic CPU scale limit
max_cpu = max(cpu_values)
cpu_scale_limit = self._calculate_cpu_scale_limit(max_cpu)
# Create figure with two subplots
plt.style.use("dark_background")
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 6), dpi=100, sharex=True)
fig.patch.set_facecolor("#2f3136")
# CPU subplot
ax1.set_facecolor("#36393f")
ax1.plot(
timestamps,
cpu_values,
color="#7289da",
linewidth=2.5,
marker="o",
markersize=4,
)
ax1.fill_between(timestamps, cpu_values, alpha=0.3, color="#7289da")
ax1.set_ylabel("CPU Usage (%)", color="#ffffff", fontsize=10)
ax1.set_ylim(0, cpu_scale_limit)
ax1.tick_params(colors="#ffffff", labelsize=8)
ax1.grid(True, alpha=0.3, color="#ffffff")
# Add horizontal grid lines at 100% increments for CPU subplot
for i in range(100, cpu_scale_limit + 1, 100):
ax1.axhline(y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8)
# Title with vCPU info if applicable
title = f"{self.server_name} - Resource Usage"
if cpu_scale_limit > 100:
estimated_vcpus = cpu_scale_limit // 100
title += f" (~{estimated_vcpus} vCPU cores)"
ax1.set_title(title, color="#ffffff", fontsize=12)
# Memory subplot
ax2.set_facecolor("#36393f")
ax2.plot(
timestamps,
memory_values,
color="#43b581",
linewidth=2.5,
marker="o",
markersize=4,
)
ax2.fill_between(timestamps, memory_values, alpha=0.3, color="#43b581")
ax2.set_ylabel("Memory (MB)", color="#ffffff", fontsize=10)
ax2.set_ylim(0, max(memory_values) * 1.1)
ax2.tick_params(colors="#ffffff", labelsize=8)
ax2.grid(True, alpha=0.3, color="#ffffff")
# Format time axis (only on bottom subplot)
ax2.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
ax2.xaxis.set_major_locator(mdates.SecondLocator(interval=20))
plt.setp(
ax2.xaxis.get_majorticklabels(),
rotation=45,
ha="right",
color="#ffffff",
fontsize=8,
)
# Style both subplots
for ax in [ax1, ax2]:
ax.spines["bottom"].set_color("#ffffff")
ax.spines["left"].set_color("#ffffff")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
# Save to BytesIO
img_buffer = io.BytesIO()
plt.savefig(
img_buffer,
format="png",
facecolor="#2f3136",
edgecolor="none",
bbox_inches="tight",
dpi=100,
)
img_buffer.seek(0)
plt.close(fig)
logger.debug(f"Generated combined graph for {self.server_name} (CPU scale: 0-{cpu_scale_limit}%)")
return img_buffer
except Exception as e:
logger.error(f"Failed to generate combined graph for {self.server_name}: {str(e)}")
plt.close("all")
return None
def get_data_summary(self) -> Dict[str, any]:
"""
Get summary statistics for the current data points.
Returns:
Dictionary containing data point count, latest values, and trends
"""
if not self.data_points:
return {
"point_count": 0,
"has_data": False,
"latest_cpu": 0,
"latest_memory": 0,
}
# Get latest values
latest_point = self.data_points[-1]
latest_cpu = latest_point[1]
latest_memory = latest_point[2]
# Calculate CPU scale info
max_cpu = max(point[1] for point in self.data_points)
cpu_scale_limit = self._calculate_cpu_scale_limit(max_cpu)
estimated_vcpus = cpu_scale_limit // 100
# Calculate trends if we have multiple points
cpu_trend = "stable"
memory_trend = "stable"
if len(self.data_points) >= 2:
first_point = self.data_points[0]
cpu_change = latest_cpu - first_point[1]
memory_change = latest_memory - first_point[2]
# Determine trends (>5% change considered significant)
if abs(cpu_change) > 5:
cpu_trend = "increasing" if cpu_change > 0 else "decreasing"
if abs(memory_change) > 50: # 50MB change threshold
memory_trend = "increasing" if memory_change > 0 else "decreasing"
return {
"point_count": len(self.data_points),
"has_data": self.has_sufficient_data,
"latest_cpu": latest_cpu,
"latest_memory": latest_memory,
"cpu_trend": cpu_trend,
"memory_trend": memory_trend,
"cpu_scale_limit": cpu_scale_limit,
"estimated_vcpus": estimated_vcpus,
"time_span_minutes": len(self.data_points) * 10 / 60, # Convert to minutes
}
class ServerMetricsManager:
"""
Global manager for all server metrics graphs.
Handles:
- Creation and cleanup of ServerMetricsGraphs instances
- Bulk operations across all tracked servers
- Memory management for graph storage
"""
def __init__(self):
"""Initialize the metrics manager."""
self.server_graphs: Dict[str, ServerMetricsGraphs] = {}
logger.info("Initialized ServerMetricsManager")
def get_or_create_server_graphs(self, server_id: str, server_name: str) -> ServerMetricsGraphs:
"""
Get existing ServerMetricsGraphs instance or create a new one.
Args:
server_id: Pterodactyl server identifier
server_name: Human-readable server name
Returns:
ServerMetricsGraphs instance for the specified server
"""
if server_id not in self.server_graphs:
self.server_graphs[server_id] = ServerMetricsGraphs(server_id, server_name)
logger.debug(f"Created new metrics graphs for server {server_name}")
return self.server_graphs[server_id]
def add_server_data(self, server_id: str, server_name: str, cpu_percent: float, memory_mb: float):
"""
Add data point to a server's metrics tracking.
Args:
server_id: Pterodactyl server identifier
server_name: Human-readable server name
cpu_percent: Current CPU usage percentage
memory_mb: Current memory usage in megabytes
"""
graphs = self.get_or_create_server_graphs(server_id, server_name)
graphs.add_data_point(cpu_percent, memory_mb)
def remove_server(self, server_id: str):
"""
Remove a server from metrics tracking.
Args:
server_id: Pterodactyl server identifier to remove
"""
if server_id in self.server_graphs:
del self.server_graphs[server_id]
logger.debug(f"Removed metrics tracking for server {server_id}")
def get_server_graphs(self, server_id: str) -> Optional[ServerMetricsGraphs]:
"""
Get ServerMetricsGraphs instance for a specific server.
Args:
server_id: Pterodactyl server identifier
Returns:
ServerMetricsGraphs instance or None if not found
"""
return self.server_graphs.get(server_id)
def cleanup_old_servers(self, active_server_ids: list):
"""
Remove tracking for servers that no longer exist.
Args:
active_server_ids: List of currently active server IDs
"""
servers_to_remove = []
for server_id in self.server_graphs:
if server_id not in active_server_ids:
servers_to_remove.append(server_id)
for server_id in servers_to_remove:
self.remove_server(server_id)
if servers_to_remove:
logger.info(f"Cleaned up metrics for {len(servers_to_remove)} inactive servers")
def get_summary(self) -> Dict[str, any]:
"""
Get summary of all tracked servers.
Returns:
Dictionary with tracking statistics
"""
return {
"total_servers": len(self.server_graphs),
"servers_with_data": sum(1 for graphs in self.server_graphs.values() if graphs.has_sufficient_data),
"total_data_points": sum(len(graphs.data_points) for graphs in self.server_graphs.values()),
}
+742
View File
@@ -0,0 +1,742 @@
"""
Unit and Integration Tests for Pterodactyl Discord Bot
Test coverage:
- Configuration validation
- Panel API client operations
- Discord bot commands and interactions
- Server metrics tracking
- Embed management
- Error handling
"""
import pytest
import asyncio
import json
import os
from collections import defaultdict
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
import configparser
import discord
from discord.ext import commands
import aiohttp
from pathlib import Path
# Import the modules to test
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import Config, ConfigValidationError
from api import PanelAPI
from models import ServerData, ServerResources
from metrics import ServerMetricsGraphs, ServerMetricsManager
from views import ServerStatusView, ServerDropdownView
from bot import PterodactylBot
from exceptions import PterodisBotError
# ==========================================
# FIXTURES
# ==========================================
@pytest.fixture
def mock_config():
"""
Create a mock configuration for testing.
Returns:
Config: A properly configured test configuration object
"""
config = Config.__new__(Config)
config.pterodactyl_url = 'https://panel.example.com'
config.client_api_key = 'ptlc_test_client_key_123'
config.application_api_key = 'ptla_test_app_key_456'
config.discord_token = 'test_discord_token'
config.allowed_guild_ids = [123456789, 987654321]
config.allowed_guild_id = 123456789 # Backward compat
config.required_role = "Game Server User"
config.update_interval = 10
config.embed_storage_path = Path("./embed/embed_locations.json")
# Set the singleton instance
Config._instance = config
return config
@pytest.fixture
def mock_panel_api(mock_config):
"""
Create a mock PanelAPI instance with properly configured session.
Returns:
PanelAPI: A mocked API instance ready for testing
"""
api = Mock(spec=PanelAPI)
api.panel_url = 'https://panel.example.com'
api.client_api_key = 'ptlc_test_client_key'
api.application_api_key = 'ptla_test_app_key'
api.session = AsyncMock(spec=aiohttp.ClientSession)
api.session.close = AsyncMock()
api._is_initialized = True
api._lock = asyncio.Lock()
# Mock the methods
api.initialize = AsyncMock()
api.close = AsyncMock()
api.get_servers = AsyncMock()
api.get_server_resources = AsyncMock()
api.send_power_action = AsyncMock()
api.get_server_details = AsyncMock()
api.get_server_allocations = AsyncMock()
api.get_client_server_details = AsyncMock()
api.get_server_default_allocation = AsyncMock()
api._request = AsyncMock()
return api
@pytest.fixture
def sample_server_data():
"""
Sample server data from Panel API.
Returns:
dict: Server attributes in Panel API format
"""
return {
'attributes': {
'identifier': 'abc123',
'name': 'Test Server',
'description': 'A test game server',
'suspended': False,
'limits': {
'cpu': 200,
'memory': 2048,
'disk': 10240
}
}
}
@pytest.fixture
def sample_resources_data():
"""
Sample resource usage data from Panel API.
Returns:
dict: Resource usage attributes in Panel API format
"""
return {
'attributes': {
'current_state': 'running',
'resources': {
'cpu_absolute': 45.5,
'memory_bytes': 1073741824, # 1GB
'disk_bytes': 5368709120, # 5GB
'network_rx_bytes': 10485760, # 10MB
'network_tx_bytes': 5242880, # 5MB
'uptime': 3600000 # 1 hour in milliseconds
}
}
}
@pytest.fixture
def mock_discord_interaction():
"""
Create a mock Discord interaction with properly configured user roles.
Returns:
AsyncMock: A mocked Discord interaction object
"""
interaction = AsyncMock(spec=discord.Interaction)
interaction.user = Mock()
interaction.user.name = 'TestUser'
# Create mock role with proper name attribute
mock_role = Mock()
mock_role.name = "Game Server User"
interaction.user.roles = [mock_role]
interaction.guild_id = 123456789
interaction.channel = Mock()
interaction.channel.id = 987654321
interaction.response = AsyncMock()
interaction.followup = AsyncMock()
return interaction
@pytest.fixture
def mock_bot(mock_config, mock_panel_api):
"""
Create a mock bot instance for testing.
Returns:
PterodactylBot: A mocked bot instance
"""
intents = discord.Intents.default()
intents.message_content = True
bot = PterodactylBot(mock_config, command_prefix="!", intents=intents)
bot.api = mock_panel_api
bot.server_cache = {}
# Don't override embed_locations - let it use the defaultdict from EmbedManager
bot.previous_states = defaultdict(dict)
return bot
# ==========================================
# CONFIGURATION VALIDATION TESTS
# ==========================================
class TestConfigValidation:
"""Test configuration validation logic."""
def test_valid_config(self, mock_config):
"""
Test that valid configuration object has expected values.
Args:
mock_config: Pytest fixture providing valid config
"""
# Use the mock_config fixture directly
assert mock_config.pterodactyl_url == 'https://panel.example.com'
assert mock_config.client_api_key == 'ptlc_test_client_key_123'
assert mock_config.application_api_key == 'ptla_test_app_key_456'
assert mock_config.discord_token == 'test_discord_token'
assert mock_config.allowed_guild_id == 123456789
assert mock_config.required_role == "Game Server User"
assert mock_config.update_interval == 10
assert isinstance(mock_config.embed_storage_path, Path)
def test_pelican_panel_api_keys(self, mock_config):
"""
Test is_pelican_panel property with Pelican Panel API keys.
Args:
mock_config: Pytest fixture providing valid config
"""
mock_config.client_api_key = 'pacc_test_client_key_123'
mock_config.application_api_key = 'papp_test_app_key_456'
assert mock_config.is_pelican_panel is True
assert mock_config.get_panel_display_name() == "Pelican Panel"
def test_pterodactyl_api_keys(self, mock_config):
"""
Test is_pelican_panel property with Pterodactyl API keys.
Args:
mock_config: Pytest fixture providing valid config
"""
mock_config.client_api_key = 'ptlc_test_client_key_123'
mock_config.application_api_key = 'ptla_test_app_key_456'
assert mock_config.is_pelican_panel is False
assert mock_config.get_panel_display_name() == "Pterodactyl"
# ==========================================
# PANEL API TESTS
# ==========================================
class TestPanelAPI:
"""Test Panel API client functionality."""
@pytest.mark.asyncio
async def test_initialize(self, mock_config):
"""
Test API client initialization.
Verifies that the API client properly creates an aiohttp session
"""
api = PanelAPI(mock_config)
await api.initialize()
assert api.session is not None
assert isinstance(api.session, aiohttp.ClientSession)
assert api._is_initialized is True
await api.close()
@pytest.mark.asyncio
async def test_close(self, mock_panel_api):
"""
Test API client cleanup properly calls session.close().
Args:
mock_panel_api: Pytest fixture providing mocked API instance
"""
# Ensure the session is marked as not closed
mock_panel_api.session.closed = False
await mock_panel_api.close()
# Verify close was called once
mock_panel_api.close.assert_called_once()
# The mock's _is_initialized will be a Mock, verify close was called
mock_panel_api.close.assert_awaited()
@pytest.mark.asyncio
async def test_request_success(self, mock_panel_api):
"""
Test successful API request with properly mocked context manager.
Args:
mock_panel_api: Pytest fixture providing mocked API instance
"""
# Configure the mock _request to return expected result
mock_panel_api._request.return_value = {'data': 'test'}
result = await mock_panel_api._request('GET', 'test/endpoint')
assert result == {'data': 'test'}
mock_panel_api._request.assert_called_once_with('GET', 'test/endpoint')
@pytest.mark.asyncio
async def test_request_error(self, mock_panel_api):
"""
Test API request with error response.
Args:
mock_panel_api: Pytest fixture providing mocked API instance
"""
from exceptions import APIError
# Configure the mock _request to raise APIError
mock_panel_api._request.side_effect = APIError("Not found", "test/endpoint", 404)
with pytest.raises(APIError) as exc_info:
await mock_panel_api._request('GET', 'test/endpoint')
assert exc_info.value.status_code == 404
assert "Not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_get_servers(self, mock_panel_api, sample_server_data):
"""
Test fetching server list.
Args:
mock_panel_api: Pytest fixture providing mocked API instance
sample_server_data: Pytest fixture providing sample server data
"""
mock_panel_api.get_servers.return_value = [sample_server_data]
servers = await mock_panel_api.get_servers()
assert len(servers) == 1
assert servers[0]['attributes']['identifier'] == 'abc123'
mock_panel_api.get_servers.assert_called_once()
@pytest.mark.asyncio
async def test_get_server_resources(self, mock_panel_api, sample_resources_data):
"""
Test fetching server resources.
Args:
mock_panel_api: Pytest fixture providing mocked API instance
sample_resources_data: Pytest fixture providing sample resource data
"""
mock_panel_api.get_server_resources.return_value = sample_resources_data
result = await mock_panel_api.get_server_resources('abc123')
assert result['attributes']['current_state'] == 'running'
assert result['attributes']['resources']['cpu_absolute'] == 45.5
mock_panel_api.get_server_resources.assert_called_once_with('abc123')
@pytest.mark.asyncio
async def test_send_power_action_valid(self, mock_panel_api):
"""
Test sending valid power action.
Args:
mock_panel_api: Pytest fixture providing mocked API instance
"""
mock_panel_api.send_power_action.return_value = {'status': 'success'}
result = await mock_panel_api.send_power_action('abc123', 'start')
assert result == {'status': 'success'}
mock_panel_api.send_power_action.assert_called_once_with('abc123', 'start')
@pytest.mark.asyncio
async def test_send_power_action_invalid(self, mock_panel_api):
"""
Test sending invalid power action.
Args:
mock_panel_api: Pytest fixture providing mocked API instance
"""
# The actual implementation raises ValueError for invalid actions
# We can test that our mock properly returns an error dict
mock_panel_api.send_power_action.return_value = {
'status': 'error',
'message': "Invalid action. Must be one of: ['start', 'stop', 'restart']"
}
result = await mock_panel_api.send_power_action('abc123', 'invalid_action')
assert result['status'] == 'error'
assert 'Invalid action' in result['message']
mock_panel_api.send_power_action.assert_called_once_with('abc123', 'invalid_action')
@pytest.mark.asyncio
async def test_get_server_allocations(self, mock_panel_api):
"""
Test fetching server allocations.
Args:
mock_panel_api: Pytest fixture providing mocked API instance
"""
mock_panel_api.get_server_allocations.return_value = {
'data': [{
'attributes': {
'ip_alias': '192.168.1.100',
'port': 25565,
'is_default': True
}
}]
}
result = await mock_panel_api.get_server_allocations('abc123')
assert 'data' in result
assert len(result['data']) == 1
mock_panel_api.get_server_allocations.assert_called_once_with('abc123')
# ==========================================
# SERVER METRICS GRAPHS TESTS
# ==========================================
class TestServerMetricsGraphs:
"""Test ServerMetricsGraphs class."""
def test_initialization(self):
"""Test ServerMetricsGraphs initialization."""
graphs = ServerMetricsGraphs('test_server', 'Test Server')
assert graphs.server_id == 'test_server'
assert graphs.server_name == 'Test Server'
assert len(graphs.data_points) == 0
assert graphs.has_sufficient_data is False
def test_add_data_point(self):
"""Test adding data points."""
graphs = ServerMetricsGraphs('test_server', 'Test Server')
timestamp = datetime(2024, 1, 1, 12, 0, 0)
graphs.add_data_point(50.0, 1024.0, timestamp)
assert len(graphs.data_points) == 1
assert graphs.data_points[0] == (timestamp, 50.0, 1024.0)
assert graphs.has_sufficient_data is False
# Add second point
graphs.add_data_point(60.0, 1024.0)
assert graphs.has_sufficient_data is True
def test_data_rotation(self):
"""Test data rotation with maxlen=6."""
graphs = ServerMetricsGraphs('test_server', 'Test Server')
# Add 7 data points
for i in range(7):
graphs.add_data_point(float(i * 10), float(1000 + i * 100))
assert len(graphs.data_points) == 6 # Should only keep last 6
assert graphs.data_points[0][1] == 10.0 # First point rotated out
def test_cpu_scale_calculation(self):
"""Test CPU scale limit calculation."""
graphs = ServerMetricsGraphs('test_server', 'Test Server')
# Test single vCPU
assert graphs._calculate_cpu_scale_limit(50) == 100
assert graphs._calculate_cpu_scale_limit(100) == 100
# Test multi-vCPU
assert graphs._calculate_cpu_scale_limit(101) == 200
assert graphs._calculate_cpu_scale_limit(250) == 300
assert graphs._calculate_cpu_scale_limit(350) == 400
def test_get_data_summary(self):
"""Test data summary generation."""
graphs = ServerMetricsGraphs('test_server', 'Test Server')
# Empty summary
summary = graphs.get_data_summary()
assert summary['point_count'] == 0
assert summary['has_data'] is False
# Add data
graphs.add_data_point(50.0, 1024.0)
graphs.add_data_point(60.0, 1024.0)
summary = graphs.get_data_summary()
assert summary['point_count'] == 2
assert summary['has_data'] is True
assert summary['latest_cpu'] == 60.0
assert summary['latest_memory'] == 1024.0
assert summary['cpu_trend'] == 'increasing'
class TestServerMetricsManager:
"""Test ServerMetricsManager class."""
def test_initialization(self):
"""Test ServerMetricsManager initialization."""
manager = ServerMetricsManager()
assert manager.server_graphs == {}
def test_get_or_create_server_graphs(self):
"""Test getting or creating server graphs."""
manager = ServerMetricsManager()
graphs1 = manager.get_or_create_server_graphs('server1', 'Server 1')
graphs2 = manager.get_or_create_server_graphs('server1', 'Server 1')
assert graphs1 is graphs2 # Should return same instance
assert len(manager.server_graphs) == 1
def test_add_server_data(self):
"""Test adding data to server metrics."""
manager = ServerMetricsManager()
manager.add_server_data('server1', 'Server 1', 50.0, 1024.0)
graphs = manager.get_server_graphs('server1')
assert graphs is not None
assert len(graphs.data_points) == 1
def test_remove_server(self):
"""Test removing server from metrics."""
manager = ServerMetricsManager()
manager.get_or_create_server_graphs('server1', 'Server 1')
manager.remove_server('server1')
assert 'server1' not in manager.server_graphs
def test_cleanup_old_servers(self):
"""Test cleaning up inactive servers."""
manager = ServerMetricsManager()
manager.get_or_create_server_graphs('server1', 'Server 1')
manager.get_or_create_server_graphs('server2', 'Server 2')
manager.get_or_create_server_graphs('server3', 'Server 3')
manager.cleanup_old_servers(['server1', 'server3'])
assert 'server1' in manager.server_graphs
assert 'server2' not in manager.server_graphs
assert 'server3' in manager.server_graphs
def test_get_summary(self):
"""Test getting manager summary."""
manager = ServerMetricsManager()
manager.get_or_create_server_graphs('server1', 'Server 1')
manager.get_or_create_server_graphs('server2', 'Server 2')
# Add data to one server
manager.add_server_data('server1', 'Server 1', 50.0, 1024.0)
manager.add_server_data('server1', 'Server 1', 60.0, 1024.0)
summary = manager.get_summary()
assert summary['total_servers'] == 2
assert summary['servers_with_data'] == 1
assert summary['total_data_points'] == 2
# ==========================================
# VIEWS TESTS
# ==========================================
class TestServerStatusView:
"""Test ServerStatusView class."""
def test_view_initialization(self, mock_panel_api, sample_server_data):
"""Test ServerStatusView initialization."""
view = ServerStatusView(
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data,
guild_id=123456789
)
assert view.server_id == 'abc123'
assert view.server_name == 'Test Server'
assert view.api is mock_panel_api
assert view.timeout is None # Persistent view
assert view.guild_id == 123456789
@pytest.mark.asyncio
async def test_interaction_check_authorized(self, mock_config, mock_panel_api, sample_server_data, mock_discord_interaction):
"""Test interaction check with authorized user."""
view = ServerStatusView(
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data,
guild_id=123456789
)
result = await view.interaction_check(mock_discord_interaction)
assert result is True
@pytest.mark.asyncio
async def test_interaction_check_wrong_guild(self, mock_config, mock_panel_api, sample_server_data, mock_discord_interaction):
"""Test interaction check with wrong guild."""
view = ServerStatusView(
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data,
guild_id=123456789
)
mock_discord_interaction.guild_id = 999999999
result = await view.interaction_check(mock_discord_interaction)
assert result is False
mock_discord_interaction.response.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_interaction_check_no_role(self, mock_config, mock_panel_api, sample_server_data, mock_discord_interaction):
"""Test interaction check without required role."""
view = ServerStatusView(
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data,
guild_id=123456789
)
# Remove required role
mock_discord_interaction.user.roles = []
result = await view.interaction_check(mock_discord_interaction)
assert result is False
mock_discord_interaction.response.send_message.assert_called_once()
# ==========================================
# BOT TESTS
# ==========================================
class TestPterodactylBot:
"""Test PterodactylBot class."""
def test_bot_initialization(self, mock_config):
"""Test bot initialization."""
intents = discord.Intents.default()
intents.message_content = True
bot = PterodactylBot(mock_config, command_prefix="!", intents=intents)
assert bot.config is mock_config
assert bot.api is not None
assert bot.metrics_manager is not None
assert bot.embed_manager is not None
assert bot.server_cache == {}
# previous_states is now a defaultdict
assert isinstance(bot.previous_states, dict)
@pytest.mark.asyncio
async def test_track_new_embed(self, mock_bot, mock_discord_interaction):
"""Test tracking new embed."""
mock_message = AsyncMock()
mock_message.channel.id = 987654321
mock_message.id = 123456789
await mock_bot.embed_manager.track_embed(123456789, 'abc123', mock_message)
assert 123456789 in mock_bot.embed_manager.embed_locations
assert 'abc123' in mock_bot.embed_manager.embed_locations[123456789]
assert mock_bot.embed_manager.embed_locations[123456789]['abc123']['channel_id'] == '987654321'
assert mock_bot.embed_manager.embed_locations[123456789]['abc123']['message_id'] == '123456789'
@pytest.mark.asyncio
async def test_load_embed_locations(self, mock_bot, tmp_path):
"""Test loading embed locations from file."""
# Create a temporary embed locations file with new multi-guild format
embed_file = tmp_path / "embed_locations.json"
embed_file.write_text(json.dumps({
"123456789": {
"server1": {"channel_id": "123", "message_id": "456"}
}
}))
mock_bot.embed_manager.storage_path = embed_file
await mock_bot.embed_manager.load_locations()
assert 123456789 in mock_bot.embed_manager.embed_locations
assert "server1" in mock_bot.embed_manager.embed_locations[123456789]
@pytest.mark.asyncio
async def test_save_embed_locations(self, mock_bot, tmp_path):
"""Test saving embed locations to file."""
embed_file = tmp_path / "embed_locations.json"
mock_bot.embed_manager.storage_path = embed_file
mock_bot.embed_manager.embed_locations = {
123456789: {
"server1": {"channel_id": "123", "message_id": "456"}
}
}
await mock_bot.embed_manager.save_locations()
saved_data = json.loads(embed_file.read_text())
assert "123456789" in saved_data
assert "server1" in saved_data["123456789"]
# ==========================================
# INTEGRATION TESTS
# ==========================================
class TestIntegration:
"""Integration tests for combined functionality."""
@pytest.mark.asyncio
async def test_metrics_collection_and_graphing(self):
"""Test metrics collection and graph generation flow."""
manager = ServerMetricsManager()
# Simulate collecting data for a running server
for i in range(6):
manager.add_server_data('server1', 'Test Server', 50.0 + i * 5, 1024.0 + i * 10)
graphs = manager.get_server_graphs('server1')
# Generate combined graph
combined_graph = graphs.generate_combined_graph()
assert combined_graph is not None
assert hasattr(combined_graph, 'read') # BytesIO-like object
@pytest.mark.asyncio
async def test_embed_creation(self, mock_bot, sample_server_data, sample_resources_data):
"""Test server status embed creation."""
from models import ServerData, ServerResources
server_obj = ServerData.model_validate(sample_server_data)
resources_obj = ServerResources.model_validate(sample_resources_data)
embed, view = await mock_bot.embed_manager.create_server_embed(server_obj, resources_obj, 123456789)
assert embed is not None
assert view is not None
assert isinstance(embed, discord.Embed)
assert 'Test Server' in embed.title
if __name__ == '__main__':
pytest.main([__file__, '-v'])
+345
View File
@@ -0,0 +1,345 @@
"""
Discord Views Module
Contains all Discord UI views and interactive components.
Handles server control buttons, dropdowns, and permission checks.
Supports multi-guild deployments with per-guild validation.
"""
import logging
from typing import TYPE_CHECKING
import discord
from discord.ext import commands
from config import Config
from exceptions import PermissionError
if TYPE_CHECKING:
from api import PanelAPI
logger = logging.getLogger("pterodisbot")
class ServerStatusView(discord.ui.View):
"""
Interactive view containing server control buttons.
Provides persistent controls for server management with role-based access.
Buttons: Start (green), Stop (red), Restart (blurple), Show Address (grey).
Features:
- Guild restriction (only works in the guild the embed was created in)
- Role-based access control (requires "Game Server User" role in that guild)
- Persistent views (timeout=None for infinite persistence)
- Comprehensive error handling with user feedback
- Per-server action locking to prevent race conditions across guilds
"""
def __init__(
self,
server_id: str,
server_name: str,
api: "PanelAPI",
server_data: dict,
guild_id: int,
) -> None:
"""
Initialize the server status view with control buttons.
Args:
server_id: The server's panel identifier
server_name: Human-readable server name
api: Panel API client instance
server_data: Full server data from panel
guild_id: Discord guild ID this view belongs to
"""
super().__init__(timeout=None) # Persistent view
self.server_id = server_id
self.server_name = server_name
self.api = api
self.server_data = server_data
self.guild_id = guild_id
logger.debug(f"Created ServerStatusView for {server_name} ({server_id}) in guild {guild_id}")
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""
Verify the interacting user has the required role and is in the correct guild.
This check ensures:
1. The interaction is from the same guild the embed was created in
2. The guild is in the allowed guilds list
3. The user has the required role in that guild
Args:
interaction: Discord interaction object
Returns:
bool: True if authorized, False otherwise
"""
config = Config.get_instance()
# First check if interaction is from the SAME guild as this view
if interaction.guild_id != self.guild_id:
logger.warning(
f"Interaction from wrong guild: expected {self.guild_id}, got {interaction.guild_id} "
f"for server {self.server_name} by {interaction.user.name}"
)
await interaction.response.send_message(
"This control panel belongs to a different server.", ephemeral=True
)
return False
# Then check if the guild is in the allowed list
if interaction.guild_id not in config.allowed_guild_ids:
logger.warning(f"Unauthorized interaction attempt from guild {interaction.guild_id}")
await interaction.response.send_message(
"This bot is not available in this server.", ephemeral=True
)
return False
# Then check for required role in THIS guild
logger.debug(f"Checking permissions for {interaction.user.name} on server {self.server_name} in guild {interaction.guild_id}")
has_role = any(role.name == config.required_role for role in interaction.user.roles)
if not has_role:
logger.warning(
f"Permission denied for {interaction.user.name} in guild {interaction.guild_id} - "
f"missing '{config.required_role}' role"
)
await interaction.response.send_message(
f"You don't have permission to control servers. You need the '{config.required_role}' role in this server.",
ephemeral=True,
)
return False
logger.debug(f"Permission granted for {interaction.user.name} in guild {interaction.guild_id}")
return True
async def on_error(
self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item
) -> None:
"""
Handle errors in button interactions.
Args:
interaction: Discord interaction object
error: Exception that occurred
item: The UI item that triggered the error
"""
logger.error(f"View error in {self.server_name} (guild {self.guild_id}) by {interaction.user.name}: {str(error)}")
if not interaction.response.is_done():
await interaction.response.send_message(
"An error occurred while processing your request.", ephemeral=True
)
else:
await interaction.followup.send(
"An error occurred while processing your request.", ephemeral=True
)
@discord.ui.button(
label="Start", style=discord.ButtonStyle.green, custom_id="start_button"
)
async def start_button(
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
"""Send a start command to the server with cross-guild embed update."""
await self._handle_power_action(interaction, "start")
@discord.ui.button(
label="Stop", style=discord.ButtonStyle.red, custom_id="stop_button"
)
async def stop_button(
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
"""Send a stop command to the server with cross-guild embed update."""
await self._handle_power_action(interaction, "stop")
@discord.ui.button(
label="Restart", style=discord.ButtonStyle.blurple, custom_id="restart_button"
)
async def restart_button(
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
"""Send a restart command to the server with cross-guild embed update."""
await self._handle_power_action(interaction, "restart")
async def _handle_power_action(self, interaction: discord.Interaction, action: str) -> None:
"""
Handle a power action (start/stop/restart) with proper locking and cross-guild updates.
Uses a per-server lock to serialize concurrent power actions from multiple guilds,
then triggers an immediate embed update across all guilds tracking this server.
Args:
interaction: Discord interaction object
action: Power action to perform (start, stop, restart)
"""
from bot import PterodactylBot
bot = PterodactylBot.get_instance()
logger.info(f"{action.capitalize()} button pressed for {self.server_name} by {interaction.user.name} in guild {interaction.guild_id}")
# Acquire per-server lock to serialize concurrent actions across guilds
async with bot.get_server_action_lock(self.server_id):
await interaction.response.defer(ephemeral=True)
result = await self.api.send_power_action(self.server_id, action)
if result.get("status") == "success":
message = f"Server '{self.server_name}' is {action}ing..."
logger.info(f"Successfully executed {action} on server {self.server_name}")
else:
message = f"Failed to {action} server: {result.get('message', 'Unknown error')}"
logger.error(f"Failed to {action} server {self.server_name}: {message}")
await interaction.followup.send(message, ephemeral=True)
# Trigger immediate embed update for ALL guilds tracking this server
# This ensures all guilds see the state change promptly
await bot.trigger_embed_update_for_server(self.server_id)
@discord.ui.button(
label="Show Address",
style=discord.ButtonStyle.grey,
custom_id="show_address_button",
)
async def show_address_button(
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
"""Show server's default allocation IP and port."""
logger.info(f"Show Address button pressed for {self.server_name} by {interaction.user.name} in guild {interaction.guild_id}")
try:
await interaction.response.defer(ephemeral=True)
logger.debug(f"Fetching server details for {self.server_id}")
ip_alias, port = await self.api.get_server_default_allocation(self.server_id)
# Create and send embed
embed = discord.Embed(
title=f"{self.server_name} Connection Info",
color=discord.Color.blue(),
description=f"Server ID: `{self.server_id}`",
)
embed.add_field(name="Address", value=f"`{ip_alias}`", inline=True)
embed.add_field(name="Port", value=f"`{port}`", inline=True)
await interaction.followup.send(embed=embed, ephemeral=True)
logger.info(f"Displayed connection info for {self.server_name}")
except Exception as e:
logger.error(f"Failed to show address for {self.server_name}: {str(e)}")
await interaction.followup.send(
"⚠️ Failed to get connection info. The server may not have any ports allocated.",
ephemeral=True,
)
class ServerDropdown(discord.ui.Select):
"""Dropdown menu for server selection in status dashboard."""
def __init__(self, server_options: list) -> None:
"""
Initialize the server dropdown.
Args:
server_options: List of discord.SelectOption objects
"""
super().__init__(
placeholder="Select a server to display...",
options=server_options,
min_values=1,
max_values=1,
)
async def callback(self, interaction: discord.Interaction) -> None:
"""
Handle server selection from dropdown menu.
Creates permanent status embed in the channel for the selected server
in the CURRENT guild (where the interaction occurred).
"""
from bot import PterodactylBot
await interaction.response.defer(ephemeral=True)
bot = PterodactylBot.get_instance()
selected_server_id = self.values[0]
server_data = bot.server_cache.get(selected_server_id)
if not server_data:
await interaction.followup.send(
"❌ Selected server no longer available. Please try again.", ephemeral=True
)
return
server_name = server_data["attributes"]["name"]
guild_id = interaction.guild_id
logger.info(f"User {interaction.user.name} selected server: {server_name} in guild {guild_id}")
try:
# Get current server status for embed creation
resources = await bot.api.get_server_resources(selected_server_id)
# Delete old embed if it exists in THIS guild to prevent duplication
guild_locations = bot.embed_manager.get_guild_locations(guild_id)
if selected_server_id in guild_locations:
logger.debug(f"Found existing embed for {selected_server_id} in guild {guild_id}, attempting to delete")
try:
old_location = guild_locations[selected_server_id]
old_channel = bot.get_channel(int(old_location["channel_id"]))
if old_channel:
# Verify channel belongs to the correct guild
if old_channel.guild.id != guild_id:
logger.warning(f"Old embed channel not in guild {guild_id}, skipping delete")
else:
try:
old_message = await old_channel.fetch_message(
int(old_location["message_id"])
)
await old_message.delete()
logger.debug(f"Deleted old embed for {selected_server_id} in guild {guild_id}")
except discord.NotFound:
logger.debug(f"Old embed for {selected_server_id} in guild {guild_id} already deleted")
except Exception as e:
logger.error(f"Failed to delete old embed: {str(e)}")
# Create and send new permanent status embed in channel
from models import ServerData, ServerResources
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
embed, view = await bot.embed_manager.create_server_embed(server_obj, resources_obj, guild_id)
message = await interaction.channel.send(embed=embed, view=view)
await bot.embed_manager.track_embed(guild_id, selected_server_id, message)
await interaction.followup.send(
f"✅ **{server_name}** status has been posted in {interaction.channel.mention}",
ephemeral=True,
)
logger.info(f"Successfully posted status for {server_name} in guild {guild_id}")
except Exception as e:
logger.error(f"Failed to create status embed for {server_name} in guild {guild_id}: {str(e)}")
await interaction.followup.send(
f"❌ Failed to create status embed for **{server_name}**: {str(e)}", ephemeral=True
)
class ServerDropdownView(discord.ui.View):
"""View containing the server dropdown with timeout for auto-cleanup."""
def __init__(self, server_options: list, timeout: int = 180) -> None:
"""
Initialize dropdown view with timeout.
Args:
server_options: List of discord.SelectOption objects
timeout: Timeout in seconds (default 3 minutes for ephemeral cleanup)
"""
super().__init__(timeout=timeout)
self.server_options = server_options
self.add_item(ServerDropdown(server_options))
async def on_timeout(self) -> None:
"""Clean up when dropdown times out (ephemeral auto-removal)."""
logger.debug("Server dropdown timed out and was automatically cleaned up")