Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2cb18634f |
No files matched your search
+47
-103
@@ -31,36 +31,30 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.11', '3.12', '3.13']
|
python-version: ['3.9', '3.10', '3.11']
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
fetch-depth: 50
|
|
||||||
|
|
||||||
- name: Configure Git
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
run: |
|
uses: actions/setup-python@v4
|
||||||
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:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
activate-environment: true
|
|
||||||
enable-cache: true
|
- name: Cache pip dependencies
|
||||||
cache-dependency-glob: |
|
uses: actions/cache@v3
|
||||||
requirements.txt
|
with:
|
||||||
requirements-test.txt
|
path: ~/.cache/pip
|
||||||
|
key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-py${{ matrix.python-version }}-pip-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
uv pip install --upgrade setuptools wheel
|
python -m pip install --upgrade pip setuptools wheel
|
||||||
uv pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
uv pip install -r requirements-test.txt
|
pip install -r requirements-test.txt
|
||||||
|
|
||||||
- name: Create test configuration
|
- name: Create test configuration
|
||||||
run: |
|
run: |
|
||||||
@@ -70,9 +64,6 @@ jobs:
|
|||||||
PanelURL = https://panel.example.com
|
PanelURL = https://panel.example.com
|
||||||
ClientAPIKey = ptlc_test_client_key_123456789
|
ClientAPIKey = ptlc_test_client_key_123456789
|
||||||
ApplicationAPIKey = ptla_test_app_key_987654321
|
ApplicationAPIKey = ptla_test_app_key_987654321
|
||||||
# Pelican Panel test keys
|
|
||||||
# ClientAPIKey = pacc_test_client_key_123456789
|
|
||||||
# ApplicationAPIKey = papp_test_app_key_987654321
|
|
||||||
|
|
||||||
[Discord]
|
[Discord]
|
||||||
Token = test_discord_token_placeholder
|
Token = test_discord_token_placeholder
|
||||||
@@ -81,16 +72,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests with coverage
|
- name: Run unit tests with coverage
|
||||||
run: |
|
run: |
|
||||||
python -m pytest test_pterodisbot.py \
|
pytest test_pterodisbot.py \
|
||||||
-v \
|
-v \
|
||||||
--tb=short \
|
--tb=short \
|
||||||
--cov=config \
|
--cov=pterodisbot \
|
||||||
--cov=api \
|
--cov=server_metrics_graphs \
|
||||||
--cov=embeds \
|
|
||||||
--cov=views \
|
|
||||||
--cov=bot \
|
|
||||||
--cov=metrics \
|
|
||||||
--cov=models \
|
|
||||||
--cov-report=xml \
|
--cov-report=xml \
|
||||||
--cov-report=term \
|
--cov-report=term \
|
||||||
--cov-report=html \
|
--cov-report=html \
|
||||||
@@ -113,31 +99,20 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
fetch-depth: 50
|
|
||||||
|
|
||||||
- name: Configure Git
|
- name: Set up Python
|
||||||
run: |
|
uses: actions/setup-python@v4
|
||||||
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:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.11'
|
||||||
activate-environment: true
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- name: Install linting tools
|
- name: Install linting tools
|
||||||
run: |
|
run: |
|
||||||
uv pip install --upgrade setuptools wheel
|
python -m pip install --upgrade pip
|
||||||
uv pip install flake8 pylint black isort mypy
|
pip install flake8 pylint black isort mypy
|
||||||
|
|
||||||
- name: Run flake8
|
- name: Run flake8
|
||||||
run: |
|
run: |
|
||||||
flake8 config.py api.py embeds.py views.py bot.py metrics.py models.py \
|
flake8 pterodisbot.py server_metrics_graphs.py \
|
||||||
--max-line-length=140 \
|
--max-line-length=140 \
|
||||||
--ignore=E501,W503,E203 \
|
--ignore=E501,W503,E203 \
|
||||||
--exclude=venv,__pycache__,build,dist \
|
--exclude=venv,__pycache__,build,dist \
|
||||||
@@ -147,7 +122,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
pylint config.py api.py embeds.py views.py bot.py metrics.py models.py \
|
pylint pterodisbot.py server_metrics_graphs.py \
|
||||||
--disable=C0111,C0103,R0913,R0914,R0915,W0718 \
|
--disable=C0111,C0103,R0913,R0914,R0915,W0718 \
|
||||||
--max-line-length=140 \
|
--max-line-length=140 \
|
||||||
--output-format=text \
|
--output-format=text \
|
||||||
@@ -156,17 +131,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Check code formatting with black
|
- name: Check code formatting with black
|
||||||
run: |
|
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
|
black --check --line-length=140 --diff pterodisbot.py server_metrics_graphs.py | tee black-report.txt
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Check import ordering
|
- name: Check import ordering
|
||||||
run: |
|
run: |
|
||||||
isort --check-only --profile black --line-length=140 config.py api.py embeds.py views.py bot.py metrics.py models.py
|
isort --check-only --profile black --line-length=140 pterodisbot.py server_metrics_graphs.py
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Type checking with mypy
|
- name: Type checking with mypy
|
||||||
run: |
|
run: |
|
||||||
mypy config.py api.py embeds.py views.py bot.py metrics.py models.py --ignore-missing-imports > mypy-report.txt || true
|
mypy pterodisbot.py server_metrics_graphs.py --ignore-missing-imports > mypy-report.txt || true
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Upload linting reports
|
- name: Upload linting reports
|
||||||
@@ -187,31 +162,20 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
fetch-depth: 50
|
|
||||||
|
|
||||||
- name: Configure Git
|
- name: Set up Python
|
||||||
run: |
|
uses: actions/setup-python@v4
|
||||||
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:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.11'
|
||||||
activate-environment: true
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- name: Install security tools
|
- name: Install security tools
|
||||||
run: |
|
run: |
|
||||||
uv pip install --upgrade setuptools wheel
|
python -m pip install --upgrade pip
|
||||||
uv pip install bandit safety pip-audit
|
pip install bandit safety pip-audit
|
||||||
|
|
||||||
- name: Run bandit security scan
|
- name: Run bandit security scan
|
||||||
run: |
|
run: |
|
||||||
bandit -r config.py api.py embeds.py views.py bot.py metrics.py models.py \
|
bandit -r . \
|
||||||
-f json \
|
-f json \
|
||||||
-o bandit-report.json \
|
-o bandit-report.json \
|
||||||
-ll \
|
-ll \
|
||||||
@@ -220,7 +184,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run safety dependency check
|
- name: Run safety dependency check
|
||||||
run: |
|
run: |
|
||||||
uv pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
safety check --json --output safety-report.json || true
|
safety check --json --output safety-report.json || true
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -247,31 +211,23 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
fetch-depth: 50
|
|
||||||
|
|
||||||
- name: Configure Git
|
- name: Set up Python
|
||||||
run: |
|
uses: actions/setup-python@v4
|
||||||
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:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.11'
|
||||||
activate-environment: true
|
|
||||||
enable-cache: true
|
- name: Cache dependencies
|
||||||
cache-dependency-glob: |
|
uses: actions/cache@v3
|
||||||
requirements.txt
|
with:
|
||||||
requirements-test.txt
|
path: ~/.cache/pip
|
||||||
|
key: ${{ runner.os }}-pip-integration-${{ hashFiles('requirements.txt') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
uv pip install --upgrade setuptools wheel
|
python -m pip install --upgrade pip
|
||||||
uv pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
uv pip install -r requirements-test.txt
|
pip install -r requirements-test.txt
|
||||||
|
|
||||||
- name: Create test configuration
|
- name: Create test configuration
|
||||||
run: |
|
run: |
|
||||||
@@ -281,9 +237,6 @@ jobs:
|
|||||||
PanelURL = https://panel.example.com
|
PanelURL = https://panel.example.com
|
||||||
ClientAPIKey = ptlc_test_client_key_123456789
|
ClientAPIKey = ptlc_test_client_key_123456789
|
||||||
ApplicationAPIKey = ptla_test_app_key_987654321
|
ApplicationAPIKey = ptla_test_app_key_987654321
|
||||||
# Pelican Panel test keys
|
|
||||||
# ClientAPIKey = pacc_test_client_key_123456789
|
|
||||||
# ApplicationAPIKey = papp_test_app_key_987654321
|
|
||||||
|
|
||||||
[Discord]
|
[Discord]
|
||||||
Token = test_discord_token_placeholder
|
Token = test_discord_token_placeholder
|
||||||
@@ -292,7 +245,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run integration tests
|
- name: Run integration tests
|
||||||
run: |
|
run: |
|
||||||
python -m pytest test_pterodisbot.py::TestIntegration \
|
pytest test_pterodisbot.py::TestIntegration \
|
||||||
-v \
|
-v \
|
||||||
--tb=short \
|
--tb=short \
|
||||||
--timeout=60
|
--timeout=60
|
||||||
@@ -313,15 +266,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
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
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Pterodisbot the Pterodactyl Discord Bot
|
# Pterodactyl Discord Bot
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -6,11 +6,10 @@ 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)
|
||||||
@@ -21,160 +20,58 @@ 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
|
||||||
|
|
||||||
### 🎮 Server Management
|
### Core Functionality
|
||||||
- **Interactive Server Selection**: Choose servers from an intuitive dropdown menu showing all available game servers
|
- **Real-time Server Monitoring**: Auto-updating embeds showing server status (online/offline) and detailed resource usage
|
||||||
- **Real-time Power Controls**: Start, stop, and restart servers with color-coded button controls
|
- **Power Management**: Start, stop, and restart servers directly from Discord with button controls
|
||||||
- 🟢 Green Start button
|
- **Connection Information**: One-click display of server addresses and ports
|
||||||
- 🔴 Red Stop button
|
- **Multi-channel Support**: Status embeds can be placed in any channel with proper permissions
|
||||||
- 🔵 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
|
|
||||||
|
|
||||||
### 📊 Server Monitoring
|
### Intelligent Updates
|
||||||
- **Live Status Tracking**: Auto-updating embeds display current server state (online/offline/starting)
|
- **State Change Detection**: Embeds update immediately when server power state changes
|
||||||
- **Resource Usage Metrics**: Detailed monitoring of:
|
- **Resource Thresholds**: CPU usage changes >50% trigger updates
|
||||||
- CPU usage with dynamic scaling for multi-core servers
|
- **Optimized API Calls**: Minimizes requests to the Discord API
|
||||||
- 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
|
|
||||||
|
|
||||||
### 🔄 Intelligent Update System
|
### Access Control
|
||||||
- **Smart Update Triggers**: Embeds refresh automatically when:
|
- **Guild Restriction**: Operates only in specified Discord server
|
||||||
- Server power state changes (offline ↔ starting ↔ running)
|
- **Role-based Permissions**: "Game Server User" role required for interactions
|
||||||
- CPU usage shifts by more than 50%
|
- **Admin Commands**: Special commands restricted to server administrators
|
||||||
- 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
|
|
||||||
|
|
||||||
### 🔒 Security & Access Control
|
### Reliability Features
|
||||||
- **Single-Guild Restriction**: Bot operates exclusively in your designated Discord server
|
- **Persistent Storage**: Remembers embed locations between bot restarts
|
||||||
- **Role-based Permissions**: "Game Server User" role required for all server interactions
|
- **Error Handling**: Comprehensive error recovery and logging
|
||||||
- **Administrator Commands**: Special management commands restricted to server administrators
|
- **Rate Limit Protection**: Built-in delays between API calls
|
||||||
- **Interaction Validation**: Every button press and command verified for proper authorization
|
|
||||||
|
|
||||||
### 💾 Data Persistence
|
## Architecture Overview
|
||||||
- **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
|
|
||||||
|
|
||||||
### 🛡️ Reliability Features
|
The bot is built with several key components:
|
||||||
- **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
|
|
||||||
|
|
||||||
### 🎨 User Interface
|
1. **PterodactylAPI Class**: Handles all communication with the Pterodactyl Panel API
|
||||||
- **Color-coded Status**: Visual indicators for server states
|
2. **ServerStatusView Class**: Manages the interactive Discord UI with control buttons
|
||||||
- 🔵 Blue for online servers
|
3. **PterodactylBot Class**: Main bot class that orchestrates all operations
|
||||||
- 🔴 Red for offline servers
|
4. **Background Task**: Regularly checks server status and updates embeds when needed
|
||||||
- **Ephemeral Interactions**: Private responses visible only to command users
|
5. **Configuration System**: Validates and manages all settings from config.ini
|
||||||
- **Real-time Feedback**: Instant confirmation messages for all actions
|
|
||||||
- **Persistent Controls**: Server control buttons remain functional indefinitely
|
|
||||||
- **Responsive Design**: Clean, Discord-optimized embed layouts
|
|
||||||
|
|
||||||
### 🔧 Administrator Tools
|
The system uses aiohttp for asynchronous HTTP requests and discord.py for Discord interactions.
|
||||||
- **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 (from setup above)
|
- Discord bot token with proper permissions
|
||||||
- Discord server (guild) ID (from setup above)
|
|
||||||
- Pterodactyl panel with API access
|
- Pterodactyl panel with API access
|
||||||
- Pterodactyl Client API key (starts with `ptlc_`)
|
- Server with both Client and Application API keys
|
||||||
- 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/pterodisbot.git
|
git clone https://git.serendipity.systems/k.eaven/pterodactyl-discord-bot.git
|
||||||
cd pterodactyl-discord-bot
|
cd pterodactyl-discord-bot
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -194,7 +91,7 @@ Before installing the bot software, you need to create a Discord application and
|
|||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.serendipity.systems/k.eaven/pterodisbot.git
|
git clone https://git.serendipity.systems/k.eaven/pterodactyl-discord-bot.git
|
||||||
cd pterodactyl-discord-bot
|
cd pterodactyl-discord-bot
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -224,13 +121,13 @@ Before installing the bot software, you need to create a Discord application and
|
|||||||
pterodisbot:
|
pterodisbot:
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
# Remove brackets and spaces in environment variables
|
# Remmove 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 from Developer Portal}
|
- DISCORD_TOKEN={Your Discord bot token}
|
||||||
- ALLOWED_GUILD_ID={Your Discord server ID from step 5 above}
|
- ALLOWED_GUILD_ID={The Discord server ID where the bot should operate}
|
||||||
- PANEL_URL={Your Pterodactyl panel URL with https://}
|
- PANEL_URL={Your Pterodactyl panel URL (must include http:// or https://)}
|
||||||
- CLIENT_API_KEY={Pterodactyl client API key starting with ptlc_}
|
- CLIENT_API_KEY={Pterodactyl client API key (starts with `ptlc_`)}
|
||||||
- APPLICATION_API_KEY={Pterodactyl application API key starting with ptla_}
|
- APPLICATION_API_KEY={Pterodactyl application API key (starts with `ptla_`)}
|
||||||
volumes:
|
volumes:
|
||||||
- bot_logs:/app/logs
|
- bot_logs:/app/logs
|
||||||
- bot_embed:/app/embed
|
- bot_embed:/app/embed
|
||||||
@@ -245,199 +142,13 @@ Before installing the bot software, you need to create a Discord application and
|
|||||||
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 (from Discord Developer Portal)
|
- `DISCORD_TOKEN`: Your Discord bot token
|
||||||
- `ALLOWED_GUILD_ID`: The Discord server ID where the bot should operate
|
- `ALLOWED_GUILD_ID`: The Discord server ID where the bot should operate
|
||||||
- `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_`)
|
||||||
@@ -467,251 +178,142 @@ python generate_config.py
|
|||||||
|
|
||||||
### First Run Setup
|
### First Run Setup
|
||||||
|
|
||||||
1. Ensure your bot is invited to your Discord server with proper permissions (see [Discord Bot Setup](#discord-bot-setup))
|
1. Invite your bot to your Discord server with these permissions:
|
||||||
|
- View Channels
|
||||||
|
- Send Messages
|
||||||
|
- Embed Links
|
||||||
|
- Manage Messages (for embed updates)
|
||||||
|
- Use Slash Commands
|
||||||
|
|
||||||
2. Verify the "Game Server User" role exists in your server
|
2. Create a "Game Server User" role for users who should control servers
|
||||||
|
|
||||||
3. Start the bot using your preferred installation method
|
3. Use the `/server_status` command to create your first status embed
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
#### `/server_status`
|
#### `/server_status`
|
||||||
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.
|
Display an interactive context menu containing all available servers which can be selected to create an embed for a given server.
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
#### `/refresh_embeds` (Admin only)
|
#### `/refresh_embeds` (Admin only)
|
||||||
Force a complete refresh of all server status embeds. This command:
|
Force a refresh of all server status embeds. This is useful if embeds become out of sync.
|
||||||
- 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)
|
||||||
Permanently delete all server status embeds from the Discord server. This command:
|
Delete all embeds from the Discord guild.
|
||||||
- Removes all tracked embed messages
|
|
||||||
- Clears the embed tracking file
|
|
||||||
- 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 dynamically display:
|
Status embeds include:
|
||||||
|
- 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)
|
||||||
|
|
||||||
**Header Information:**
|
Embed locations are stored in `embed_locations.json` and persist between bot restarts.
|
||||||
- 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 two levels of access control:
|
||||||
|
|
||||||
### 1. Guild Restriction
|
1. **Guild Restriction**: Only works in the Discord server specified in `AllowedGuildID`
|
||||||
The bot only responds to commands and interactions from the Discord server specified in `AllowedGuildID`. This prevents unauthorized use if the bot is accidentally added to other servers.
|
2. **Role Requirement**: Users must have the "Game Server User" role to interact with server controls
|
||||||
|
|
||||||
### 2. Role-Based Access
|
Administrators can use the `/refresh_embeds` and `/purge_embeds` command.
|
||||||
Users must have the **"Game Server User"** role (exact name match) to:
|
|
||||||
- Use server control buttons (Start/Stop/Restart)
|
|
||||||
- View server addresses
|
|
||||||
- Interact with any bot features
|
|
||||||
|
|
||||||
### 3. Administrator Access
|
|
||||||
Server administrators (users with Discord's Administrator permission) can additionally:
|
|
||||||
- Use `/refresh_embeds` command
|
|
||||||
- Use `/purge_embeds` command
|
|
||||||
- No special role required - based on Discord permissions
|
|
||||||
|
|
||||||
**Note:** The `/server_status` command can be used by anyone, but only users with the "Game Server User" role 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
|
1. **File Logs**: Rotating log files (5MB max, 3 backups) in `pterodactyl_bot.log`
|
||||||
- **Location**: `logs/pterodisbot.log`
|
2. **Console Output**: Real-time logging to stdout
|
||||||
- **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`
|
|
||||||
|
|
||||||
### 2. Console Output
|
Log levels:
|
||||||
- **Real-time**: Logs appear in stdout/stderr
|
- **DEBUG**: Detailed operational information (API calls, state changes)
|
||||||
- **Format**: Same as file logs
|
- **INFO**: General operational messages (bot startup, command usage)
|
||||||
- **Level**: INFO and above (less verbose than file logs)
|
- **WARNING**: Potential issues (missing embeds, API warnings)
|
||||||
|
- **ERROR**: Operational failures (API errors, permission issues)
|
||||||
### Log Levels
|
- **CRITICAL**: Critical failures (configuration errors, startup failures)
|
||||||
|
|
||||||
- **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:**
|
||||||
**Symptoms**: Embeds show outdated information or don't change when server state changes
|
- Verify the bot has proper channel permissions
|
||||||
|
- Check API keys have correct permissions
|
||||||
|
- Use `/refresh_embeds` to reset all embeds
|
||||||
|
|
||||||
**Solutions:**
|
**Buttons not working:**
|
||||||
- Verify the bot has proper channel permissions (View Channel, Send Messages, Embed Links)
|
- Confirm users have the "Game Server User" role
|
||||||
- Check that both API keys are valid and have correct permissions
|
- Ensure the bot has these 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)
|
|
||||||
|
|
||||||
#### Commands not appearing
|
**API errors:**
|
||||||
**Symptoms**: Slash commands don't show up when typing `/`
|
- Double-check your Pterodactyl panel URL
|
||||||
|
|
||||||
**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 panel
|
- Confirm the server exists in Pterodactyl
|
||||||
- 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
|
||||||
# Traditional installation
|
tail -f pterodactyl_bot.log
|
||||||
tail -f logs/pterodisbot.log
|
|
||||||
|
|
||||||
# Docker installation
|
|
||||||
docker logs -f pterodisbot
|
|
||||||
|
|
||||||
# Docker Compose
|
|
||||||
docker-compose logs -f pterodisbot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For searching logs:
|
## Development
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Python 3.8+
|
||||||
|
- Poetry (recommended) or pip
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
1. Clone the repository
|
||||||
|
2. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
# Find all ERROR level messages
|
poetry install
|
||||||
grep "ERROR" logs/pterodisbot.log
|
```
|
||||||
|
or
|
||||||
# Find logs for specific server
|
```bash
|
||||||
grep "Server_Name" logs/pterodisbot.log
|
pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
|
||||||
# View last 100 lines
|
|
||||||
tail -n 100 logs/pterodisbot.log
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Getting Help
|
3. Create a `.env` file with your development environment variables
|
||||||
|
|
||||||
If issues persist after trying these solutions:
|
4. Run in development mode:
|
||||||
|
```bash
|
||||||
|
python pterodisbot.py
|
||||||
|
```
|
||||||
|
|
||||||
1. Enable DEBUG logging by reviewing recent log files
|
### Testing
|
||||||
2. Check the [Git Issues](https://git.serendipity.systems/k.eaven/pterodactyl-discord-bot/issues) for similar problems
|
The project includes unit tests (to be expanded). Run with:
|
||||||
3. Verify your setup matches all requirements in [Prerequisites](#prerequisites)
|
```bash
|
||||||
4. Test each component individually (Discord bot, Pterodactyl API, permissions)
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please follow these guidelines:
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||||
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/your-feature`)
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
"""
|
|
||||||
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.2.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",
|
|
||||||
]
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,707 +0,0 @@
|
|||||||
"""
|
|
||||||
Main Bot Module
|
|
||||||
|
|
||||||
Core bot class and command definitions.
|
|
||||||
Manages the bot lifecycle, background tasks, and Discord interactions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, 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.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Background status update task with smart update detection
|
|
||||||
- Persistent embed tracking with JSON storage
|
|
||||||
- Real-time server monitoring with metrics graphs
|
|
||||||
- Role-based access control
|
|
||||||
- Single-guild restriction
|
|
||||||
- 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
|
|
||||||
self.server_cache: dict = {}
|
|
||||||
|
|
||||||
# Concurrency control
|
|
||||||
self.update_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
# State tracking for smart updates
|
|
||||||
# Format: {server_id: (state, cpu_usage, last_force_update)}
|
|
||||||
self.previous_states: dict = {}
|
|
||||||
|
|
||||||
logger.info("Initialized PterodactylBot instance with state tracking")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_instance(cls) -> Optional["PterodactylBot"]:
|
|
||||||
"""Get the singleton bot instance."""
|
|
||||||
return _bot_instance
|
|
||||||
|
|
||||||
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 when:
|
|
||||||
1. Server power state changes (started/stopped/restarted)
|
|
||||||
2. Significant CPU usage change (>50% difference)
|
|
||||||
3. First time seeing the server
|
|
||||||
4. Server has been running for 10 minutes (force update for uptime)
|
|
||||||
|
|
||||||
This minimizes API calls to Discord and updates while maintaining
|
|
||||||
real-time awareness of important server changes.
|
|
||||||
"""
|
|
||||||
logger.info("Starting optimized server status update cycle")
|
|
||||||
async with self.update_lock:
|
|
||||||
try:
|
|
||||||
# Fetch current server list from panel
|
|
||||||
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
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Update statistics
|
|
||||||
update_count = 0
|
|
||||||
error_count = 0
|
|
||||||
missing_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
current_time = datetime.now().timestamp()
|
|
||||||
|
|
||||||
# Process each server we're tracking embeds for
|
|
||||||
for server_id, location in list(self.embed_manager.embed_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")
|
|
||||||
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})")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
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
|
|
||||||
prev_state, prev_cpu, last_force_update = self.previous_states.get(
|
|
||||||
server_id, (None, 0, None)
|
|
||||||
)
|
|
||||||
|
|
||||||
# DECISION LOGIC: Should we update the embed?
|
|
||||||
needs_update = False
|
|
||||||
|
|
||||||
# 1. Check if power state changed (most important)
|
|
||||||
if current_state != prev_state:
|
|
||||||
logger.debug(
|
|
||||||
f"Power state changed for {server_name}: "
|
|
||||||
f"{prev_state} -> {current_state}"
|
|
||||||
)
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
# 2. Check for significant CPU change (only if server is running)
|
|
||||||
elif current_state == "running" and abs(cpu_usage - prev_cpu) > 50:
|
|
||||||
logger.debug(
|
|
||||||
f"Significant CPU change for {server_name}: "
|
|
||||||
f"{prev_cpu}% -> {cpu_usage}%"
|
|
||||||
)
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
# 3. First time we're seeing this server (initial update)
|
|
||||||
elif prev_state is None:
|
|
||||||
logger.debug(f"First check for {server_name}, performing initial update")
|
|
||||||
needs_update = True
|
|
||||||
|
|
||||||
# 4. Force update every 10 minutes for running servers (for uptime counter)
|
|
||||||
elif current_state == "running" and (
|
|
||||||
last_force_update is None
|
|
||||||
or current_time - last_force_update >= 600
|
|
||||||
): # 10 minutes = 600 seconds
|
|
||||||
logger.debug(f"Executing 10-minute force update for running server {server_name}")
|
|
||||||
needs_update = True
|
|
||||||
last_force_update = current_time
|
|
||||||
|
|
||||||
# PERFORM UPDATE IF NEEDED
|
|
||||||
if needs_update:
|
|
||||||
# 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(
|
|
||||||
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
|
|
||||||
new_last_force_update = (
|
|
||||||
last_force_update
|
|
||||||
if needs_update
|
|
||||||
and current_state == "running"
|
|
||||||
and current_time - (last_force_update or 0) >= 600
|
|
||||||
else last_force_update
|
|
||||||
)
|
|
||||||
self.previous_states[server_id] = (
|
|
||||||
current_state,
|
|
||||||
cpu_usage,
|
|
||||||
new_last_force_update,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# No significant changes, but update tracking with current state
|
|
||||||
self.previous_states[server_id] = (
|
|
||||||
current_state,
|
|
||||||
cpu_usage,
|
|
||||||
last_force_update,
|
|
||||||
)
|
|
||||||
skipped_count += 1
|
|
||||||
logger.debug(f"No changes detected for {server_name}, skipping update")
|
|
||||||
|
|
||||||
except discord.NotFound:
|
|
||||||
# Embed message was deleted - clean up tracking
|
|
||||||
logger.warning(f"Embed for server {server_id} not found, removing from tracking")
|
|
||||||
await self.embed_manager.untrack_embed(server_id)
|
|
||||||
missing_count += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update status for server {server_id}: {str(e)}")
|
|
||||||
error_count += 1
|
|
||||||
|
|
||||||
# Small delay between servers to avoid rate limits
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
# Log summary of this update cycle
|
|
||||||
logger.info(
|
|
||||||
f"Update cycle complete: "
|
|
||||||
f"{update_count} updated, "
|
|
||||||
f"{skipped_count} skipped, "
|
|
||||||
f"{missing_count} missing, "
|
|
||||||
f"{error_count} errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in update_status task: {str(e)}")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
@update_status.before_loop
|
|
||||||
async def before_update_status(self) -> None:
|
|
||||||
"""Wait for bot to be ready before starting update task."""
|
|
||||||
logger.debug("Waiting for bot readiness before starting update task")
|
|
||||||
await self.wait_until_ready()
|
|
||||||
await self.embed_manager.refresh_all_embeds()
|
|
||||||
|
|
||||||
@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 the allowed guild.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interaction: Discord interaction object
|
|
||||||
config: Application configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if interaction is allowed, False otherwise
|
|
||||||
"""
|
|
||||||
if interaction.guild_id != config.allowed_guild_id:
|
|
||||||
logger.warning(
|
|
||||||
f"Command attempted from unauthorized guild {interaction.guild_id} "
|
|
||||||
f"by {interaction.user.name}"
|
|
||||||
)
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"This bot is only available in a specific 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 != config.allowed_guild_id:
|
|
||||||
logger.debug(f"Ignoring interaction from unauthorized guild {interaction.guild_id}")
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"This bot is only available in a specific 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 only to the allowed guild
|
|
||||||
guild = discord.Object(id=bot.config.allowed_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 {bot.config.allowed_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
|
|
||||||
"""
|
|
||||||
if not await check_allowed_guild(interaction, config):
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Server status command invoked by {interaction.user.name}")
|
|
||||||
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
|
|
||||||
|
|
||||||
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} 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 (admin only)"
|
|
||||||
)
|
|
||||||
async def refresh_embeds(interaction: discord.Interaction) -> None:
|
|
||||||
"""Slash command to refresh all server embeds."""
|
|
||||||
if not await check_allowed_guild(interaction, config):
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Refresh embeds command invoked by {interaction.user.name}")
|
|
||||||
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}")
|
|
||||||
await interaction.followup.send(
|
|
||||||
"You need administrator permissions to refresh all embeds.", ephemeral=True
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info("Starting full embed refresh per admin request")
|
|
||||||
deleted, created = await bot.embed_manager.refresh_all_embeds()
|
|
||||||
await interaction.followup.send(
|
|
||||||
f"Refreshed all embeds. Deleted {deleted} old embeds, created {created} new ones.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
logger.info(f"Embed refresh completed: {deleted} deleted, {created} created")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Embed refresh failed: {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 (admin only)",
|
|
||||||
)
|
|
||||||
async def purge_embeds(interaction: discord.Interaction) -> None:
|
|
||||||
"""
|
|
||||||
Slash command to permanently purge all server status embeds from Discord channels.
|
|
||||||
|
|
||||||
Workflow:
|
|
||||||
1. Validates administrator permissions
|
|
||||||
2. Checks if any embeds are currently tracked
|
|
||||||
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
|
|
||||||
|
|
||||||
logger.info(f"Purge embeds command invoked by {interaction.user.name}")
|
|
||||||
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}")
|
|
||||||
await interaction.followup.send(
|
|
||||||
"You need administrator permissions to purge all embeds.", ephemeral=True
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info("Starting embed purge per admin request")
|
|
||||||
|
|
||||||
deleted_count = 0
|
|
||||||
not_found_count = 0
|
|
||||||
error_count = 0
|
|
||||||
total_embeds = len(bot.embed_manager.embed_locations)
|
|
||||||
|
|
||||||
if total_embeds == 0:
|
|
||||||
await interaction.followup.send(
|
|
||||||
"No embeds are currently being tracked. Nothing to purge.", ephemeral=True
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create progress embed
|
|
||||||
progress_embed = discord.Embed(
|
|
||||||
title="🔄 Purging Server Embeds",
|
|
||||||
description=f"Processing {total_embeds} embeds...",
|
|
||||||
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
|
|
||||||
for server_id, location in list(bot.embed_manager.embed_locations.items()):
|
|
||||||
try:
|
|
||||||
channel = bot.get_channel(int(location["channel_id"]))
|
|
||||||
if channel:
|
|
||||||
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}")
|
|
||||||
except discord.NotFound:
|
|
||||||
not_found_count += 1
|
|
||||||
logger.debug(f"Embed for server {server_id} already deleted")
|
|
||||||
except discord.Forbidden:
|
|
||||||
error_count += 1
|
|
||||||
logger.error(f"Permission denied when deleting embed for server {server_id}")
|
|
||||||
except Exception as e:
|
|
||||||
error_count += 1
|
|
||||||
logger.error(f"Error deleting embed for server {server_id}: {str(e)}")
|
|
||||||
else:
|
|
||||||
not_found_count += 1
|
|
||||||
logger.warning(f"Channel not found for server {server_id}")
|
|
||||||
|
|
||||||
# Remove from tracking immediately
|
|
||||||
await bot.embed_manager.untrack_embed(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}: {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 file has been cleared")
|
|
||||||
|
|
||||||
await progress_message.edit(embed=result_embed)
|
|
||||||
logger.info(f"Embed purge completed: {deleted_count} deleted, {not_found_count} not found, {error_count} errors")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Embed purge failed: {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,207 +0,0 @@
|
|||||||
"""
|
|
||||||
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 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_id: Discord server ID where bot operates
|
|
||||||
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_id: int = self._config.getint("Discord", "AllowedGuildID")
|
|
||||||
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", "AllowedGuildID"]
|
|
||||||
for key in required_discord:
|
|
||||||
if not self._config.get("Discord", key, fallback=None):
|
|
||||||
errors.append(f"Missing required Discord config value: {key}")
|
|
||||||
|
|
||||||
# Validate AllowedGuildID is a valid integer
|
|
||||||
try:
|
|
||||||
guild_id = self._config.getint("Discord", "AllowedGuildID", fallback=0)
|
|
||||||
if guild_id <= 0:
|
|
||||||
errors.append("AllowedGuildID must be a positive integer")
|
|
||||||
except ValueError:
|
|
||||||
errors.append("AllowedGuildID must be a valid integer")
|
|
||||||
|
|
||||||
# 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_")
|
|
||||||
)
|
|
||||||
|
|
||||||
@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_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", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
config["Discord"] = {
|
|
||||||
"Token": os.environ.get("DISCORD_TOKEN", ""),
|
|
||||||
"AllowedGuildID": os.environ.get("ALLOWED_GUILD_ID", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(output_path, "w") as f:
|
|
||||||
config.write(f)
|
|
||||||
|
|
||||||
logger.info(f"Generated config.ini from environment variables at {output_path}")
|
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# Builder stage using slim Python image
|
# Builder stage using slim Python image
|
||||||
FROM python:3.12-slim as builder
|
FROM python:3.11-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 \
|
||||||
@@ -19,7 +19,7 @@ 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 slim
|
# Final stage - using slim
|
||||||
FROM python:3.12-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Install minimal runtime dependencies
|
# Install minimal runtime dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -1,424 +0,0 @@
|
|||||||
"""
|
|
||||||
Embed Management Module
|
|
||||||
|
|
||||||
Handles creation, tracking, and persistence of Discord server status embeds.
|
|
||||||
Manages embed locations storage and provides utilities for embed lifecycle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
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.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Embed creation with real-time server data
|
|
||||||
- Persistent storage of embed locations (channel_id, message_id)
|
|
||||||
- Embed refresh and recreation
|
|
||||||
- Cleanup of deleted/missing embeds
|
|
||||||
- Rate limit protection during bulk operations
|
|
||||||
|
|
||||||
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
|
|
||||||
self.embed_locations: Dict[str, Dict[str, int]] = {}
|
|
||||||
self.previous_states: Dict[str, Tuple[str, float, Optional[float]]] = {}
|
|
||||||
|
|
||||||
# Ensure storage directory exists
|
|
||||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
logger.info("Initialized EmbedManager")
|
|
||||||
|
|
||||||
async def load_locations(self) -> None:
|
|
||||||
"""Load saved embed locations from JSON storage file."""
|
|
||||||
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:
|
|
||||||
self.embed_locations = json.load(f)
|
|
||||||
logger.info(f"Loaded {len(self.embed_locations)} embed locations from storage")
|
|
||||||
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:
|
|
||||||
with open(self.storage_path, "w") as f:
|
|
||||||
json.dump(self.embed_locations, f, indent=2)
|
|
||||||
logger.debug("Successfully saved embed locations")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save embed locations: {str(e)}")
|
|
||||||
|
|
||||||
async def track_embed(self, server_id: str, message: discord.Message) -> None:
|
|
||||||
"""
|
|
||||||
Track a newly created embed in storage.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
server_id: The server's panel identifier
|
|
||||||
message: Discord message containing the embed
|
|
||||||
"""
|
|
||||||
logger.debug(f"Tracking new embed for server {server_id} in channel {message.channel.id}")
|
|
||||||
self.embed_locations[server_id] = {
|
|
||||||
"channel_id": str(message.channel.id),
|
|
||||||
"message_id": str(message.id),
|
|
||||||
}
|
|
||||||
await self.save_locations()
|
|
||||||
|
|
||||||
async def untrack_embed(self, server_id: str) -> None:
|
|
||||||
"""
|
|
||||||
Remove a server's embed from tracking.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
server_id: The server's panel identifier
|
|
||||||
"""
|
|
||||||
self.embed_locations.pop(server_id, None)
|
|
||||||
self.previous_states.pop(server_id, None)
|
|
||||||
await self.save_locations()
|
|
||||||
|
|
||||||
async def create_server_embed(
|
|
||||||
self, server_data: ServerData, resources: ServerResources
|
|
||||||
) -> 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
|
|
||||||
|
|
||||||
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})")
|
|
||||||
|
|
||||||
# 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
|
|
||||||
from views import ServerStatusView
|
|
||||||
|
|
||||||
view = ServerStatusView(
|
|
||||||
server_id=identifier,
|
|
||||||
server_name=name,
|
|
||||||
api=self.api,
|
|
||||||
server_data=server_data.model_dump(),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"Successfully built status components for {name}")
|
|
||||||
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, unit: str = "") -> str:
|
|
||||||
if value == 0:
|
|
||||||
return f"{'∞':>8} {unit}"
|
|
||||||
return f"{value:>8} {unit}"
|
|
||||||
|
|
||||||
# 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) -> Tuple[int, int]:
|
|
||||||
"""
|
|
||||||
Perform a complete refresh of all server status embeds.
|
|
||||||
|
|
||||||
Creates new embeds and deletes old ones to prevent duplication.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (deleted_count, created_count)
|
|
||||||
"""
|
|
||||||
logger.info("Starting full refresh of all server embeds")
|
|
||||||
from bot import PterodactylBot # Avoid circular import
|
|
||||||
bot = PterodactylBot.get_instance()
|
|
||||||
|
|
||||||
deleted_count = 0
|
|
||||||
created_count = 0
|
|
||||||
skipped_count = 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 self.embed_locations:
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
channel_id = self.embed_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}")
|
|
||||||
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)
|
|
||||||
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']}")
|
|
||||||
|
|
||||||
await asyncio.sleep(1) # Rate limit protection
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create new embed for server {server_id}: {str(e)}")
|
|
||||||
|
|
||||||
if not new_embeds:
|
|
||||||
logger.warning("No new embeds created during refresh")
|
|
||||||
return 0, 0
|
|
||||||
|
|
||||||
# Delete old embeds
|
|
||||||
for server_id, location in list(self.embed_locations.items()):
|
|
||||||
try:
|
|
||||||
channel = bot.get_channel(int(location["channel_id"]))
|
|
||||||
if channel:
|
|
||||||
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}")
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
except discord.NotFound:
|
|
||||||
logger.debug(f"Old embed for server {server_id} already deleted")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete old embed for server {server_id}: {str(e)}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing old embed for server {server_id}: {str(e)}")
|
|
||||||
|
|
||||||
logger.info(f"Deleted {deleted_count} old embeds, created {created_count} new embeds")
|
|
||||||
|
|
||||||
# Update storage
|
|
||||||
self.embed_locations = new_embeds
|
|
||||||
await self.save_locations()
|
|
||||||
|
|
||||||
return deleted_count, created_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Critical error during embed refresh: {str(e)}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def update_single_embed(
|
|
||||||
self,
|
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
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)
|
|
||||||
|
|
||||||
channel = bot.get_channel(int(location["channel_id"]))
|
|
||||||
if not channel:
|
|
||||||
logger.warning(f"Channel {location['channel_id']} not found for server {server_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}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except discord.NotFound:
|
|
||||||
logger.warning(f"Embed for server {server_id} not found, removing from tracking")
|
|
||||||
self.embed_locations.pop(server_id, None)
|
|
||||||
self.previous_states.pop(server_id, None)
|
|
||||||
await self.save_locations()
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update status for server {server_id}: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# Import at bottom to avoid circular imports
|
|
||||||
import asyncio
|
|
||||||
Executable → Regular
+1
-1
@@ -5,4 +5,4 @@ if ! python generate_config.py; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec python main.py
|
exec python pterodisbot.py
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
+18
-68
@@ -3,7 +3,6 @@ import configparser
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
def validate_env_vars():
|
def validate_env_vars():
|
||||||
"""Validate that required environment variables are set and non-empty."""
|
"""Validate that required environment variables are set and non-empty."""
|
||||||
required_vars = [
|
required_vars = [
|
||||||
@@ -18,82 +17,33 @@ def validate_env_vars():
|
|||||||
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."""
|
|
||||||
return {
|
|
||||||
'Discord': {
|
|
||||||
'Token': os.getenv('DISCORD_TOKEN'),
|
|
||||||
'AllowedGuildID': os.getenv('ALLOWED_GUILD_ID')
|
|
||||||
},
|
|
||||||
'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
|
|
||||||
existing_value = existing_config.get(section, key, fallback=None)
|
|
||||||
if existing_value != env_value:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def generate_config():
|
def generate_config():
|
||||||
"""
|
"""Generate config.ini from environment variables."""
|
||||||
Generate config.ini from environment variables if:
|
# Validate environment variables first
|
||||||
1. No config file exists, OR
|
|
||||||
2. Config file exists but values don't match environment variables
|
|
||||||
|
|
||||||
Environment variables always take priority.
|
|
||||||
"""
|
|
||||||
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()
|
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()
|
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}
|
|
||||||
|
|
||||||
|
# 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.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 written to {config_path}")
|
print(f"Config file generated at {config_path}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
"""
|
|
||||||
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")
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/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
@@ -1,538 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""
|
|
||||||
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."""
|
|
||||||
|
|
||||||
channel_id: str
|
|
||||||
message_id: str
|
|
||||||
|
|
||||||
|
|
||||||
# Rebuild models to resolve forward references
|
|
||||||
ResourceAttributes.model_rebuild()
|
|
||||||
ServerAttributes.model_rebuild()
|
|
||||||
+1585
File diff suppressed because it is too large.
Load diff
+14
-14
@@ -1,26 +1,26 @@
|
|||||||
# Testing Dependencies for Pterodactyl Discord Bot
|
# Testing Dependencies for Pterodactyl Discord Bot
|
||||||
|
|
||||||
# Core testing framework
|
# Core testing framework
|
||||||
pytest>=9.1.1
|
pytest>=7.4.0
|
||||||
pytest-asyncio>=1.4.0
|
pytest-asyncio>=0.21.0
|
||||||
pytest-cov>=7.1.0
|
pytest-cov>=4.1.0
|
||||||
pytest-mock>=3.17.0
|
pytest-mock>=3.11.1
|
||||||
pytest-timeout>=2.4.0
|
pytest-timeout>=2.1.0
|
||||||
|
|
||||||
# Code quality and linting
|
# Code quality and linting
|
||||||
flake8>=7.4.1
|
flake8>=6.0.0
|
||||||
pylint>=4.1.3
|
pylint>=2.17.0
|
||||||
black>=26.10.1
|
black>=23.7.0
|
||||||
isort>=9.0.2
|
isort>=5.12.0
|
||||||
|
|
||||||
# Security scanning
|
# Security scanning
|
||||||
bandit>=1.9.4
|
bandit>=1.7.5
|
||||||
safety>=3.8.1
|
safety>=2.3.5
|
||||||
|
|
||||||
# Mocking and fixtures
|
# Mocking and fixtures
|
||||||
pytest-fixtures>=0.1.0
|
pytest-fixtures>=0.1.0
|
||||||
freezegun>=1.5.5
|
freezegun>=1.2.2
|
||||||
|
|
||||||
# Coverage reporting
|
# Coverage reporting
|
||||||
coverage>=7.17.0
|
coverage>=7.2.7
|
||||||
coverage-badge>=1.1.2
|
coverage-badge>=1.1.0
|
||||||
+5
-6
@@ -1,6 +1,5 @@
|
|||||||
discord.py>=2.7.1
|
discord.py>=2.3.0
|
||||||
aiohttp>=3.14.5
|
aiohttp>=3.8.0
|
||||||
configparser>=7.3.0
|
configparser>=5.3.0
|
||||||
python-dotenv>=1.2.4
|
python-dotenv
|
||||||
matplotlib>=3.11.2
|
matplotlib
|
||||||
pydantic>=2.14.1
|
|
||||||
+423
-353
File diff suppressed because it is too large.
Load diff
@@ -1,315 +0,0 @@
|
|||||||
"""
|
|
||||||
Discord Views Module
|
|
||||||
|
|
||||||
Contains all Discord UI views and interactive components.
|
|
||||||
Handles server control buttons, dropdowns, and permission checks.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 configured server)
|
|
||||||
- Role-based access control (requires "Game Server User" role)
|
|
||||||
- Persistent views (timeout=None for infinite persistence)
|
|
||||||
- Comprehensive error handling with user feedback
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
server_id: str,
|
|
||||||
server_name: str,
|
|
||||||
api: "PanelAPI",
|
|
||||||
server_data: dict,
|
|
||||||
) -> 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
|
|
||||||
"""
|
|
||||||
super().__init__(timeout=None) # Persistent view
|
|
||||||
self.server_id = server_id
|
|
||||||
self.server_name = server_name
|
|
||||||
self.api = api
|
|
||||||
self.server_data = server_data
|
|
||||||
logger.debug(f"Created ServerStatusView for {server_name} ({server_id})")
|
|
||||||
|
|
||||||
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
|
||||||
"""
|
|
||||||
Verify the interacting user has the required role and is in the allowed guild.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interaction: Discord interaction object
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if authorized, False otherwise
|
|
||||||
"""
|
|
||||||
config = Config.get_instance()
|
|
||||||
|
|
||||||
# First check if interaction is from the allowed guild
|
|
||||||
if interaction.guild_id != config.allowed_guild_id:
|
|
||||||
logger.warning(f"Unauthorized interaction attempt from guild {interaction.guild_id}")
|
|
||||||
await interaction.response.send_message(
|
|
||||||
"This bot is only available in a specific server.", ephemeral=True
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Then check for required role
|
|
||||||
logger.debug(f"Checking permissions for {interaction.user.name} on server {self.server_name}")
|
|
||||||
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} - 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.",
|
|
||||||
ephemeral=True,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.debug(f"Permission granted for {interaction.user.name}")
|
|
||||||
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} 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."""
|
|
||||||
logger.info(f"Start button pressed for {self.server_name} by {interaction.user.name}")
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
result = await self.api.send_power_action(self.server_id, "start")
|
|
||||||
|
|
||||||
if result.get("status") == "success":
|
|
||||||
message = f"Server '{self.server_name}' is starting..."
|
|
||||||
logger.info(f"Successfully started server {self.server_name}")
|
|
||||||
else:
|
|
||||||
message = f"Failed to start server: {result.get('message', 'Unknown error')}"
|
|
||||||
logger.error(f"Failed to start server {self.server_name}: {message}")
|
|
||||||
|
|
||||||
await interaction.followup.send(message, ephemeral=True)
|
|
||||||
|
|
||||||
@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."""
|
|
||||||
logger.info(f"Stop button pressed for {self.server_name} by {interaction.user.name}")
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
result = await self.api.send_power_action(self.server_id, "stop")
|
|
||||||
|
|
||||||
if result.get("status") == "success":
|
|
||||||
message = f"Server '{self.server_name}' is stopping..."
|
|
||||||
logger.info(f"Successfully stopped server {self.server_name}")
|
|
||||||
else:
|
|
||||||
message = f"Failed to stop server: {result.get('message', 'Unknown error')}"
|
|
||||||
logger.error(f"Failed to stop server {self.server_name}: {message}")
|
|
||||||
|
|
||||||
await interaction.followup.send(message, ephemeral=True)
|
|
||||||
|
|
||||||
@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."""
|
|
||||||
logger.info(f"Restart button pressed for {self.server_name} by {interaction.user.name}")
|
|
||||||
await interaction.response.defer(ephemeral=True)
|
|
||||||
result = await self.api.send_power_action(self.server_id, "restart")
|
|
||||||
|
|
||||||
if result.get("status") == "success":
|
|
||||||
message = f"Server '{self.server_name}' is restarting..."
|
|
||||||
logger.info(f"Successfully restarted server {self.server_name}")
|
|
||||||
else:
|
|
||||||
message = f"Failed to restart server: {result.get('message', 'Unknown error')}"
|
|
||||||
logger.error(f"Failed to restart server {self.server_name}: {message}")
|
|
||||||
|
|
||||||
await interaction.followup.send(message, ephemeral=True)
|
|
||||||
|
|
||||||
@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}")
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
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"]
|
|
||||||
logger.info(f"User {interaction.user.name} selected server: {server_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get current server status for embed creation
|
|
||||||
resources = await bot.api.get_server_resources(selected_server_id)
|
|
||||||
|
|
||||||
# Delete old embed if it exists to prevent duplication
|
|
||||||
if selected_server_id in bot.embed_manager.embed_locations:
|
|
||||||
logger.debug(f"Found existing embed for {selected_server_id}, attempting to delete")
|
|
||||||
try:
|
|
||||||
old_location = bot.embed_manager.embed_locations[selected_server_id]
|
|
||||||
old_channel = bot.get_channel(int(old_location["channel_id"]))
|
|
||||||
if old_channel:
|
|
||||||
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}")
|
|
||||||
except discord.NotFound:
|
|
||||||
logger.debug(f"Old embed for {selected_server_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)
|
|
||||||
message = await interaction.channel.send(embed=embed, view=view)
|
|
||||||
await bot.embed_manager.track_embed(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}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create status embed for {server_name}: {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")
|
|
||||||
Reference in new issue
Block a user