Combine bot management with security and monitoring features
Migrate from Flask-based "Bot Master" to a unified Node.js/discord.js "AeThex Unified Bot" integrating Sentinel security, federation sync, and ticketing. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e72fc1b7-94bd-4d6c-801f-cbac2fae245c Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: a4a30d75-2648-45eb-adcc-4aaeaa0072fb Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/e72fc1b7-94bd-4d6c-801f-cbac2fae245c/7DQc4BR Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
ddea985e6f
commit
89a8700ddd
29 changed files with 78 additions and 3867 deletions
210
main.py
210
main.py
|
|
@ -1,210 +0,0 @@
|
||||||
import os
|
|
||||||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
|
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
|
||||||
from datetime import datetime
|
|
||||||
import requests
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.secret_key = os.environ.get("FLASK_SECRET_KEY", os.urandom(24))
|
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL")
|
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
|
||||||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
|
|
||||||
"pool_recycle": 300,
|
|
||||||
"pool_pre_ping": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
db = SQLAlchemy(app)
|
|
||||||
|
|
||||||
class Bot(db.Model):
|
|
||||||
__tablename__ = 'bots'
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
name = db.Column(db.String(100), nullable=False)
|
|
||||||
description = db.Column(db.Text)
|
|
||||||
health_endpoint = db.Column(db.String(500))
|
|
||||||
admin_token = db.Column(db.String(200))
|
|
||||||
bot_type = db.Column(db.String(50), default='discord')
|
|
||||||
status = db.Column(db.String(20), default='unknown')
|
|
||||||
last_checked = db.Column(db.DateTime)
|
|
||||||
guild_count = db.Column(db.Integer, default=0)
|
|
||||||
command_count = db.Column(db.Integer, default=0)
|
|
||||||
uptime_seconds = db.Column(db.Integer, default=0)
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
return {
|
|
||||||
'id': self.id,
|
|
||||||
'name': self.name,
|
|
||||||
'description': self.description,
|
|
||||||
'has_health_endpoint': bool(self.health_endpoint),
|
|
||||||
'has_admin_token': bool(self.admin_token),
|
|
||||||
'bot_type': self.bot_type,
|
|
||||||
'status': self.status,
|
|
||||||
'last_checked': self.last_checked.isoformat() if self.last_checked else None,
|
|
||||||
'guild_count': self.guild_count,
|
|
||||||
'command_count': self.command_count,
|
|
||||||
'uptime_seconds': self.uptime_seconds,
|
|
||||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
class BotLog(db.Model):
|
|
||||||
__tablename__ = 'bot_logs'
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
bot_id = db.Column(db.Integer, db.ForeignKey('bots.id'), nullable=False)
|
|
||||||
level = db.Column(db.String(20), default='info')
|
|
||||||
message = db.Column(db.Text)
|
|
||||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
bot = db.relationship('Bot', backref=db.backref('logs', lazy='dynamic'))
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
def check_bot_health(bot):
|
|
||||||
if not bot.health_endpoint:
|
|
||||||
return {'status': 'unknown', 'error': 'No health endpoint configured'}
|
|
||||||
|
|
||||||
try:
|
|
||||||
headers = {}
|
|
||||||
if bot.admin_token:
|
|
||||||
headers['Authorization'] = f'Bearer {bot.admin_token}'
|
|
||||||
|
|
||||||
response = requests.get(bot.health_endpoint, headers=headers, timeout=10)
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json()
|
|
||||||
bot.status = data.get('status', 'online')
|
|
||||||
bot.guild_count = data.get('guilds', data.get('guildCount', 0))
|
|
||||||
bot.command_count = data.get('commands', data.get('commandCount', 0))
|
|
||||||
bot.uptime_seconds = data.get('uptime', 0)
|
|
||||||
bot.last_checked = datetime.utcnow()
|
|
||||||
db.session.commit()
|
|
||||||
return {'status': 'online', 'data': data}
|
|
||||||
else:
|
|
||||||
bot.status = 'offline'
|
|
||||||
bot.last_checked = datetime.utcnow()
|
|
||||||
db.session.commit()
|
|
||||||
return {'status': 'offline', 'error': f'HTTP {response.status_code}'}
|
|
||||||
except requests.exceptions.Timeout:
|
|
||||||
bot.status = 'timeout'
|
|
||||||
bot.last_checked = datetime.utcnow()
|
|
||||||
db.session.commit()
|
|
||||||
return {'status': 'timeout', 'error': 'Request timed out'}
|
|
||||||
except requests.exceptions.ConnectionError:
|
|
||||||
bot.status = 'offline'
|
|
||||||
bot.last_checked = datetime.utcnow()
|
|
||||||
db.session.commit()
|
|
||||||
return {'status': 'offline', 'error': 'Connection failed'}
|
|
||||||
except Exception as e:
|
|
||||||
bot.status = 'error'
|
|
||||||
bot.last_checked = datetime.utcnow()
|
|
||||||
db.session.commit()
|
|
||||||
return {'status': 'error', 'error': str(e)}
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def dashboard():
|
|
||||||
bots = Bot.query.order_by(Bot.created_at.desc()).all()
|
|
||||||
stats = {
|
|
||||||
'total_bots': len(bots),
|
|
||||||
'online_bots': sum(1 for b in bots if b.status == 'online'),
|
|
||||||
'total_guilds': sum(b.guild_count or 0 for b in bots),
|
|
||||||
'total_commands': sum(b.command_count or 0 for b in bots),
|
|
||||||
}
|
|
||||||
return render_template('dashboard.html', bots=bots, stats=stats)
|
|
||||||
|
|
||||||
@app.route('/bots')
|
|
||||||
def list_bots():
|
|
||||||
bots = Bot.query.order_by(Bot.created_at.desc()).all()
|
|
||||||
return render_template('bots.html', bots=bots)
|
|
||||||
|
|
||||||
@app.route('/bots/add', methods=['GET', 'POST'])
|
|
||||||
def add_bot():
|
|
||||||
if request.method == 'POST':
|
|
||||||
name = request.form.get('name')
|
|
||||||
description = request.form.get('description')
|
|
||||||
health_endpoint = request.form.get('health_endpoint')
|
|
||||||
admin_token = request.form.get('admin_token')
|
|
||||||
bot_type = request.form.get('bot_type', 'discord')
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
flash('Bot name is required', 'error')
|
|
||||||
return redirect(url_for('add_bot'))
|
|
||||||
|
|
||||||
bot = Bot(
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
health_endpoint=health_endpoint,
|
|
||||||
admin_token=admin_token,
|
|
||||||
bot_type=bot_type
|
|
||||||
)
|
|
||||||
db.session.add(bot)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if health_endpoint:
|
|
||||||
check_bot_health(bot)
|
|
||||||
|
|
||||||
flash(f'Bot "{name}" added successfully!', 'success')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
|
|
||||||
return render_template('add_bot.html')
|
|
||||||
|
|
||||||
@app.route('/bots/<int:bot_id>')
|
|
||||||
def view_bot(bot_id):
|
|
||||||
bot = Bot.query.get_or_404(bot_id)
|
|
||||||
return render_template('view_bot.html', bot=bot)
|
|
||||||
|
|
||||||
@app.route('/bots/<int:bot_id>/edit', methods=['GET', 'POST'])
|
|
||||||
def edit_bot(bot_id):
|
|
||||||
bot = Bot.query.get_or_404(bot_id)
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
bot.name = request.form.get('name', bot.name)
|
|
||||||
bot.description = request.form.get('description')
|
|
||||||
bot.health_endpoint = request.form.get('health_endpoint')
|
|
||||||
new_token = request.form.get('admin_token')
|
|
||||||
if new_token:
|
|
||||||
bot.admin_token = new_token
|
|
||||||
bot.bot_type = request.form.get('bot_type', 'discord')
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
flash(f'Bot "{bot.name}" updated successfully!', 'success')
|
|
||||||
return redirect(url_for('view_bot', bot_id=bot.id))
|
|
||||||
|
|
||||||
return render_template('edit_bot.html', bot=bot)
|
|
||||||
|
|
||||||
@app.route('/bots/<int:bot_id>/delete', methods=['POST'])
|
|
||||||
def delete_bot(bot_id):
|
|
||||||
bot = Bot.query.get_or_404(bot_id)
|
|
||||||
name = bot.name
|
|
||||||
BotLog.query.filter_by(bot_id=bot.id).delete()
|
|
||||||
db.session.delete(bot)
|
|
||||||
db.session.commit()
|
|
||||||
flash(f'Bot "{name}" deleted successfully!', 'success')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
|
|
||||||
@app.route('/bots/<int:bot_id>/check', methods=['POST'])
|
|
||||||
def check_bot(bot_id):
|
|
||||||
bot = Bot.query.get_or_404(bot_id)
|
|
||||||
result = check_bot_health(bot)
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
@app.route('/api/bots')
|
|
||||||
def api_list_bots():
|
|
||||||
bots = Bot.query.all()
|
|
||||||
return jsonify([bot.to_dict() for bot in bots])
|
|
||||||
|
|
||||||
@app.route('/api/bots/<int:bot_id>/health')
|
|
||||||
def api_bot_health(bot_id):
|
|
||||||
bot = Bot.query.get_or_404(bot_id)
|
|
||||||
result = check_bot_health(bot)
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
@app.route('/api/check-all', methods=['POST'])
|
|
||||||
def api_check_all():
|
|
||||||
bots = Bot.query.all()
|
|
||||||
results = {}
|
|
||||||
for bot in bots:
|
|
||||||
results[bot.id] = check_bot_health(bot)
|
|
||||||
return jsonify(results)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
|
||||||
271
replit.md
271
replit.md
|
|
@ -1,241 +1,126 @@
|
||||||
# Bot Master
|
# AeThex Unified Bot
|
||||||
|
|
||||||
A centralized management dashboard for managing multiple Discord bots.
|
A single Discord bot combining community features and enterprise security (Sentinel).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Bot Master is a Flask-based web application that provides a central dashboard for monitoring and managing multiple Discord bots. It supports:
|
AeThex Unified Bot handles both community features AND security in one instance:
|
||||||
|
|
||||||
- **Bot Registry**: Store configurations for all your bots
|
- **Sentinel Security**: Anti-nuke protection with RAM-based heat tracking
|
||||||
- **Status Monitoring**: Real-time health checks for each bot
|
- **Federation Sync**: Cross-server role synchronization across 5 realms
|
||||||
- **Statistics**: Track servers, commands, and uptime across all bots
|
- **Ticket System**: Support tickets with automatic channel creation
|
||||||
- **CRUD Operations**: Add, edit, view, and delete bot configurations
|
- **Admin Monitoring**: Real-time status, threat monitoring, server overview
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Python/Flask
|
- **Runtime**: Node.js 20
|
||||||
- **Database**: PostgreSQL (via Flask-SQLAlchemy)
|
- **Framework**: discord.js v14
|
||||||
- **Frontend**: Jinja2 templates with custom CSS
|
- **Database**: Supabase (optional, for user verification)
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/
|
|
||||||
├── main.py # Flask application entry point
|
|
||||||
├── templates/ # HTML templates
|
|
||||||
│ ├── base.html # Base layout template
|
|
||||||
│ ├── dashboard.html # Main dashboard view
|
|
||||||
│ ├── bots.html # Bot list table view
|
|
||||||
│ ├── add_bot.html # Add new bot form
|
|
||||||
│ ├── view_bot.html # Bot details view
|
|
||||||
│ └── edit_bot.html # Edit bot form
|
|
||||||
├── static/ # Static assets (currently empty)
|
|
||||||
└── attached_assets/ # Uploaded files and extracted bot examples
|
|
||||||
├── bot1/ # Example AeThex bot (basic)
|
|
||||||
└── bot2/ # Example AeThex bot (extended with feed sync)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Models
|
|
||||||
|
|
||||||
### Bot
|
|
||||||
- `id`: Primary key
|
|
||||||
- `name`: Bot name (required)
|
|
||||||
- `description`: Bot description
|
|
||||||
- `health_endpoint`: URL for health check API
|
|
||||||
- `admin_token`: Bearer token for authenticated endpoints
|
|
||||||
- `bot_type`: Type of bot (discord, telegram, slack, other)
|
|
||||||
- `status`: Current status (online, offline, unknown, timeout, error)
|
|
||||||
- `last_checked`: Timestamp of last health check
|
|
||||||
- `guild_count`, `command_count`, `uptime_seconds`: Stats from health endpoint
|
|
||||||
- `created_at`, `updated_at`: Timestamps
|
|
||||||
|
|
||||||
### BotLog
|
|
||||||
- Stores log entries per bot (for future logging features)
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
- `GET /` - Dashboard
|
|
||||||
- `GET /bots` - List all bots
|
|
||||||
- `GET /bots/add` - Add bot form
|
|
||||||
- `POST /bots/add` - Create new bot
|
|
||||||
- `GET /bots/<id>` - View bot details
|
|
||||||
- `GET /bots/<id>/edit` - Edit bot form
|
|
||||||
- `POST /bots/<id>/edit` - Update bot
|
|
||||||
- `POST /bots/<id>/delete` - Delete bot
|
|
||||||
- `POST /bots/<id>/check` - Check bot health
|
|
||||||
|
|
||||||
### API (JSON)
|
|
||||||
- `GET /api/bots` - List all bots as JSON
|
|
||||||
- `GET /api/bots/<id>/health` - Check specific bot health
|
|
||||||
- `POST /api/check-all` - Check all bots health
|
|
||||||
|
|
||||||
## Health Check Integration
|
|
||||||
|
|
||||||
Each bot should expose a health endpoint that returns JSON:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "online",
|
|
||||||
"guilds": 5,
|
|
||||||
"commands": 12,
|
|
||||||
"uptime": 3600
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Application
|
|
||||||
|
|
||||||
The application runs on port 5000 using the Flask development server.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
|
||||||
- `FLASK_SECRET_KEY` - Secret key for session management (auto-generated if not set)
|
|
||||||
|
|
||||||
## Example Bots
|
|
||||||
|
|
||||||
The `attached_assets` folder contains two example AeThex Discord bots:
|
|
||||||
- **Bot 1**: Basic version with verify, profile, set-realm, unlink, verify-role commands
|
|
||||||
- **Bot 2**: Extended version with additional help, stats, leaderboard, post commands and feed sync
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Aethex Sentinel Bot
|
|
||||||
|
|
||||||
Enterprise-grade Discord bot for managing a federation of 5 servers.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Aethex Sentinel is a TypeScript-based Discord bot built with the Sapphire framework. It provides:
|
|
||||||
|
|
||||||
- **Federation Sync**: Cross-server role synchronization
|
|
||||||
- **Sentinel Security**: Anti-nuke protection with heat-based threat detection
|
|
||||||
- **Commerce Module**: Ticket system with database persistence
|
|
||||||
- **Dashboard Updates**: Network status updates in voice channels
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Runtime**: Node.js 20 + TypeScript
|
|
||||||
- **Framework**: @sapphire/framework
|
|
||||||
- **Database**: PostgreSQL (via Prisma ORM)
|
|
||||||
- **Health Endpoint**: HTTP server on port 8080
|
- **Health Endpoint**: HTTP server on port 8080
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
sentinel-bot/
|
aethex-bot/
|
||||||
├── src/
|
├── bot.js # Main entry point
|
||||||
│ ├── core/
|
|
||||||
│ │ ├── client.ts - SapphireClient with Prisma
|
|
||||||
│ │ ├── config.ts - Environment configuration
|
|
||||||
│ │ └── health.ts - Health endpoint server
|
|
||||||
│ ├── modules/
|
|
||||||
│ │ ├── federation/FederationManager.ts - Cross-server role sync
|
|
||||||
│ │ ├── security/HeatSystem.ts - Anti-nuke protection
|
|
||||||
│ │ ├── commerce/TicketManager.ts - Ticket system
|
|
||||||
│ │ └── dashboard/StatusUpdater.ts - Network status updates
|
|
||||||
│ ├── listeners/
|
|
||||||
│ │ ├── ready.ts - Bot ready handler
|
|
||||||
│ │ ├── guildMemberUpdate.ts - Role sync listener
|
|
||||||
│ │ ├── auditLogCreate.ts - Security monitor
|
|
||||||
│ │ └── interactionCreate.ts - Button handler
|
|
||||||
│ ├── commands/
|
|
||||||
│ │ ├── federation.ts - /federation command
|
|
||||||
│ │ ├── sentinel.ts - /sentinel command
|
|
||||||
│ │ ├── ticket.ts - /ticket command
|
|
||||||
│ │ └── status.ts - /status command
|
|
||||||
│ └── index.ts - Entry point
|
|
||||||
├── prisma/
|
|
||||||
│ └── schema.prisma - Database models
|
|
||||||
├── package.json
|
├── package.json
|
||||||
├── tsconfig.json
|
├── .env.example
|
||||||
└── .env.example
|
├── commands/
|
||||||
|
│ ├── admin.js # /admin status|heat|servers|threats|federation
|
||||||
|
│ ├── federation.js # /federation link|unlink|list
|
||||||
|
│ ├── status.js # /status - network overview
|
||||||
|
│ └── ticket.js # /ticket create|close
|
||||||
|
├── events/
|
||||||
|
│ └── guildMemberUpdate.js # Federation role sync listener
|
||||||
|
├── listeners/
|
||||||
|
│ └── sentinel/
|
||||||
|
│ ├── antiNuke.js # Channel delete monitor
|
||||||
|
│ ├── roleDelete.js # Role delete monitor
|
||||||
|
│ ├── memberBan.js # Mass ban detection
|
||||||
|
│ └── memberKick.js # Mass kick detection
|
||||||
|
└── scripts/
|
||||||
|
└── register-commands.js # Slash command registration
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Models (Prisma)
|
|
||||||
|
|
||||||
- **User**: Discord user profiles with federation membership
|
|
||||||
- **HeatEvent**: Security events for threat detection
|
|
||||||
- **Ticket**: Support tickets with transcripts
|
|
||||||
- **GuildConfig**: Per-guild configuration
|
|
||||||
- **RoleMapping**: Cross-server role sync mappings
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/federation link` | Link a role across servers |
|
| `/admin status` | View bot status and statistics |
|
||||||
| `/federation unlink` | Remove a role mapping |
|
| `/admin heat @user` | Check heat level of a user |
|
||||||
| `/federation list` | Show all role mappings |
|
| `/admin servers` | View all connected servers |
|
||||||
| `/sentinel heat` | View heat level of a user |
|
| `/admin threats` | View active threat monitor |
|
||||||
| `/sentinel lockdown` | Enable/disable lockdown mode |
|
| `/admin federation` | View federation role mappings |
|
||||||
| `/sentinel config` | Configure security thresholds |
|
| `/federation link @role` | Link a role for cross-server sync |
|
||||||
| `/ticket create` | Create a support ticket |
|
| `/federation unlink @role` | Remove a role from sync |
|
||||||
| `/ticket close` | Close a ticket with transcript |
|
| `/federation list` | List all linked roles |
|
||||||
|
| `/ticket create [reason]` | Create a support ticket |
|
||||||
|
| `/ticket close` | Close the current ticket |
|
||||||
| `/status` | View network status |
|
| `/status` | View network status |
|
||||||
|
|
||||||
## Health Endpoint
|
## Sentinel Security System
|
||||||
|
|
||||||
The bot exposes a health endpoint compatible with Bot Master dashboard:
|
The anti-nuke system uses RAM-based heat tracking for instant response:
|
||||||
|
|
||||||
|
- **Heat Threshold**: 3 dangerous actions in 10 seconds triggers auto-ban
|
||||||
|
- **Monitored Actions**: Channel delete, role delete, member ban, member kick
|
||||||
|
- **Alerts**: Sends to configured alert channel and DMs server owner
|
||||||
|
- **Whitelist**: Set `WHITELISTED_USERS` env var for trusted users
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `DISCORD_TOKEN` or `DISCORD_BOT_TOKEN` - Bot token
|
||||||
|
- `DISCORD_CLIENT_ID` - Application ID (currently: 1447339527885553828)
|
||||||
|
|
||||||
|
Optional - Federation:
|
||||||
|
- `HUB_GUILD_ID` - Main hub server
|
||||||
|
- `LABS_GUILD_ID`, `GAMEFORGE_GUILD_ID`, `CORP_GUILD_ID`, `FOUNDATION_GUILD_ID`
|
||||||
|
|
||||||
|
Optional - Security:
|
||||||
|
- `WHITELISTED_USERS` - Comma-separated user IDs to skip heat tracking
|
||||||
|
- `ALERT_CHANNEL_ID` - Channel for security alerts
|
||||||
|
|
||||||
|
Optional - Supabase:
|
||||||
|
- `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE` - For user verification features
|
||||||
|
|
||||||
|
## Health Endpoint
|
||||||
|
|
||||||
**GET /health** (port 8080)
|
**GET /health** (port 8080)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"guilds": 5,
|
"guilds": 5,
|
||||||
"commands": 12,
|
"commands": 4,
|
||||||
"uptime": 3600,
|
"uptime": 3600,
|
||||||
"timestamp": "2025-12-07T12:00:00.000Z",
|
"heatMapSize": 0,
|
||||||
"bot": {
|
"timestamp": "2025-12-07T22:15:00.000Z"
|
||||||
"tag": "Aethex Sentinel#1234",
|
|
||||||
"id": "123456789"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**GET /stats** (port 8080)
|
**GET /stats** (port 8080)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"guilds": [
|
"guilds": [...],
|
||||||
{ "id": "...", "name": "...", "memberCount": 100 }
|
|
||||||
],
|
|
||||||
"totalMembers": 500,
|
"totalMembers": 500,
|
||||||
"uptime": 3600
|
"uptime": 3600,
|
||||||
|
"activeTickets": 0,
|
||||||
|
"heatEvents": 0
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Required secrets (add in Replit Secrets tab):
|
|
||||||
- `DISCORD_TOKEN` - Discord bot token
|
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
|
||||||
|
|
||||||
Optional configuration:
|
|
||||||
- `HUB_GUILD_ID` - Main hub server ID
|
|
||||||
- `FEDERATION_GUILD_IDS` - Comma-separated list of guild IDs
|
|
||||||
- `HEALTH_PORT` - Health server port (default: 8080)
|
|
||||||
|
|
||||||
## Running the Bot
|
## Running the Bot
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd sentinel-bot
|
cd aethex-bot
|
||||||
npm install
|
npm install
|
||||||
npx prisma generate
|
node scripts/register-commands.js # Register slash commands (run once)
|
||||||
npx prisma db push
|
|
||||||
npm run build
|
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
For development:
|
## Current Status
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Bot Master
|
- Bot is running and connected to 5 servers
|
||||||
|
- All 4 commands registered (/admin, /federation, /status, /ticket)
|
||||||
Once the Sentinel bot is running, add it to Bot Master dashboard:
|
- Sentinel listeners active (channel/role delete, ban/kick monitoring)
|
||||||
1. Go to Bot Master dashboard (port 5000)
|
- Health endpoint available at port 8080
|
||||||
2. Click "Add Bot"
|
|
||||||
3. Enter name: "Aethex Sentinel"
|
|
||||||
4. Enter health endpoint: `http://localhost:8080/health`
|
|
||||||
5. Select type: "discord"
|
|
||||||
|
|
||||||
The dashboard will automatically poll the health endpoint for status updates.
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# Discord Bot Configuration
|
|
||||||
DISCORD_TOKEN=your_bot_token_here
|
|
||||||
|
|
||||||
# Federation Guild IDs
|
|
||||||
HUB_ID=515711457946632232
|
|
||||||
FORGE_ID=1245619208805416970
|
|
||||||
FOUNDATION_ID=1338564560277344287
|
|
||||||
LABS_ID=1275962459596783686
|
|
||||||
CORP_ID=373713073594302464
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
WHITELISTED_USERS=113472107526033408,user_id_2
|
|
||||||
|
|
||||||
# Dashboard Configuration
|
|
||||||
STATUS_CHANNEL_ID=voice_channel_id_for_status
|
|
||||||
|
|
||||||
# Health Check
|
|
||||||
HEALTH_PORT=8044
|
|
||||||
|
|
||||||
# Database (auto-provided by Replit)
|
|
||||||
# DATABASE_URL=postgresql://...
|
|
||||||
1097
sentinel-bot/package-lock.json
generated
1097
sentinel-bot/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
||||||
{
|
|
||||||
"name": "aethex-sentinel",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Enterprise-grade Discord bot for managing a federation of servers",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "ts-node src/index.ts",
|
|
||||||
"db:generate": "prisma generate",
|
|
||||||
"db:push": "prisma db push",
|
|
||||||
"db:studio": "prisma studio"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/client": "^5.22.0",
|
|
||||||
"@sapphire/framework": "^5.2.1",
|
|
||||||
"@sapphire/plugin-scheduled-tasks": "^10.0.1",
|
|
||||||
"discord.js": "^14.16.3",
|
|
||||||
"dotenv": "^16.4.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.17.6",
|
|
||||||
"prisma": "^5.22.0",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id
|
|
||||||
heatLevel Int @default(0)
|
|
||||||
balance Float @default(0.0)
|
|
||||||
roles String[]
|
|
||||||
lastHeatReset DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
tickets Ticket[]
|
|
||||||
heatEvents HeatEvent[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model HeatEvent {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
userId String
|
|
||||||
action String
|
|
||||||
timestamp DateTime @default(now())
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
@@index([userId, timestamp])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Ticket {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
threadId String @unique
|
|
||||||
userId String
|
|
||||||
type String
|
|
||||||
status String @default("open")
|
|
||||||
transcript String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
closedAt DateTime?
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model GuildConfig {
|
|
||||||
id String @id
|
|
||||||
name String
|
|
||||||
type String
|
|
||||||
statusChannelId String?
|
|
||||||
feedChannelId String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model RoleMapping {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
sourceGuild String
|
|
||||||
sourceRole String
|
|
||||||
targetGuild String
|
|
||||||
targetRole String
|
|
||||||
roleName String
|
|
||||||
|
|
||||||
@@unique([sourceGuild, sourceRole, targetGuild])
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { Command } from '@sapphire/framework';
|
|
||||||
import { PermissionFlagsBits } from 'discord.js';
|
|
||||||
import { federationManager } from '../modules/federation/FederationManager';
|
|
||||||
import { prisma } from '../core/client';
|
|
||||||
|
|
||||||
export class FederationCommand extends Command {
|
|
||||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
name: 'federation',
|
|
||||||
description: 'Manage federation role mappings',
|
|
||||||
requiredUserPermissions: [PermissionFlagsBits.Administrator],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override registerApplicationCommands(registry: Command.Registry) {
|
|
||||||
registry.registerChatInputCommand((builder) =>
|
|
||||||
builder
|
|
||||||
.setName(this.name)
|
|
||||||
.setDescription(this.description)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub
|
|
||||||
.setName('add-mapping')
|
|
||||||
.setDescription('Add a role mapping between guilds')
|
|
||||||
.addStringOption((opt) =>
|
|
||||||
opt.setName('source-guild').setDescription('Source guild ID').setRequired(true)
|
|
||||||
)
|
|
||||||
.addStringOption((opt) =>
|
|
||||||
opt.setName('source-role').setDescription('Source role ID').setRequired(true)
|
|
||||||
)
|
|
||||||
.addStringOption((opt) =>
|
|
||||||
opt.setName('target-guild').setDescription('Target guild ID').setRequired(true)
|
|
||||||
)
|
|
||||||
.addStringOption((opt) =>
|
|
||||||
opt.setName('target-role').setDescription('Target role ID').setRequired(true)
|
|
||||||
)
|
|
||||||
.addStringOption((opt) =>
|
|
||||||
opt.setName('role-name').setDescription('Friendly name for this mapping').setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub.setName('list').setDescription('List all role mappings')
|
|
||||||
)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub.setName('reload').setDescription('Reload mappings from database')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === 'add-mapping') {
|
|
||||||
const sourceGuild = interaction.options.getString('source-guild', true);
|
|
||||||
const sourceRole = interaction.options.getString('source-role', true);
|
|
||||||
const targetGuild = interaction.options.getString('target-guild', true);
|
|
||||||
const targetRole = interaction.options.getString('target-role', true);
|
|
||||||
const roleName = interaction.options.getString('role-name', true);
|
|
||||||
|
|
||||||
await federationManager.addMapping({
|
|
||||||
sourceGuild,
|
|
||||||
sourceRole,
|
|
||||||
targetGuild,
|
|
||||||
targetRole,
|
|
||||||
roleName,
|
|
||||||
});
|
|
||||||
|
|
||||||
return interaction.reply({
|
|
||||||
content: `✅ Added mapping: "${roleName}" from ${sourceGuild} to ${targetGuild}`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'list') {
|
|
||||||
const mappings = await prisma.roleMapping.findMany();
|
|
||||||
|
|
||||||
if (mappings.length === 0) {
|
|
||||||
return interaction.reply({
|
|
||||||
content: 'No role mappings configured.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = mappings
|
|
||||||
.map((m) => `• **${m.roleName}**: ${m.sourceGuild}:${m.sourceRole} → ${m.targetGuild}:${m.targetRole}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return interaction.reply({
|
|
||||||
content: `**Federation Role Mappings:**\n${list}`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'reload') {
|
|
||||||
await federationManager.loadMappings();
|
|
||||||
return interaction.reply({
|
|
||||||
content: '✅ Reloaded role mappings from database.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { Command } from '@sapphire/framework';
|
|
||||||
import { PermissionFlagsBits, EmbedBuilder } from 'discord.js';
|
|
||||||
import { heatSystem } from '../modules/security/HeatSystem';
|
|
||||||
import { config } from '../core/config';
|
|
||||||
|
|
||||||
export class SentinelCommand extends Command {
|
|
||||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
name: 'sentinel',
|
|
||||||
description: 'Manage the Sentinel security system',
|
|
||||||
requiredUserPermissions: [PermissionFlagsBits.Administrator],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override registerApplicationCommands(registry: Command.Registry) {
|
|
||||||
registry.registerChatInputCommand((builder) =>
|
|
||||||
builder
|
|
||||||
.setName(this.name)
|
|
||||||
.setDescription(this.description)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub.setName('status').setDescription('View Sentinel security status')
|
|
||||||
)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub
|
|
||||||
.setName('clear-heat')
|
|
||||||
.setDescription('Clear heat level for a user')
|
|
||||||
.addUserOption((opt) =>
|
|
||||||
opt.setName('user').setDescription('User to clear heat for').setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub.setName('unlock').setDescription('Lift lockdown mode on this server')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === 'status') {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor(0x00ff00)
|
|
||||||
.setTitle('🛡️ Sentinel Security Status')
|
|
||||||
.addFields(
|
|
||||||
{ name: 'Heat Threshold', value: `${config.security.heatThreshold} actions`, inline: true },
|
|
||||||
{ name: 'Window', value: `${config.security.heatWindowMs}ms`, inline: true },
|
|
||||||
{ name: 'Whitelisted Users', value: `${config.security.whitelistedUsers.length}`, inline: true },
|
|
||||||
{
|
|
||||||
name: 'Monitored Actions',
|
|
||||||
value: config.security.dangerousActions.join(', '),
|
|
||||||
inline: false,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'clear-heat') {
|
|
||||||
const user = interaction.options.getUser('user', true);
|
|
||||||
heatSystem.clearHeat(user.id);
|
|
||||||
return interaction.reply({
|
|
||||||
content: `✅ Cleared heat for ${user.tag}`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'unlock') {
|
|
||||||
if (!interaction.guild) {
|
|
||||||
return interaction.reply({
|
|
||||||
content: 'This command must be used in a server.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await heatSystem.unlockGuild(interaction.guild);
|
|
||||||
return interaction.reply({
|
|
||||||
content: '🔓 Lockdown lifted. @everyone permissions restored.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { Command } from '@sapphire/framework';
|
|
||||||
import { EmbedBuilder } from 'discord.js';
|
|
||||||
import { config } from '../core/config';
|
|
||||||
|
|
||||||
export class StatusCommand extends Command {
|
|
||||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
name: 'status',
|
|
||||||
description: 'View Sentinel bot and network status',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override registerApplicationCommands(registry: Command.Registry) {
|
|
||||||
registry.registerChatInputCommand((builder) =>
|
|
||||||
builder.setName(this.name).setDescription(this.description)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
|
||||||
const client = this.container.client;
|
|
||||||
|
|
||||||
const guildIds = Object.values(config.guilds).filter(id => id);
|
|
||||||
const guilds = guildIds
|
|
||||||
.map(id => client.guilds.cache.get(id))
|
|
||||||
.filter(g => g !== undefined);
|
|
||||||
|
|
||||||
const totalMembers = guilds.reduce((sum, g) => sum + g!.memberCount, 0);
|
|
||||||
|
|
||||||
const guildList = guilds
|
|
||||||
.map(g => `• **${g!.name}**: ${g!.memberCount.toLocaleString()} members`)
|
|
||||||
.join('\n') || 'No federation guilds connected';
|
|
||||||
|
|
||||||
const uptimeSeconds = Math.floor(process.uptime());
|
|
||||||
const hours = Math.floor(uptimeSeconds / 3600);
|
|
||||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
|
||||||
const seconds = uptimeSeconds % 60;
|
|
||||||
const uptimeStr = `${hours}h ${minutes}m ${seconds}s`;
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor(0x5865f2)
|
|
||||||
.setTitle('🛡️ Aethex Sentinel Status')
|
|
||||||
.setThumbnail(client.user?.displayAvatarURL() || null)
|
|
||||||
.addFields(
|
|
||||||
{ name: '📡 Network', value: `${guilds.length} guilds`, inline: true },
|
|
||||||
{ name: '👥 Total Members', value: totalMembers.toLocaleString(), inline: true },
|
|
||||||
{ name: '⏱️ Uptime', value: uptimeStr, inline: true },
|
|
||||||
{ name: '🏰 Federation Guilds', value: guildList, inline: false }
|
|
||||||
)
|
|
||||||
.setFooter({ text: 'Aethex Sentinel • Protecting the Federation' })
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
return interaction.reply({ embeds: [embed] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { Command } from '@sapphire/framework';
|
|
||||||
import { PermissionFlagsBits, ChannelType, TextChannel } from 'discord.js';
|
|
||||||
import { ticketManager } from '../modules/commerce/TicketManager';
|
|
||||||
import { prisma } from '../core/client';
|
|
||||||
|
|
||||||
export class TicketCommand extends Command {
|
|
||||||
public constructor(context: Command.LoaderContext, options: Command.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
name: 'ticket',
|
|
||||||
description: 'Manage the ticket system',
|
|
||||||
requiredUserPermissions: [PermissionFlagsBits.Administrator],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override registerApplicationCommands(registry: Command.Registry) {
|
|
||||||
registry.registerChatInputCommand((builder) =>
|
|
||||||
builder
|
|
||||||
.setName(this.name)
|
|
||||||
.setDescription(this.description)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub
|
|
||||||
.setName('setup')
|
|
||||||
.setDescription('Set up the ticket panel in this channel')
|
|
||||||
)
|
|
||||||
.addSubcommand((sub) =>
|
|
||||||
sub.setName('stats').setDescription('View ticket statistics')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
|
|
||||||
if (subcommand === 'setup') {
|
|
||||||
if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText) {
|
|
||||||
return interaction.reply({
|
|
||||||
content: 'This command must be used in a text channel.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
await ticketManager.createSupportPanel(interaction.channel as TextChannel);
|
|
||||||
|
|
||||||
return interaction.editReply({
|
|
||||||
content: '✅ Ticket panel created!',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === 'stats') {
|
|
||||||
const totalTickets = await prisma.ticket.count();
|
|
||||||
const openTickets = await prisma.ticket.count({ where: { status: 'open' } });
|
|
||||||
const closedTickets = await prisma.ticket.count({ where: { status: 'closed' } });
|
|
||||||
|
|
||||||
const byType = await prisma.ticket.groupBy({
|
|
||||||
by: ['type'],
|
|
||||||
_count: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const typeStats = byType
|
|
||||||
.map((t) => `• ${t.type}: ${t._count.id}`)
|
|
||||||
.join('\n') || 'No tickets yet';
|
|
||||||
|
|
||||||
return interaction.reply({
|
|
||||||
content:
|
|
||||||
`**🎫 Ticket Statistics**\n\n` +
|
|
||||||
`Total: ${totalTickets}\n` +
|
|
||||||
`Open: ${openTickets}\n` +
|
|
||||||
`Closed: ${closedTickets}\n\n` +
|
|
||||||
`**By Type:**\n${typeStats}`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { SapphireClient } from '@sapphire/framework';
|
|
||||||
import { GatewayIntentBits, Partials } from 'discord.js';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
export const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class SentinelClient extends SapphireClient {
|
|
||||||
public constructor() {
|
|
||||||
super({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildMembers,
|
|
||||||
GatewayIntentBits.GuildModeration,
|
|
||||||
GatewayIntentBits.GuildMessages,
|
|
||||||
GatewayIntentBits.MessageContent,
|
|
||||||
GatewayIntentBits.DirectMessages,
|
|
||||||
],
|
|
||||||
partials: [
|
|
||||||
Partials.Channel,
|
|
||||||
Partials.Message,
|
|
||||||
Partials.GuildMember,
|
|
||||||
],
|
|
||||||
loadMessageCommandListeners: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async login(token?: string): Promise<string> {
|
|
||||||
await prisma.$connect();
|
|
||||||
console.log('✅ Database connected');
|
|
||||||
return super.login(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async destroy(): Promise<void> {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
return super.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import 'dotenv/config';
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
token: process.env.DISCORD_TOKEN!,
|
|
||||||
|
|
||||||
guilds: {
|
|
||||||
hub: process.env.HUB_ID!,
|
|
||||||
gameforge: process.env.FORGE_ID!,
|
|
||||||
foundation: process.env.FOUNDATION_ID!,
|
|
||||||
labs: process.env.LABS_ID!,
|
|
||||||
corp: process.env.CORP_ID!,
|
|
||||||
},
|
|
||||||
|
|
||||||
security: {
|
|
||||||
heatThreshold: 3,
|
|
||||||
heatWindowMs: 5000,
|
|
||||||
dangerousActions: ['CHANNEL_DELETE', 'BAN_ADD', 'KICK', 'ROLE_DELETE', 'WEBHOOK_DELETE'],
|
|
||||||
whitelistedUsers: process.env.WHITELISTED_USERS?.split(',') || [],
|
|
||||||
},
|
|
||||||
|
|
||||||
dashboard: {
|
|
||||||
updateIntervalMs: 5 * 60 * 1000,
|
|
||||||
statusChannelId: process.env.STATUS_CHANNEL_ID,
|
|
||||||
},
|
|
||||||
|
|
||||||
health: {
|
|
||||||
port: parseInt(process.env.HEALTH_PORT || '8080'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateConfig(): void {
|
|
||||||
const required = ['DISCORD_TOKEN'];
|
|
||||||
const missing = required.filter(key => !process.env[key]);
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
console.error('❌ Missing required environment variables:', missing.join(', '));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Configuration validated');
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import http from 'http';
|
|
||||||
import { SentinelClient } from './client';
|
|
||||||
import { config } from './config';
|
|
||||||
|
|
||||||
export function startHealthServer(client: SentinelClient): void {
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url === '/health') {
|
|
||||||
const guilds = client.guilds.cache.size;
|
|
||||||
const commands = client.stores.get('commands')?.size || 0;
|
|
||||||
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
status: client.isReady() ? 'online' : 'offline',
|
|
||||||
guilds,
|
|
||||||
commands,
|
|
||||||
uptime: Math.floor(process.uptime()),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
bot: {
|
|
||||||
tag: client.user?.tag || 'Not ready',
|
|
||||||
id: client.user?.id,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.url === '/stats') {
|
|
||||||
const guildStats = client.guilds.cache.map(g => ({
|
|
||||||
id: g.id,
|
|
||||||
name: g.name,
|
|
||||||
memberCount: g.memberCount,
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
guilds: guildStats,
|
|
||||||
totalMembers: guildStats.reduce((sum, g) => sum + g.memberCount, 0),
|
|
||||||
uptime: Math.floor(process.uptime()),
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end(JSON.stringify({ error: 'Not found' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(config.health.port, () => {
|
|
||||||
console.log(`🏥 Health server running on port ${config.health.port}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { config, validateConfig } from './core/config';
|
|
||||||
import { SentinelClient } from './core/client';
|
|
||||||
import { startHealthServer } from './core/health';
|
|
||||||
|
|
||||||
validateConfig();
|
|
||||||
|
|
||||||
const client = new SentinelClient();
|
|
||||||
|
|
||||||
client.once('ready', () => {
|
|
||||||
console.log(`✅ Aethex Sentinel logged in as ${client.user?.tag}`);
|
|
||||||
console.log(`📡 Watching ${client.guilds.cache.size} guild(s)`);
|
|
||||||
|
|
||||||
client.user?.setActivity('🛡️ Protecting the Federation', { type: 3 });
|
|
||||||
|
|
||||||
startHealthServer(client);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (error: Error) => {
|
|
||||||
console.error('❌ Unhandled Promise Rejection:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('uncaughtException', (error: Error) => {
|
|
||||||
console.error('❌ Uncaught Exception:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(config.token);
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { Listener } from '@sapphire/framework';
|
|
||||||
import { AuditLogEvent, GuildAuditLogsEntry, Guild } from 'discord.js';
|
|
||||||
import { heatSystem } from '../modules/security/HeatSystem';
|
|
||||||
|
|
||||||
type DangerousAction = 'CHANNEL_DELETE' | 'BAN_ADD' | 'KICK' | 'ROLE_DELETE' | 'WEBHOOK_DELETE';
|
|
||||||
|
|
||||||
const DANGEROUS_ACTIONS: Record<number, DangerousAction> = {
|
|
||||||
[AuditLogEvent.ChannelDelete]: 'CHANNEL_DELETE',
|
|
||||||
[AuditLogEvent.MemberBanAdd]: 'BAN_ADD',
|
|
||||||
[AuditLogEvent.MemberKick]: 'KICK',
|
|
||||||
[AuditLogEvent.RoleDelete]: 'ROLE_DELETE',
|
|
||||||
[AuditLogEvent.WebhookDelete]: 'WEBHOOK_DELETE',
|
|
||||||
};
|
|
||||||
|
|
||||||
export class AuditLogCreateListener extends Listener {
|
|
||||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
event: 'guildAuditLogEntryCreate',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async run(entry: GuildAuditLogsEntry, guild: Guild): Promise<void> {
|
|
||||||
const actionType = DANGEROUS_ACTIONS[entry.action];
|
|
||||||
if (!actionType) return;
|
|
||||||
|
|
||||||
const executorId = entry.executorId;
|
|
||||||
if (!executorId) return;
|
|
||||||
|
|
||||||
if (executorId === this.container.client.user?.id) return;
|
|
||||||
|
|
||||||
console.log(`⚠️ Dangerous action detected: ${actionType} by ${executorId} in ${guild.name}`);
|
|
||||||
|
|
||||||
const triggered = await heatSystem.recordAction(executorId, actionType, guild);
|
|
||||||
|
|
||||||
if (triggered) {
|
|
||||||
console.log(`🚨 Anti-nuke triggered for ${executorId} in ${guild.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Listener } from '@sapphire/framework';
|
|
||||||
import { GuildMember, Role } from 'discord.js';
|
|
||||||
import { federationManager } from '../modules/federation/FederationManager';
|
|
||||||
|
|
||||||
export class GuildMemberUpdateListener extends Listener {
|
|
||||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
event: 'guildMemberUpdate',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async run(oldMember: GuildMember, newMember: GuildMember): Promise<void> {
|
|
||||||
const oldRoles = oldMember.roles.cache;
|
|
||||||
const newRoles = newMember.roles.cache;
|
|
||||||
|
|
||||||
const addedRoles = newRoles.filter(role => !oldRoles.has(role.id));
|
|
||||||
const removedRoles = oldRoles.filter(role => !newRoles.has(role.id));
|
|
||||||
|
|
||||||
for (const role of addedRoles.values()) {
|
|
||||||
await federationManager.syncRoleGrant(newMember, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const role of removedRoles.values()) {
|
|
||||||
await federationManager.syncRoleRemove(newMember, role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { Listener } from '@sapphire/framework';
|
|
||||||
import { Interaction } from 'discord.js';
|
|
||||||
import { ticketManager } from '../modules/commerce/TicketManager';
|
|
||||||
|
|
||||||
export class InteractionCreateListener extends Listener {
|
|
||||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
event: 'interactionCreate',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async run(interaction: Interaction): Promise<void> {
|
|
||||||
if (!interaction.isButton()) return;
|
|
||||||
|
|
||||||
const customId = interaction.customId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (customId === 'ticket_rental') {
|
|
||||||
await ticketManager.createTicket(interaction, 'rental');
|
|
||||||
} else if (customId === 'ticket_support') {
|
|
||||||
await ticketManager.createTicket(interaction, 'support');
|
|
||||||
} else if (customId === 'ticket_billing') {
|
|
||||||
await ticketManager.createTicket(interaction, 'billing');
|
|
||||||
} else if (customId === 'ticket_invoice') {
|
|
||||||
await ticketManager.generateInvoice(interaction);
|
|
||||||
} else if (customId === 'ticket_close') {
|
|
||||||
await ticketManager.closeTicket(interaction);
|
|
||||||
} else if (customId === 'invoice_pay') {
|
|
||||||
await interaction.reply({
|
|
||||||
content: '✅ Invoice marked as paid! (Mock transaction)',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Button interaction error:', error);
|
|
||||||
|
|
||||||
if (!interaction.replied && !interaction.deferred) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: 'An error occurred while processing your request.',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Listener } from '@sapphire/framework';
|
|
||||||
import { Client } from 'discord.js';
|
|
||||||
import { federationManager } from '../modules/federation/FederationManager';
|
|
||||||
import { StatusUpdater } from '../modules/dashboard/StatusUpdater';
|
|
||||||
|
|
||||||
export class ReadyListener extends Listener {
|
|
||||||
public constructor(context: Listener.LoaderContext, options: Listener.Options) {
|
|
||||||
super(context, {
|
|
||||||
...options,
|
|
||||||
once: true,
|
|
||||||
event: 'ready',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async run(client: Client<true>): Promise<void> {
|
|
||||||
console.log(`✅ Aethex Sentinel ready as ${client.user.tag}`);
|
|
||||||
console.log(`📡 Connected to ${client.guilds.cache.size} guild(s)`);
|
|
||||||
|
|
||||||
await federationManager.loadMappings();
|
|
||||||
|
|
||||||
const statusUpdater = new StatusUpdater(client);
|
|
||||||
statusUpdater.start();
|
|
||||||
|
|
||||||
client.user.setActivity('🛡️ Protecting the Federation', { type: 3 });
|
|
||||||
|
|
||||||
console.log('✅ All modules initialized');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
import {
|
|
||||||
ButtonInteraction,
|
|
||||||
ChannelType,
|
|
||||||
EmbedBuilder,
|
|
||||||
ActionRowBuilder,
|
|
||||||
ButtonBuilder,
|
|
||||||
ButtonStyle,
|
|
||||||
TextChannel,
|
|
||||||
ThreadChannel,
|
|
||||||
Message,
|
|
||||||
} from 'discord.js';
|
|
||||||
import { prisma } from '../../core/client';
|
|
||||||
|
|
||||||
export class TicketManager {
|
|
||||||
async createTicket(interaction: ButtonInteraction, type: string): Promise<void> {
|
|
||||||
const channel = interaction.channel as TextChannel;
|
|
||||||
if (!channel || channel.type !== ChannelType.GuildText) {
|
|
||||||
await interaction.reply({ content: 'Cannot create ticket here.', ephemeral: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.upsert({
|
|
||||||
where: { id: interaction.user.id },
|
|
||||||
update: {},
|
|
||||||
create: { id: interaction.user.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
const thread = await channel.threads.create({
|
|
||||||
name: `${type}-${interaction.user.username}-${Date.now().toString(36)}`,
|
|
||||||
type: ChannelType.PrivateThread,
|
|
||||||
invitable: false,
|
|
||||||
reason: `Ticket created by ${interaction.user.tag}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await thread.members.add(interaction.user.id);
|
|
||||||
|
|
||||||
await prisma.ticket.create({
|
|
||||||
data: {
|
|
||||||
threadId: thread.id,
|
|
||||||
userId: interaction.user.id,
|
|
||||||
type,
|
|
||||||
status: 'open',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor(0x5865f2)
|
|
||||||
.setTitle(`🎫 ${type.charAt(0).toUpperCase() + type.slice(1)} Ticket`)
|
|
||||||
.setDescription(
|
|
||||||
`Welcome ${interaction.user}!\n\n` +
|
|
||||||
`A staff member will be with you shortly.\n` +
|
|
||||||
`Please describe your request in detail.`
|
|
||||||
)
|
|
||||||
.setFooter({ text: `Ticket ID: ${thread.id}` })
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('ticket_invoice')
|
|
||||||
.setLabel('Generate Invoice')
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
.setEmoji('📄'),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('ticket_close')
|
|
||||||
.setLabel('Close Ticket')
|
|
||||||
.setStyle(ButtonStyle.Danger)
|
|
||||||
.setEmoji('🔒')
|
|
||||||
);
|
|
||||||
|
|
||||||
await thread.send({ embeds: [embed], components: [row] });
|
|
||||||
|
|
||||||
await interaction.reply({
|
|
||||||
content: `✅ Your ticket has been created: ${thread}`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`🎫 Ticket created: ${thread.name} by ${interaction.user.tag}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateInvoice(interaction: ButtonInteraction): Promise<void> {
|
|
||||||
const invoiceId = `INV-${Date.now().toString(36).toUpperCase()}`;
|
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor(0x00ff00)
|
|
||||||
.setTitle('📄 Invoice Generated')
|
|
||||||
.setDescription('Here is your mock invoice for the requested service.')
|
|
||||||
.addFields(
|
|
||||||
{ name: 'Invoice ID', value: invoiceId, inline: true },
|
|
||||||
{ name: 'Status', value: 'Pending', inline: true },
|
|
||||||
{ name: 'Amount', value: '$XX.XX', inline: true },
|
|
||||||
{ name: 'Service', value: 'Server Rental', inline: false },
|
|
||||||
)
|
|
||||||
.setFooter({ text: 'This is a mock invoice for demonstration' })
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('invoice_pay')
|
|
||||||
.setLabel('Mark as Paid')
|
|
||||||
.setStyle(ButtonStyle.Success)
|
|
||||||
.setEmoji('💳')
|
|
||||||
);
|
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed], components: [row] });
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeTicket(interaction: ButtonInteraction): Promise<void> {
|
|
||||||
const thread = interaction.channel as ThreadChannel;
|
|
||||||
if (!thread || thread.type !== ChannelType.PrivateThread) {
|
|
||||||
await interaction.reply({ content: 'This is not a ticket thread.', ephemeral: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticket = await prisma.ticket.findUnique({
|
|
||||||
where: { threadId: thread.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ticket) {
|
|
||||||
await interaction.reply({ content: 'Ticket not found in database.', ephemeral: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.reply({ content: '🔒 Closing ticket and saving transcript...' });
|
|
||||||
|
|
||||||
const transcript = await this.generateTranscript(thread);
|
|
||||||
|
|
||||||
await prisma.ticket.update({
|
|
||||||
where: { threadId: thread.id },
|
|
||||||
data: {
|
|
||||||
status: 'closed',
|
|
||||||
transcript,
|
|
||||||
closedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await thread.setLocked(true, 'Ticket closed');
|
|
||||||
await thread.setArchived(true, 'Ticket closed');
|
|
||||||
|
|
||||||
console.log(`🎫 Ticket closed: ${thread.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateTranscript(thread: ThreadChannel): Promise<string> {
|
|
||||||
const messages: Message[] = [];
|
|
||||||
let lastId: string | undefined;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const batch = await thread.messages.fetch({
|
|
||||||
limit: 100,
|
|
||||||
before: lastId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (batch.size === 0) break;
|
|
||||||
|
|
||||||
messages.push(...batch.values());
|
|
||||||
lastId = batch.last()?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.reverse();
|
|
||||||
|
|
||||||
const transcript = messages
|
|
||||||
.map(m => `[${m.createdAt.toISOString()}] ${m.author.tag}: ${m.content}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return transcript;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSupportPanel(channel: TextChannel): Promise<void> {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor(0x5865f2)
|
|
||||||
.setTitle('🎫 Support Center')
|
|
||||||
.setDescription(
|
|
||||||
'Need help? Click a button below to open a support ticket.\n\n' +
|
|
||||||
'**Available Services:**\n' +
|
|
||||||
'• Server Rental - Rent a game server\n' +
|
|
||||||
'• Technical Support - Get help with issues\n' +
|
|
||||||
'• Billing - Payment and invoice questions'
|
|
||||||
)
|
|
||||||
.setFooter({ text: 'Aethex Sentinel Support System' });
|
|
||||||
|
|
||||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('ticket_rental')
|
|
||||||
.setLabel('Rent Server')
|
|
||||||
.setStyle(ButtonStyle.Primary)
|
|
||||||
.setEmoji('🖥️'),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('ticket_support')
|
|
||||||
.setLabel('Technical Support')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
.setEmoji('🔧'),
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId('ticket_billing')
|
|
||||||
.setLabel('Billing')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
.setEmoji('💰')
|
|
||||||
);
|
|
||||||
|
|
||||||
await channel.send({ embeds: [embed], components: [row] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ticketManager = new TicketManager();
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
import { Client, VoiceChannel, ChannelType } from 'discord.js';
|
|
||||||
import { prisma } from '../../core/client';
|
|
||||||
import { config } from '../../core/config';
|
|
||||||
|
|
||||||
export class StatusUpdater {
|
|
||||||
private client: Client;
|
|
||||||
private intervalId: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
constructor(client: Client) {
|
|
||||||
this.client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
if (this.intervalId) return;
|
|
||||||
|
|
||||||
this.update();
|
|
||||||
|
|
||||||
this.intervalId = setInterval(() => {
|
|
||||||
this.update();
|
|
||||||
}, config.dashboard.updateIntervalMs);
|
|
||||||
|
|
||||||
console.log(`📊 Status updater started (interval: ${config.dashboard.updateIntervalMs}ms)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
if (this.intervalId) {
|
|
||||||
clearInterval(this.intervalId);
|
|
||||||
this.intervalId = null;
|
|
||||||
console.log('📊 Status updater stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const guildIds = Object.values(config.guilds).filter(id => id);
|
|
||||||
let totalMembers = 0;
|
|
||||||
const guildStats: { id: string; name: string; members: number }[] = [];
|
|
||||||
|
|
||||||
for (const guildId of guildIds) {
|
|
||||||
const guild = this.client.guilds.cache.get(guildId);
|
|
||||||
if (guild) {
|
|
||||||
totalMembers += guild.memberCount;
|
|
||||||
guildStats.push({
|
|
||||||
id: guild.id,
|
|
||||||
name: guild.name,
|
|
||||||
members: guild.memberCount,
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.guildConfig.upsert({
|
|
||||||
where: { id: guild.id },
|
|
||||||
update: {
|
|
||||||
name: guild.name,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: guild.id,
|
|
||||||
name: guild.name,
|
|
||||||
type: this.getGuildType(guild.id),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.dashboard.statusChannelId && config.guilds.hub) {
|
|
||||||
await this.updateStatusChannel(totalMembers);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 Network status: ${totalMembers} total members across ${guildStats.length} guilds`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to update status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateStatusChannel(totalMembers: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const hubGuild = this.client.guilds.cache.get(config.guilds.hub);
|
|
||||||
if (!hubGuild) return;
|
|
||||||
|
|
||||||
const channel = hubGuild.channels.cache.get(config.dashboard.statusChannelId!);
|
|
||||||
if (!channel || channel.type !== ChannelType.GuildVoice) return;
|
|
||||||
|
|
||||||
const voiceChannel = channel as VoiceChannel;
|
|
||||||
const newName = `🟢 Network: ${totalMembers.toLocaleString()} Users`;
|
|
||||||
|
|
||||||
if (voiceChannel.name !== newName) {
|
|
||||||
await voiceChannel.setName(newName);
|
|
||||||
console.log(`📊 Updated status channel: ${newName}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to update status channel:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getGuildType(guildId: string): string {
|
|
||||||
const entries = Object.entries(config.guilds);
|
|
||||||
const found = entries.find(([, id]) => id === guildId);
|
|
||||||
return found ? found[0] : 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNetworkStats(): Promise<{
|
|
||||||
totalMembers: number;
|
|
||||||
totalGuilds: number;
|
|
||||||
guilds: { id: string; name: string; members: number; type: string }[];
|
|
||||||
}> {
|
|
||||||
const guildIds = Object.values(config.guilds).filter(id => id);
|
|
||||||
const guilds: { id: string; name: string; members: number; type: string }[] = [];
|
|
||||||
|
|
||||||
for (const guildId of guildIds) {
|
|
||||||
const guild = this.client.guilds.cache.get(guildId);
|
|
||||||
if (guild) {
|
|
||||||
guilds.push({
|
|
||||||
id: guild.id,
|
|
||||||
name: guild.name,
|
|
||||||
members: guild.memberCount,
|
|
||||||
type: this.getGuildType(guild.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalMembers: guilds.reduce((sum, g) => sum + g.members, 0),
|
|
||||||
totalGuilds: guilds.length,
|
|
||||||
guilds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import { Guild, GuildMember, Role } from 'discord.js';
|
|
||||||
import { prisma } from '../../core/client';
|
|
||||||
import { config } from '../../core/config';
|
|
||||||
|
|
||||||
interface RoleMapping {
|
|
||||||
sourceGuild: string;
|
|
||||||
sourceRole: string;
|
|
||||||
targetGuild: string;
|
|
||||||
targetRole: string;
|
|
||||||
roleName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FederationManager {
|
|
||||||
private roleMappings: RoleMapping[] = [];
|
|
||||||
|
|
||||||
async loadMappings(): Promise<void> {
|
|
||||||
this.roleMappings = await prisma.roleMapping.findMany();
|
|
||||||
console.log(`📋 Loaded ${this.roleMappings.length} role mappings`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addMapping(mapping: Omit<RoleMapping, 'id'>): Promise<void> {
|
|
||||||
await prisma.roleMapping.create({
|
|
||||||
data: mapping,
|
|
||||||
});
|
|
||||||
await this.loadMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncRoleGrant(member: GuildMember, role: Role): Promise<void> {
|
|
||||||
const sourceGuildId = member.guild.id;
|
|
||||||
|
|
||||||
const mappings = this.roleMappings.filter(
|
|
||||||
m => m.sourceGuild === sourceGuildId && m.sourceRole === role.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mappings.length === 0) return;
|
|
||||||
|
|
||||||
console.log(`🔄 Syncing role "${role.name}" for ${member.user.tag} to ${mappings.length} guild(s)`);
|
|
||||||
|
|
||||||
for (const mapping of mappings) {
|
|
||||||
try {
|
|
||||||
const targetGuild = member.client.guilds.cache.get(mapping.targetGuild);
|
|
||||||
if (!targetGuild) {
|
|
||||||
console.warn(`⚠️ Target guild ${mapping.targetGuild} not found`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetMember = await targetGuild.members.fetch(member.id).catch(() => null);
|
|
||||||
if (!targetMember) {
|
|
||||||
console.warn(`⚠️ User ${member.user.tag} not in target guild ${targetGuild.name}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetRole = targetGuild.roles.cache.get(mapping.targetRole);
|
|
||||||
if (!targetRole) {
|
|
||||||
console.warn(`⚠️ Target role ${mapping.targetRole} not found in ${targetGuild.name}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetMember.roles.cache.has(targetRole.id)) {
|
|
||||||
await targetMember.roles.add(targetRole, `Federation sync from ${member.guild.name}`);
|
|
||||||
console.log(`✅ Granted "${targetRole.name}" to ${member.user.tag} in ${targetGuild.name}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Failed to sync role to guild ${mapping.targetGuild}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.updateUserRoles(member.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncRoleRemove(member: GuildMember, role: Role): Promise<void> {
|
|
||||||
const sourceGuildId = member.guild.id;
|
|
||||||
|
|
||||||
const mappings = this.roleMappings.filter(
|
|
||||||
m => m.sourceGuild === sourceGuildId && m.sourceRole === role.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mappings.length === 0) return;
|
|
||||||
|
|
||||||
console.log(`🔄 Removing synced role "${role.name}" for ${member.user.tag} from ${mappings.length} guild(s)`);
|
|
||||||
|
|
||||||
for (const mapping of mappings) {
|
|
||||||
try {
|
|
||||||
const targetGuild = member.client.guilds.cache.get(mapping.targetGuild);
|
|
||||||
if (!targetGuild) continue;
|
|
||||||
|
|
||||||
const targetMember = await targetGuild.members.fetch(member.id).catch(() => null);
|
|
||||||
if (!targetMember) continue;
|
|
||||||
|
|
||||||
const targetRole = targetGuild.roles.cache.get(mapping.targetRole);
|
|
||||||
if (!targetRole) continue;
|
|
||||||
|
|
||||||
if (targetMember.roles.cache.has(targetRole.id)) {
|
|
||||||
await targetMember.roles.remove(targetRole, `Federation sync removal from ${member.guild.name}`);
|
|
||||||
console.log(`✅ Removed "${targetRole.name}" from ${member.user.tag} in ${targetGuild.name}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Failed to remove synced role:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.updateUserRoles(member.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateUserRoles(userId: string): Promise<void> {
|
|
||||||
const syncedRoles = this.roleMappings
|
|
||||||
.filter(m => m.sourceGuild === config.guilds.hub)
|
|
||||||
.map(m => m.roleName);
|
|
||||||
|
|
||||||
await prisma.user.upsert({
|
|
||||||
where: { id: userId },
|
|
||||||
update: { roles: syncedRoles },
|
|
||||||
create: { id: userId, roles: syncedRoles },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getGuildType(guildId: string): string | null {
|
|
||||||
const entries = Object.entries(config.guilds);
|
|
||||||
const found = entries.find(([, id]) => id === guildId);
|
|
||||||
return found ? found[0] : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const federationManager = new FederationManager();
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
import { Guild, GuildMember, PermissionFlagsBits } from 'discord.js';
|
|
||||||
import { prisma } from '../../core/client';
|
|
||||||
import { config } from '../../core/config';
|
|
||||||
|
|
||||||
type DangerousAction = 'CHANNEL_DELETE' | 'BAN_ADD' | 'KICK' | 'ROLE_DELETE' | 'WEBHOOK_DELETE';
|
|
||||||
|
|
||||||
interface HeatEvent {
|
|
||||||
userId: string;
|
|
||||||
action: DangerousAction;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HeatSystem {
|
|
||||||
private heatCache: Map<string, HeatEvent[]> = new Map();
|
|
||||||
|
|
||||||
isWhitelisted(userId: string): boolean {
|
|
||||||
return config.security.whitelistedUsers.includes(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordAction(userId: string, action: DangerousAction, guild: Guild): Promise<boolean> {
|
|
||||||
if (this.isWhitelisted(userId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const event: HeatEvent = { userId, action, timestamp: now };
|
|
||||||
|
|
||||||
await prisma.heatEvent.create({
|
|
||||||
data: { userId, action, timestamp: now },
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = this.heatCache.get(userId) || [];
|
|
||||||
events.push(event);
|
|
||||||
|
|
||||||
const windowStart = new Date(now.getTime() - config.security.heatWindowMs);
|
|
||||||
const recentEvents = events.filter(e => e.timestamp >= windowStart);
|
|
||||||
this.heatCache.set(userId, recentEvents);
|
|
||||||
|
|
||||||
console.log(`🔥 Heat event: ${action} by ${userId} (${recentEvents.length}/${config.security.heatThreshold})`);
|
|
||||||
|
|
||||||
if (recentEvents.length >= config.security.heatThreshold) {
|
|
||||||
console.log(`🚨 THRESHOLD EXCEEDED for ${userId}!`);
|
|
||||||
await this.triggerLockdown(userId, guild, recentEvents);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async triggerLockdown(userId: string, guild: Guild, events: HeatEvent[]): Promise<void> {
|
|
||||||
console.log(`🔒 LOCKDOWN INITIATED in ${guild.name}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const member = await guild.members.fetch(userId).catch(() => null);
|
|
||||||
if (member && member.bannable) {
|
|
||||||
await member.ban({
|
|
||||||
reason: `[SENTINEL] Anti-nuke triggered: ${events.length} dangerous actions in ${config.security.heatWindowMs}ms`,
|
|
||||||
deleteMessageSeconds: 0,
|
|
||||||
});
|
|
||||||
console.log(`🔨 Banned ${member.user.tag} for nuke attempt`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to ban attacker:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const everyoneRole = guild.roles.everyone;
|
|
||||||
await everyoneRole.setPermissions(
|
|
||||||
everyoneRole.permissions.remove([
|
|
||||||
PermissionFlagsBits.SendMessages,
|
|
||||||
PermissionFlagsBits.AddReactions,
|
|
||||||
PermissionFlagsBits.CreatePublicThreads,
|
|
||||||
PermissionFlagsBits.CreatePrivateThreads,
|
|
||||||
]),
|
|
||||||
'[SENTINEL] Lockdown mode activated'
|
|
||||||
);
|
|
||||||
console.log(`🔒 Locked down @everyone permissions`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to modify @everyone permissions:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendLockdownAlert(guild, userId, events);
|
|
||||||
|
|
||||||
await prisma.user.upsert({
|
|
||||||
where: { id: userId },
|
|
||||||
update: { heatLevel: events.length },
|
|
||||||
create: { id: userId, heatLevel: events.length },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendLockdownAlert(guild: Guild, attackerId: string, events: HeatEvent[]): Promise<void> {
|
|
||||||
const owner = await guild.fetchOwner().catch(() => null);
|
|
||||||
if (!owner) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await owner.send({
|
|
||||||
content: `🚨 **LOCKDOWN ALERT** 🚨\n\n` +
|
|
||||||
`Server: **${guild.name}**\n` +
|
|
||||||
`Attacker ID: \`${attackerId}\`\n` +
|
|
||||||
`Actions detected: ${events.length}\n` +
|
|
||||||
`Actions: ${events.map(e => e.action).join(', ')}\n\n` +
|
|
||||||
`The attacker has been banned and @everyone permissions have been restricted.\n` +
|
|
||||||
`Please review audit logs and restore permissions when safe.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to DM server owner:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async unlockGuild(guild: Guild): Promise<void> {
|
|
||||||
try {
|
|
||||||
const everyoneRole = guild.roles.everyone;
|
|
||||||
await everyoneRole.setPermissions(
|
|
||||||
everyoneRole.permissions.add([
|
|
||||||
PermissionFlagsBits.SendMessages,
|
|
||||||
PermissionFlagsBits.AddReactions,
|
|
||||||
]),
|
|
||||||
'[SENTINEL] Lockdown lifted'
|
|
||||||
);
|
|
||||||
console.log(`🔓 Lifted lockdown in ${guild.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to unlock guild:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHeat(userId: string): void {
|
|
||||||
this.heatCache.delete(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const heatSystem = new HeatSystem();
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"emitDecoratorMetadata": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Add Bot - Bot Master{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>Add New Bot</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="max-width: 600px;">
|
|
||||||
<form method="POST">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="name">Bot Name *</label>
|
|
||||||
<input type="text" id="name" name="name" class="form-control" required placeholder="e.g., AeThex Main Bot">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="description">Description</label>
|
|
||||||
<textarea id="description" name="description" class="form-control" placeholder="What does this bot do?"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bot_type">Bot Type</label>
|
|
||||||
<select id="bot_type" name="bot_type" class="form-control">
|
|
||||||
<option value="discord">Discord Bot</option>
|
|
||||||
<option value="telegram">Telegram Bot</option>
|
|
||||||
<option value="slack">Slack Bot</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="health_endpoint">Health Check Endpoint</label>
|
|
||||||
<input type="url" id="health_endpoint" name="health_endpoint" class="form-control" placeholder="https://your-bot.host.com:8044/health">
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 0.5rem;">
|
|
||||||
The URL where your bot exposes its health/status endpoint
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="admin_token">Admin Token (Optional)</label>
|
|
||||||
<input type="password" id="admin_token" name="admin_token" class="form-control" placeholder="Bearer token for authenticated endpoints">
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 0.5rem;">
|
|
||||||
Used for accessing protected bot management endpoints
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; margin-top: 2rem;">
|
|
||||||
<button type="submit" class="btn btn-primary">Add Bot</button>
|
|
||||||
<a href="/" class="btn btn-secondary">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,274 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}Bot Master{% endblock %}</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--bg-primary: #0f0f23;
|
|
||||||
--bg-secondary: #1a1a35;
|
|
||||||
--bg-card: #252545;
|
|
||||||
--text-primary: #e0e0ff;
|
|
||||||
--text-secondary: #9090b0;
|
|
||||||
--accent: #7c5cff;
|
|
||||||
--accent-hover: #9070ff;
|
|
||||||
--success: #00cc88;
|
|
||||||
--warning: #ffaa00;
|
|
||||||
--danger: #ff4466;
|
|
||||||
--border: #3a3a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-nav a {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-nav a:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-messages {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
background: rgba(0, 204, 136, 0.2);
|
|
||||||
border: 1px solid var(--success);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-error {
|
|
||||||
background: rgba(255, 68, 102, 0.2);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--danger);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #ff6688;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.form-control {
|
|
||||||
min-height: 100px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background: rgba(0, 204, 136, 0.2);
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background: rgba(255, 68, 102, 0.2);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-unknown {
|
|
||||||
background: rgba(144, 144, 176, 0.2);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-timeout, .status-error {
|
|
||||||
background: rgba(255, 170, 0, 0.2);
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online .pulse {
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% block extra_css %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar">
|
|
||||||
<a href="/" class="navbar-brand">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
||||||
<circle cx="8.5" cy="10" r="1.5"/>
|
|
||||||
<circle cx="15.5" cy="10" r="1.5"/>
|
|
||||||
<path d="M8 15h8"/>
|
|
||||||
</svg>
|
|
||||||
Bot Master
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-nav">
|
|
||||||
<li><a href="/">Dashboard</a></li>
|
|
||||||
<li><a href="/bots">All Bots</a></li>
|
|
||||||
<li><a href="/bots/add">Add Bot</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="flash-messages">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}All Bots - Bot Master{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.bots-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bots-table th,
|
|
||||||
.bots-table td {
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bots-table th {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bots-table tr:hover {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-name-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>All Bots</h1>
|
|
||||||
<a href="/bots/add" class="btn btn-primary">Add Bot</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
{% if bots %}
|
|
||||||
<table class="bots-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bot</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Servers</th>
|
|
||||||
<th>Commands</th>
|
|
||||||
<th>Last Checked</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for bot in bots %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="bot-name-cell">
|
|
||||||
<div class="bot-icon">{{ bot.name[0].upper() }}</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-weight: 500;">{{ bot.name }}</div>
|
|
||||||
<div style="font-size: 0.875rem; color: var(--text-secondary);">{{ bot.bot_type|capitalize }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="status-badge status-{{ bot.status }}">
|
|
||||||
<span class="pulse"></span>
|
|
||||||
{{ bot.status|capitalize }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ bot.guild_count or 0 }}</td>
|
|
||||||
<td>{{ bot.command_count or 0 }}</td>
|
|
||||||
<td>
|
|
||||||
{% if bot.last_checked %}
|
|
||||||
{{ bot.last_checked.strftime('%b %d, %H:%M') }}
|
|
||||||
{% else %}
|
|
||||||
Never
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a href="/bots/{{ bot.id }}" class="btn btn-secondary btn-sm">View</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p style="text-align: center; color: var(--text-secondary); padding: 2rem;">No bots added yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Dashboard - Bot Master{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bots-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-name {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-description {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-stat {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-stat-value {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-stat-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state h2 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-all-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 2rem;
|
|
||||||
right: 2rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 4px 12px rgba(124, 92, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-all-btn.loading svg {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>Dashboard</h1>
|
|
||||||
<a href="/bots/add" class="btn btn-primary">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M12 5v14M5 12h14"/>
|
|
||||||
</svg>
|
|
||||||
Add Bot
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ stats.total_bots }}</div>
|
|
||||||
<div class="stat-label">Total Bots</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ stats.online_bots }}</div>
|
|
||||||
<div class="stat-label">Online</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ stats.total_guilds }}</div>
|
|
||||||
<div class="stat-label">Servers</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-value">{{ stats.total_commands }}</div>
|
|
||||||
<div class="stat-label">Commands</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if bots %}
|
|
||||||
<h2>Your Bots</h2>
|
|
||||||
<div class="bots-grid">
|
|
||||||
{% for bot in bots %}
|
|
||||||
<div class="bot-card" data-bot-id="{{ bot.id }}">
|
|
||||||
<div class="bot-header">
|
|
||||||
<div class="bot-name">{{ bot.name }}</div>
|
|
||||||
<span class="status-badge status-{{ bot.status }}">
|
|
||||||
<span class="pulse"></span>
|
|
||||||
{{ bot.status|capitalize }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if bot.description %}
|
|
||||||
<div class="bot-description">{{ bot.description[:100] }}{% if bot.description|length > 100 %}...{% endif %}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="bot-stats">
|
|
||||||
<div class="bot-stat">
|
|
||||||
<div class="bot-stat-value">{{ bot.guild_count or 0 }}</div>
|
|
||||||
<div class="bot-stat-label">Servers</div>
|
|
||||||
</div>
|
|
||||||
<div class="bot-stat">
|
|
||||||
<div class="bot-stat-value">{{ bot.command_count or 0 }}</div>
|
|
||||||
<div class="bot-stat-label">Commands</div>
|
|
||||||
</div>
|
|
||||||
<div class="bot-stat">
|
|
||||||
<div class="bot-stat-value">{{ (bot.uptime_seconds // 3600) if bot.uptime_seconds else 0 }}h</div>
|
|
||||||
<div class="bot-stat-label">Uptime</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bot-actions">
|
|
||||||
<a href="/bots/{{ bot.id }}" class="btn btn-secondary btn-sm">View Details</a>
|
|
||||||
<button class="btn btn-secondary btn-sm check-health-btn" data-bot-id="{{ bot.id }}">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M23 4v6h-6M1 20v-6h6"/>
|
|
||||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
|
||||||
</svg>
|
|
||||||
Check
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary refresh-all-btn" id="refreshAllBtn" title="Refresh All Bots">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M23 4v6h-6M1 20v-6h6"/>
|
|
||||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<h2>No Bots Added Yet</h2>
|
|
||||||
<p>Start by adding your first Discord bot to manage it from this dashboard.</p>
|
|
||||||
<a href="/bots/add" class="btn btn-primary">Add Your First Bot</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.check-health-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async function() {
|
|
||||||
const botId = this.dataset.botId;
|
|
||||||
const card = this.closest('.bot-card');
|
|
||||||
const badge = card.querySelector('.status-badge');
|
|
||||||
|
|
||||||
this.disabled = true;
|
|
||||||
this.innerHTML = '<svg class="spinning" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg> Checking...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/bots/${botId}/check`, { method: 'POST' });
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
badge.className = `status-badge status-${result.status}`;
|
|
||||||
badge.innerHTML = `<span class="pulse"></span> ${result.status.charAt(0).toUpperCase() + result.status.slice(1)}`;
|
|
||||||
|
|
||||||
if (result.data) {
|
|
||||||
card.querySelector('.bot-stat-value').textContent = result.data.guilds || result.data.guildCount || 0;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking health:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.disabled = false;
|
|
||||||
this.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg> Check';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('refreshAllBtn')?.addEventListener('click', async function() {
|
|
||||||
this.classList.add('loading');
|
|
||||||
this.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/check-all', { method: 'POST' });
|
|
||||||
location.reload();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing all:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.classList.remove('loading');
|
|
||||||
this.disabled = false;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.spinning { animation: spin 1s linear infinite; }
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Edit {{ bot.name }} - Bot Master{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>Edit Bot</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="max-width: 600px;">
|
|
||||||
<form method="POST">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="name">Bot Name *</label>
|
|
||||||
<input type="text" id="name" name="name" class="form-control" required value="{{ bot.name }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="description">Description</label>
|
|
||||||
<textarea id="description" name="description" class="form-control">{{ bot.description or '' }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="bot_type">Bot Type</label>
|
|
||||||
<select id="bot_type" name="bot_type" class="form-control">
|
|
||||||
<option value="discord" {% if bot.bot_type == 'discord' %}selected{% endif %}>Discord Bot</option>
|
|
||||||
<option value="telegram" {% if bot.bot_type == 'telegram' %}selected{% endif %}>Telegram Bot</option>
|
|
||||||
<option value="slack" {% if bot.bot_type == 'slack' %}selected{% endif %}>Slack Bot</option>
|
|
||||||
<option value="other" {% if bot.bot_type == 'other' %}selected{% endif %}>Other</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="health_endpoint">Health Check Endpoint</label>
|
|
||||||
<input type="url" id="health_endpoint" name="health_endpoint" class="form-control" value="{{ bot.health_endpoint or '' }}" placeholder="https://your-bot.host.com:8044/health">
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 0.5rem;">
|
|
||||||
The URL where your bot exposes its health/status endpoint
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="admin_token">Admin Token</label>
|
|
||||||
<input type="password" id="admin_token" name="admin_token" class="form-control" placeholder="Leave blank to keep unchanged">
|
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 0.5rem;">
|
|
||||||
Used for accessing protected bot management endpoints. Leave blank to keep current settings.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; margin-top: 2rem;">
|
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
||||||
<a href="/bots/{{ bot.id }}" class="btn btn-secondary">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ bot.name }} - Bot Master{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.bot-header-section {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-icon-large {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-info h1 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-type-badge {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-actions-top {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card h3 {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-value {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item .value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item .label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.endpoint-display {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
word-break: break-all;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone {
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: rgba(255, 68, 102, 0.1);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger-zone h3 {
|
|
||||||
color: var(--danger);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="bot-header-section">
|
|
||||||
<div class="bot-title">
|
|
||||||
<div class="bot-icon-large">{{ bot.name[0].upper() }}</div>
|
|
||||||
<div class="bot-info">
|
|
||||||
<h1>{{ bot.name }}</h1>
|
|
||||||
<span class="bot-type-badge">{{ bot.bot_type|capitalize }} Bot</span>
|
|
||||||
</div>
|
|
||||||
<span class="status-badge status-{{ bot.status }}" style="margin-left: 1rem;">
|
|
||||||
<span class="pulse"></span>
|
|
||||||
{{ bot.status|capitalize }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="bot-actions-top">
|
|
||||||
<button class="btn btn-secondary" id="checkHealthBtn">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M23 4v6h-6M1 20v-6h6"/>
|
|
||||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
|
||||||
</svg>
|
|
||||||
Check Status
|
|
||||||
</button>
|
|
||||||
<a href="/bots/{{ bot.id }}/edit" class="btn btn-secondary">Edit</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if bot.description %}
|
|
||||||
<div class="card" style="margin-bottom: 1.5rem;">
|
|
||||||
<p>{{ bot.description }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="info-grid">
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>Statistics</h3>
|
|
||||||
<div class="stats-row">
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="value">{{ bot.guild_count or 0 }}</div>
|
|
||||||
<div class="label">Servers</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="value">{{ bot.command_count or 0 }}</div>
|
|
||||||
<div class="label">Commands</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="value">{{ (bot.uptime_seconds // 3600) if bot.uptime_seconds else 0 }}h</div>
|
|
||||||
<div class="label">Uptime</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>Configuration</h3>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Health Endpoint</span>
|
|
||||||
</div>
|
|
||||||
{% if bot.health_endpoint %}
|
|
||||||
<div class="endpoint-display">{{ bot.health_endpoint }}</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="endpoint-display" style="color: var(--text-secondary);">Not configured</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="info-item" style="margin-top: 1rem;">
|
|
||||||
<span class="info-label">Admin Token</span>
|
|
||||||
<span class="info-value">{{ "Configured" if bot.admin_token else "Not set" }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<h3>Details</h3>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Created</span>
|
|
||||||
<span class="info-value">{{ bot.created_at.strftime('%b %d, %Y %H:%M') if bot.created_at else 'Unknown' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Last Checked</span>
|
|
||||||
<span class="info-value">{{ bot.last_checked.strftime('%b %d, %Y %H:%M') if bot.last_checked else 'Never' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Last Updated</span>
|
|
||||||
<span class="info-value">{{ bot.updated_at.strftime('%b %d, %Y %H:%M') if bot.updated_at else 'Unknown' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="danger-zone">
|
|
||||||
<h3>Danger Zone</h3>
|
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 1rem;">Once you delete a bot, there is no going back. Please be certain.</p>
|
|
||||||
<form action="/bots/{{ bot.id }}/delete" method="POST" onsubmit="return confirm('Are you sure you want to delete this bot?');">
|
|
||||||
<button type="submit" class="btn btn-danger">Delete Bot</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
document.getElementById('checkHealthBtn').addEventListener('click', async function() {
|
|
||||||
const btn = this;
|
|
||||||
const badge = document.querySelector('.status-badge');
|
|
||||||
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<svg class="spinning" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg> Checking...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/bots/{{ bot.id }}/check', { method: 'POST' });
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
badge.className = `status-badge status-${result.status}`;
|
|
||||||
badge.innerHTML = `<span class="pulse"></span> ${result.status.charAt(0).toUpperCase() + result.status.slice(1)}`;
|
|
||||||
|
|
||||||
if (result.data) {
|
|
||||||
setTimeout(() => location.reload(), 500);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg> Check Status';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<style>.spinning { animation: spin 1s linear infinite; }</style>
|
|
||||||
{% endblock %}
|
|
||||||
Loading…
Reference in a new issue