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
|
||||
|
||||
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
|
||||
- **Status Monitoring**: Real-time health checks for each bot
|
||||
- **Statistics**: Track servers, commands, and uptime across all bots
|
||||
- **CRUD Operations**: Add, edit, view, and delete bot configurations
|
||||
- **Sentinel Security**: Anti-nuke protection with RAM-based heat tracking
|
||||
- **Federation Sync**: Cross-server role synchronization across 5 realms
|
||||
- **Ticket System**: Support tickets with automatic channel creation
|
||||
- **Admin Monitoring**: Real-time status, threat monitoring, server overview
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Python/Flask
|
||||
- **Database**: PostgreSQL (via Flask-SQLAlchemy)
|
||||
- **Frontend**: Jinja2 templates with custom CSS
|
||||
|
||||
## 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)
|
||||
- **Runtime**: Node.js 20
|
||||
- **Framework**: discord.js v14
|
||||
- **Database**: Supabase (optional, for user verification)
|
||||
- **Health Endpoint**: HTTP server on port 8080
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
sentinel-bot/
|
||||
├── src/
|
||||
│ ├── 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
|
||||
aethex-bot/
|
||||
├── bot.js # Main entry point
|
||||
├── 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
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/federation link` | Link a role across servers |
|
||||
| `/federation unlink` | Remove a role mapping |
|
||||
| `/federation list` | Show all role mappings |
|
||||
| `/sentinel heat` | View heat level of a user |
|
||||
| `/sentinel lockdown` | Enable/disable lockdown mode |
|
||||
| `/sentinel config` | Configure security thresholds |
|
||||
| `/ticket create` | Create a support ticket |
|
||||
| `/ticket close` | Close a ticket with transcript |
|
||||
| `/admin status` | View bot status and statistics |
|
||||
| `/admin heat @user` | Check heat level of a user |
|
||||
| `/admin servers` | View all connected servers |
|
||||
| `/admin threats` | View active threat monitor |
|
||||
| `/admin federation` | View federation role mappings |
|
||||
| `/federation link @role` | Link a role for cross-server sync |
|
||||
| `/federation unlink @role` | Remove a role from sync |
|
||||
| `/federation list` | List all linked roles |
|
||||
| `/ticket create [reason]` | Create a support ticket |
|
||||
| `/ticket close` | Close the current ticket |
|
||||
| `/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)
|
||||
```json
|
||||
{
|
||||
"status": "online",
|
||||
"guilds": 5,
|
||||
"commands": 12,
|
||||
"commands": 4,
|
||||
"uptime": 3600,
|
||||
"timestamp": "2025-12-07T12:00:00.000Z",
|
||||
"bot": {
|
||||
"tag": "Aethex Sentinel#1234",
|
||||
"id": "123456789"
|
||||
}
|
||||
"heatMapSize": 0,
|
||||
"timestamp": "2025-12-07T22:15:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**GET /stats** (port 8080)
|
||||
```json
|
||||
{
|
||||
"guilds": [
|
||||
{ "id": "...", "name": "...", "memberCount": 100 }
|
||||
],
|
||||
"guilds": [...],
|
||||
"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
|
||||
|
||||
```bash
|
||||
cd sentinel-bot
|
||||
cd aethex-bot
|
||||
npm install
|
||||
npx prisma generate
|
||||
npx prisma db push
|
||||
npm run build
|
||||
node scripts/register-commands.js # Register slash commands (run once)
|
||||
npm start
|
||||
```
|
||||
|
||||
For development:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
## Current Status
|
||||
|
||||
## Integration with Bot Master
|
||||
|
||||
Once the Sentinel bot is running, add it to Bot Master dashboard:
|
||||
1. Go to Bot Master dashboard (port 5000)
|
||||
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.
|
||||
- Bot is running and connected to 5 servers
|
||||
- All 4 commands registered (/admin, /federation, /status, /ticket)
|
||||
- Sentinel listeners active (channel/role delete, ban/kick monitoring)
|
||||
- Health endpoint available at port 8080
|
||||
|
|
|
|||
|
|
@ -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